⚡ Introduce a new high-performing standalone chat API (#1200)
Closes #1154 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added authentication functionality for user sessions in chat API. - Introduced chat-related API endpoints for starting, previewing, and continuing chat sessions, and streaming messages. - Implemented WhatsApp API webhook handling for receiving and processing messages. - Added environment variable `NEXT_PUBLIC_CHAT_API_URL` for chat API URL configuration. - **Bug Fixes** - Adjusted file upload logic to correctly determine the API host. - Fixed message streaming URL in chat integration with OpenAI. - **Documentation** - Updated guides for creating blocks, local installation, self-hosting, and deployment to use `bun` instead of `pnpm`. - **Refactor** - Refactored chat API functionalities to use modular architecture. - Simplified client log saving and session update functionalities by using external functions. - Transitioned package management and workflow commands to use `bun`. - **Chores** - Switched to `bun` for package management in Dockerfiles and GitHub workflows. - Added new Dockerfile for chat API service setup with Bun framework. - Updated `.prettierignore` and documentation with new commands. - **Style** - No visible changes to end-users. - **Tests** - No visible changes to end-users. - **Revert** - No reverts in this release. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@ -17,6 +17,12 @@ const injectViewerUrlIfVercelPreview = (val) => {
|
||||
)
|
||||
return
|
||||
process.env.NEXT_PUBLIC_VIEWER_URL = `https://${process.env.VERCEL_BRANCH_URL}`
|
||||
if (process.env.NEXT_PUBLIC_CHAT_API_URL.includes('{{pr_id}}'))
|
||||
process.env.NEXT_PUBLIC_CHAT_API_URL =
|
||||
process.env.NEXT_PUBLIC_CHAT_API_URL.replace(
|
||||
'{{pr_id}}',
|
||||
process.env.VERCEL_GIT_PULL_REQUEST_ID
|
||||
)
|
||||
}
|
||||
|
||||
injectViewerUrlIfVercelPreview(process.env.NEXT_PUBLIC_VIEWER_URL)
|
||||
|
@ -28,7 +28,7 @@
|
||||
"got": "12.6.0",
|
||||
"next": "14.1.0",
|
||||
"nextjs-cors": "2.1.2",
|
||||
"nodemailer": "6.9.3",
|
||||
"nodemailer": "6.9.8",
|
||||
"openai": "4.28.4",
|
||||
"qs": "6.11.2",
|
||||
"react": "18.2.0",
|
||||
@ -50,7 +50,7 @@
|
||||
"@typebot.io/variables": "workspace:*",
|
||||
"@types/cors": "2.8.13",
|
||||
"@types/node": "20.4.2",
|
||||
"@types/nodemailer": "6.4.8",
|
||||
"@types/nodemailer": "6.4.14",
|
||||
"@types/papaparse": "5.3.7",
|
||||
"@types/qs": "6.9.7",
|
||||
"@types/react": "18.2.15",
|
||||
|
@ -0,0 +1,45 @@
|
||||
import { getMessageStream } from '@typebot.io/bot-engine/apiHandlers/getMessageStream'
|
||||
import { StreamingTextResponse } from 'ai'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const responseHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Expose-Headers': 'Content-Length, X-JSON',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response('ok', {
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'POST',
|
||||
'Access-Control-Expose-Headers': 'Content-Length, X-JSON',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: { sessionId: string } }
|
||||
) {
|
||||
if (process.env.VERCEL_ENV)
|
||||
return NextResponse.json(
|
||||
{ message: "Can't get streaming if hosted on Vercel" },
|
||||
{ status: 400, headers: responseHeaders }
|
||||
)
|
||||
const messages =
|
||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||
const { stream, status, message } = await getMessageStream({
|
||||
sessionId: params.sessionId,
|
||||
messages,
|
||||
})
|
||||
if (!stream)
|
||||
return NextResponse.json({ message }, { status, headers: responseHeaders })
|
||||
return new StreamingTextResponse(stream, {
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
@ -1,14 +1,7 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { continueChatResponseSchema } from '@typebot.io/schemas/features/chat/schema'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { getSession } from '@typebot.io/bot-engine/queries/getSession'
|
||||
import { saveStateToDatabase } from '@typebot.io/bot-engine/saveStateToDatabase'
|
||||
import { continueBotFlow } from '@typebot.io/bot-engine/continueBotFlow'
|
||||
import { parseDynamicTheme } from '@typebot.io/bot-engine/parseDynamicTheme'
|
||||
import { isDefined, isNotDefined } from '@typebot.io/lib/utils'
|
||||
import { z } from 'zod'
|
||||
import { filterPotentiallySensitiveLogs } from '@typebot.io/bot-engine/logs/filterPotentiallySensitiveLogs'
|
||||
import { computeCurrentProgress } from '@typebot.io/bot-engine/computeCurrentProgress'
|
||||
import { continueChat as continueChatFn } from '@typebot.io/bot-engine/apiHandlers/continueChat'
|
||||
|
||||
export const continueChat = publicProcedure
|
||||
.meta({
|
||||
@ -29,92 +22,12 @@ export const continueChat = publicProcedure
|
||||
})
|
||||
)
|
||||
.output(continueChatResponseSchema)
|
||||
.mutation(async ({ input: { sessionId, message }, ctx: { res, origin } }) => {
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Session not found.',
|
||||
})
|
||||
}
|
||||
|
||||
const isSessionExpired =
|
||||
session &&
|
||||
isDefined(session.state.expiryTimeout) &&
|
||||
session.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
|
||||
|
||||
if (isSessionExpired)
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Session expired. You need to start a new session.',
|
||||
})
|
||||
|
||||
if (
|
||||
session?.state.allowedOrigins &&
|
||||
session.state.allowedOrigins.length > 0
|
||||
) {
|
||||
if (origin && session.state.allowedOrigins.includes(origin))
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
else
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Origin',
|
||||
session.state.allowedOrigins[0]
|
||||
)
|
||||
}
|
||||
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
clientSideActions,
|
||||
newSessionState,
|
||||
logs,
|
||||
lastMessageNewFormat,
|
||||
visitedEdges,
|
||||
} = await continueBotFlow(message, {
|
||||
version: 2,
|
||||
state: session.state,
|
||||
startTime: Date.now(),
|
||||
.mutation(async ({ input: { sessionId, message }, ctx: { origin, res } }) => {
|
||||
const { corsOrigin, ...response } = await continueChatFn({
|
||||
origin,
|
||||
sessionId,
|
||||
message,
|
||||
})
|
||||
|
||||
if (newSessionState)
|
||||
await saveStateToDatabase({
|
||||
session: {
|
||||
id: session.id,
|
||||
state: newSessionState,
|
||||
},
|
||||
input,
|
||||
logs,
|
||||
clientSideActions,
|
||||
visitedEdges,
|
||||
hasCustomEmbedBubble: messages.some(
|
||||
(message) => message.type === 'custom-embed'
|
||||
),
|
||||
})
|
||||
|
||||
const isPreview = isNotDefined(session.state.typebotsQueue[0].resultId)
|
||||
|
||||
const isEnded =
|
||||
newSessionState.progressMetadata &&
|
||||
!input?.id &&
|
||||
(clientSideActions?.filter((c) => c.expectsDedicatedReply).length ??
|
||||
0) === 0
|
||||
|
||||
return {
|
||||
messages,
|
||||
input,
|
||||
clientSideActions,
|
||||
dynamicTheme: parseDynamicTheme(newSessionState),
|
||||
logs: isPreview ? logs : logs?.filter(filterPotentiallySensitiveLogs),
|
||||
lastMessageNewFormat,
|
||||
progress: newSessionState.progressMetadata
|
||||
? isEnded
|
||||
? 100
|
||||
: computeCurrentProgress({
|
||||
typebotsQueue: newSessionState.typebotsQueue,
|
||||
progressMetadata: newSessionState.progressMetadata,
|
||||
currentInputBlockId: input?.id,
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
if (corsOrigin) res.setHeader('Access-Control-Allow-Origin', corsOrigin)
|
||||
return response
|
||||
})
|
||||
|
@ -1,11 +1,7 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { chatLogSchema } from '@typebot.io/schemas/features/chat/schema'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { getSession } from '@typebot.io/bot-engine/queries/getSession'
|
||||
import { z } from 'zod'
|
||||
import { saveLogs } from '@typebot.io/bot-engine/queries/saveLogs'
|
||||
import { formatLogDetails } from '@typebot.io/bot-engine/logs/helpers/formatLogDetails'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { saveClientLogs as saveClientLogsFn } from '@typebot.io/bot-engine/apiHandlers/saveClientLogs'
|
||||
|
||||
export const saveClientLogs = publicProcedure
|
||||
.meta({
|
||||
@ -22,42 +18,6 @@ export const saveClientLogs = publicProcedure
|
||||
})
|
||||
)
|
||||
.output(z.object({ message: z.string() }))
|
||||
.mutation(async ({ input: { sessionId, clientLogs } }) => {
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
if (!session) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Session not found.',
|
||||
})
|
||||
}
|
||||
|
||||
const resultId = session.state.typebotsQueue[0].resultId
|
||||
|
||||
if (!resultId) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Result not found.',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await saveLogs(
|
||||
clientLogs.map((log) => ({
|
||||
...log,
|
||||
resultId,
|
||||
details: formatLogDetails(log.details),
|
||||
}))
|
||||
)
|
||||
return {
|
||||
message: 'Logs successfully saved.',
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save logs', e)
|
||||
Sentry.captureException(e)
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to save logs.',
|
||||
})
|
||||
}
|
||||
})
|
||||
.mutation(({ input: { sessionId, clientLogs } }) =>
|
||||
saveClientLogsFn({ sessionId, clientLogs })
|
||||
)
|
||||
|
@ -3,11 +3,7 @@ import {
|
||||
startChatInputSchema,
|
||||
startChatResponseSchema,
|
||||
} from '@typebot.io/schemas/features/chat/schema'
|
||||
import { startSession } from '@typebot.io/bot-engine/startSession'
|
||||
import { saveStateToDatabase } from '@typebot.io/bot-engine/saveStateToDatabase'
|
||||
import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
|
||||
import { filterPotentiallySensitiveLogs } from '@typebot.io/bot-engine/logs/filterPotentiallySensitiveLogs'
|
||||
import { computeCurrentProgress } from '@typebot.io/bot-engine/computeCurrentProgress'
|
||||
import { startChat as startChatFn } from '@typebot.io/bot-engine/apiHandlers/startChat'
|
||||
|
||||
export const startChat = publicProcedure
|
||||
.meta({
|
||||
@ -19,99 +15,11 @@ export const startChat = publicProcedure
|
||||
})
|
||||
.input(startChatInputSchema)
|
||||
.output(startChatResponseSchema)
|
||||
.mutation(
|
||||
async ({
|
||||
input: {
|
||||
message,
|
||||
isOnlyRegistering,
|
||||
publicId,
|
||||
isStreamEnabled,
|
||||
prefilledVariables,
|
||||
resultId: startResultId,
|
||||
},
|
||||
ctx: { origin, res },
|
||||
}) => {
|
||||
const {
|
||||
typebot,
|
||||
messages,
|
||||
input,
|
||||
resultId,
|
||||
dynamicTheme,
|
||||
logs,
|
||||
clientSideActions,
|
||||
newSessionState,
|
||||
visitedEdges,
|
||||
} = await startSession({
|
||||
version: 2,
|
||||
startParams: {
|
||||
type: 'live',
|
||||
isOnlyRegistering,
|
||||
isStreamEnabled,
|
||||
publicId,
|
||||
prefilledVariables,
|
||||
resultId: startResultId,
|
||||
},
|
||||
message,
|
||||
})
|
||||
|
||||
if (
|
||||
newSessionState.allowedOrigins &&
|
||||
newSessionState.allowedOrigins.length > 0
|
||||
) {
|
||||
if (origin && newSessionState.allowedOrigins.includes(origin))
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
else
|
||||
res.setHeader(
|
||||
'Access-Control-Allow-Origin',
|
||||
newSessionState.allowedOrigins[0]
|
||||
)
|
||||
}
|
||||
|
||||
const session = isOnlyRegistering
|
||||
? await restartSession({
|
||||
state: newSessionState,
|
||||
})
|
||||
: await saveStateToDatabase({
|
||||
session: {
|
||||
state: newSessionState,
|
||||
},
|
||||
input,
|
||||
logs,
|
||||
clientSideActions,
|
||||
visitedEdges,
|
||||
hasCustomEmbedBubble: messages.some(
|
||||
(message) => message.type === 'custom-embed'
|
||||
),
|
||||
})
|
||||
|
||||
const isEnded =
|
||||
newSessionState.progressMetadata &&
|
||||
!input?.id &&
|
||||
(clientSideActions?.filter((c) => c.expectsDedicatedReply).length ??
|
||||
0) === 0
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
typebot: {
|
||||
id: typebot.id,
|
||||
theme: typebot.theme,
|
||||
settings: typebot.settings,
|
||||
},
|
||||
messages,
|
||||
input,
|
||||
resultId,
|
||||
dynamicTheme,
|
||||
logs: logs?.filter(filterPotentiallySensitiveLogs),
|
||||
clientSideActions,
|
||||
progress: newSessionState.progressMetadata
|
||||
? isEnded
|
||||
? 100
|
||||
: computeCurrentProgress({
|
||||
typebotsQueue: newSessionState.typebotsQueue,
|
||||
progressMetadata: newSessionState.progressMetadata,
|
||||
currentInputBlockId: input?.id,
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
)
|
||||
.mutation(async ({ input, ctx: { origin, res } }) => {
|
||||
const { corsOrigin, ...response } = await startChatFn({
|
||||
...input,
|
||||
origin,
|
||||
})
|
||||
if (corsOrigin) res.setHeader('Access-Control-Allow-Origin', corsOrigin)
|
||||
return response
|
||||
})
|
||||
|
@ -2,11 +2,8 @@ import {
|
||||
startPreviewChatInputSchema,
|
||||
startPreviewChatResponseSchema,
|
||||
} from '@typebot.io/schemas/features/chat/schema'
|
||||
import { startSession } from '@typebot.io/bot-engine/startSession'
|
||||
import { saveStateToDatabase } from '@typebot.io/bot-engine/saveStateToDatabase'
|
||||
import { restartSession } from '@typebot.io/bot-engine/queries/restartSession'
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { computeCurrentProgress } from '@typebot.io/bot-engine/computeCurrentProgress'
|
||||
import { startChatPreview as startChatPreviewFn } from '@typebot.io/bot-engine/apiHandlers/startChatPreview'
|
||||
|
||||
export const startChatPreview = publicProcedure
|
||||
.meta({
|
||||
@ -32,75 +29,15 @@ export const startChatPreview = publicProcedure
|
||||
prefilledVariables,
|
||||
},
|
||||
ctx: { user },
|
||||
}) => {
|
||||
const {
|
||||
typebot,
|
||||
messages,
|
||||
input,
|
||||
dynamicTheme,
|
||||
logs,
|
||||
clientSideActions,
|
||||
newSessionState,
|
||||
visitedEdges,
|
||||
} = await startSession({
|
||||
version: 2,
|
||||
startParams: {
|
||||
type: 'preview',
|
||||
isOnlyRegistering,
|
||||
isStreamEnabled,
|
||||
startFrom,
|
||||
typebotId,
|
||||
typebot: startTypebot,
|
||||
userId: user?.id,
|
||||
prefilledVariables,
|
||||
},
|
||||
}) =>
|
||||
startChatPreviewFn({
|
||||
message,
|
||||
isOnlyRegistering,
|
||||
isStreamEnabled,
|
||||
startFrom,
|
||||
typebotId,
|
||||
typebot: startTypebot,
|
||||
userId: user?.id,
|
||||
prefilledVariables,
|
||||
})
|
||||
|
||||
const session = isOnlyRegistering
|
||||
? await restartSession({
|
||||
state: newSessionState,
|
||||
})
|
||||
: await saveStateToDatabase({
|
||||
session: {
|
||||
state: newSessionState,
|
||||
},
|
||||
input,
|
||||
logs,
|
||||
clientSideActions,
|
||||
visitedEdges,
|
||||
hasCustomEmbedBubble: messages.some(
|
||||
(message) => message.type === 'custom-embed'
|
||||
),
|
||||
})
|
||||
|
||||
const isEnded =
|
||||
newSessionState.progressMetadata &&
|
||||
!input?.id &&
|
||||
(clientSideActions?.filter((c) => c.expectsDedicatedReply).length ??
|
||||
0) === 0
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
typebot: {
|
||||
id: typebot.id,
|
||||
theme: typebot.theme,
|
||||
settings: typebot.settings,
|
||||
},
|
||||
messages,
|
||||
input,
|
||||
dynamicTheme,
|
||||
logs,
|
||||
clientSideActions,
|
||||
progress: newSessionState.progressMetadata
|
||||
? isEnded
|
||||
? 100
|
||||
: computeCurrentProgress({
|
||||
typebotsQueue: newSessionState.typebotsQueue,
|
||||
progressMetadata: newSessionState.progressMetadata,
|
||||
currentInputBlockId: input?.id,
|
||||
})
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -1,14 +1,6 @@
|
||||
import { publicProcedure } from '@/helpers/server/trpc'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@typebot.io/bot-engine/queries/getSession'
|
||||
import {
|
||||
PublicTypebot,
|
||||
SessionState,
|
||||
Typebot,
|
||||
Variable,
|
||||
} from '@typebot.io/schemas'
|
||||
import prisma from '@typebot.io/lib/prisma'
|
||||
import { updateTypebotInSession as updateTypebotInSessionFn } from '@typebot.io/bot-engine/apiHandlers/updateTypebotInSession'
|
||||
|
||||
export const updateTypebotInSession = publicProcedure
|
||||
.meta({
|
||||
@ -27,85 +19,6 @@ export const updateTypebotInSession = publicProcedure
|
||||
})
|
||||
)
|
||||
.output(z.object({ message: z.literal('success') }))
|
||||
.mutation(async ({ input: { sessionId }, ctx: { user } }) => {
|
||||
if (!user)
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' })
|
||||
const session = await getSession(sessionId)
|
||||
if (!session)
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' })
|
||||
|
||||
const publicTypebot = (await prisma.publicTypebot.findFirst({
|
||||
where: {
|
||||
typebot: {
|
||||
id: session.state.typebotsQueue[0].typebot.id,
|
||||
OR: [
|
||||
{
|
||||
workspace: {
|
||||
members: {
|
||||
some: { userId: user.id, role: { in: ['ADMIN', 'MEMBER'] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
collaborators: {
|
||||
some: { userId: user.id, type: { in: ['WRITE'] } },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
edges: true,
|
||||
groups: true,
|
||||
variables: true,
|
||||
},
|
||||
})) as Pick<PublicTypebot, 'edges' | 'variables' | 'groups'> | null
|
||||
|
||||
if (!publicTypebot)
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' })
|
||||
|
||||
const newSessionState = updateSessionState(session.state, publicTypebot)
|
||||
|
||||
await prisma.chatSession.updateMany({
|
||||
where: { id: session.id },
|
||||
data: { state: newSessionState },
|
||||
})
|
||||
|
||||
return { message: 'success' }
|
||||
})
|
||||
|
||||
const updateSessionState = (
|
||||
currentState: SessionState,
|
||||
newTypebot: Pick<PublicTypebot, 'edges' | 'variables' | 'groups'>
|
||||
): SessionState => ({
|
||||
...currentState,
|
||||
typebotsQueue: currentState.typebotsQueue.map((typebotInQueue, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...typebotInQueue,
|
||||
typebot: {
|
||||
...typebotInQueue.typebot,
|
||||
edges: newTypebot.edges,
|
||||
groups: newTypebot.groups,
|
||||
variables: updateVariablesInSession(
|
||||
typebotInQueue.typebot.variables,
|
||||
newTypebot.variables
|
||||
),
|
||||
},
|
||||
}
|
||||
: typebotInQueue
|
||||
) as SessionState['typebotsQueue'],
|
||||
})
|
||||
|
||||
const updateVariablesInSession = (
|
||||
currentVariables: Variable[],
|
||||
newVariables: Typebot['variables']
|
||||
): Variable[] => [
|
||||
...currentVariables,
|
||||
...newVariables.filter(
|
||||
(newVariable) =>
|
||||
!currentVariables.find(
|
||||
(currentVariable) => currentVariable.id === newVariable.id
|
||||
)
|
||||
),
|
||||
]
|
||||
.mutation(({ input: { sessionId }, ctx: { user } }) =>
|
||||
updateTypebotInSessionFn({ user, sessionId })
|
||||
)
|
||||
|
Reference in New Issue
Block a user