From 3952ae2755ac2806055072d5cfa4cd32a01693f6 Mon Sep 17 00:00:00 2001 From: Baptiste Arnaud Date: Wed, 19 Jul 2023 11:20:44 +0200 Subject: [PATCH] =?UTF-8?q?:sparkles:=20Stream=20bubble=20content=20if=20p?= =?UTF-8?q?laced=20right=20after=20Op=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #617 --- apps/docs/openapi/chat/_spec_.json | 3 + .../openai/createChatCompletionOpenAI.ts | 62 +++++++++++++- .../integrations/openai/executeOpenAIBlock.ts | 1 + packages/embeds/js/package.json | 2 +- .../ConversationContainer/ChatChunk.tsx | 30 ++++++- .../ConversationContainer.tsx | 82 +++++++++++++++---- .../components/bubbles/StreamingBubble.tsx | 40 +++++++++ .../blocks/integrations/openai/streamChat.ts | 4 +- packages/embeds/js/src/types.ts | 7 ++ .../js/src/utils/executeClientSideActions.ts | 23 ++++-- .../js/src/utils/streamingMessageSignal.ts | 6 ++ packages/embeds/nextjs/package.json | 2 +- packages/embeds/react/package.json | 2 +- packages/schemas/features/chat.ts | 1 + 14 files changed, 231 insertions(+), 34 deletions(-) create mode 100644 packages/embeds/js/src/components/bubbles/StreamingBubble.tsx create mode 100644 packages/embeds/js/src/utils/streamingMessageSignal.ts diff --git a/apps/docs/openapi/chat/_spec_.json b/apps/docs/openapi/chat/_spec_.json index 374452752..8ff8bd7d1 100644 --- a/apps/docs/openapi/chat/_spec_.json +++ b/apps/docs/openapi/chat/_spec_.json @@ -5145,6 +5145,9 @@ }, "additionalProperties": false } + }, + "displayStream": { + "type": "boolean" } }, "required": [ diff --git a/apps/viewer/src/features/blocks/integrations/openai/createChatCompletionOpenAI.ts b/apps/viewer/src/features/blocks/integrations/openai/createChatCompletionOpenAI.ts index b563f2605..b407d7b0e 100644 --- a/apps/viewer/src/features/blocks/integrations/openai/createChatCompletionOpenAI.ts +++ b/apps/viewer/src/features/blocks/integrations/openai/createChatCompletionOpenAI.ts @@ -1,12 +1,12 @@ import { ExecuteIntegrationResponse } from '@/features/chat/types' import prisma from '@/lib/prisma' -import { SessionState } from '@typebot.io/schemas' +import { Block, BubbleBlockType, SessionState } from '@typebot.io/schemas' import { ChatCompletionOpenAIOptions, OpenAICredentials, chatCompletionMessageRoles, } from '@typebot.io/schemas/features/blocks/integrations/openai' -import { isEmpty } from '@typebot.io/lib' +import { byId, isEmpty } from '@typebot.io/lib' import { decrypt, isCredentialsV2 } from '@typebot.io/lib/api/encryption' import { updateVariables } from '@/features/variables/updateVariables' import { parseVariableNumber } from '@/features/variables/parseVariableNumber' @@ -20,7 +20,12 @@ export const createChatCompletionOpenAI = async ( { outgoingEdgeId, options, - }: { outgoingEdgeId?: string; options: ChatCompletionOpenAIOptions } + blockId, + }: { + outgoingEdgeId?: string + options: ChatCompletionOpenAIOptions + blockId: string + } ): Promise => { let newSessionState = state const noCredentialsError = { @@ -60,7 +65,14 @@ export const createChatCompletionOpenAI = async ( isPlaneteScale() && isCredentialsV2(credentials) && newSessionState.isStreamEnabled - ) + ) { + const assistantMessageVariableName = state.typebot.variables.find( + (variable) => + options.responseMapping.find( + (m) => m.valueToExtract === 'Message content' + )?.variableId === variable.id + )?.name + return { clientSideActions: [ { @@ -69,12 +81,17 @@ export const createChatCompletionOpenAI = async ( content?: string role: (typeof chatCompletionMessageRoles)[number] }[], + displayStream: isNextBubbleMessageWithAssistantMessage( + state.typebot + )(blockId, assistantMessageVariableName), }, }, ], outgoingEdgeId, newSessionState, } + } + const { response, logs } = await executeChatCompletionOpenAIRequest({ apiKey, messages, @@ -98,3 +115,40 @@ export const createChatCompletionOpenAI = async ( logs, })(messageContent, totalTokens) } + +const isNextBubbleMessageWithAssistantMessage = + (typebot: SessionState['typebot']) => + (blockId: string, assistantVariableName?: string): boolean => { + if (!assistantVariableName) return false + const nextBlock = getNextBlock(typebot)(blockId) + if (!nextBlock) return false + return ( + nextBlock.type === BubbleBlockType.TEXT && + nextBlock.content.richText?.length > 0 && + nextBlock.content.richText?.at(0)?.children.at(0).text === + `{{${assistantVariableName}}}` + ) + } + +const getNextBlock = + (typebot: SessionState['typebot']) => + (blockId: string): Block | undefined => { + const group = typebot.groups.find((group) => + group.blocks.find(byId(blockId)) + ) + if (!group) return + const blockIndex = group.blocks.findIndex(byId(blockId)) + const nextBlockInGroup = group.blocks.at(blockIndex + 1) + if (nextBlockInGroup) return nextBlockInGroup + const outgoingEdgeId = group.blocks.at(blockIndex)?.outgoingEdgeId + if (!outgoingEdgeId) return + const outgoingEdge = typebot.edges.find(byId(outgoingEdgeId)) + if (!outgoingEdge) return + const connectedGroup = typebot.groups.find(byId(outgoingEdge?.to.groupId)) + if (!connectedGroup) return + return outgoingEdge.to.blockId + ? connectedGroup.blocks.find( + (block) => block.id === outgoingEdge.to.blockId + ) + : connectedGroup?.blocks.at(0) + } diff --git a/apps/viewer/src/features/blocks/integrations/openai/executeOpenAIBlock.ts b/apps/viewer/src/features/blocks/integrations/openai/executeOpenAIBlock.ts index 377c478c4..9bab97818 100644 --- a/apps/viewer/src/features/blocks/integrations/openai/executeOpenAIBlock.ts +++ b/apps/viewer/src/features/blocks/integrations/openai/executeOpenAIBlock.ts @@ -12,6 +12,7 @@ export const executeOpenAIBlock = async ( return createChatCompletionOpenAI(state, { options: block.options, outgoingEdgeId: block.outgoingEdgeId, + blockId: block.id, }) case 'Create image': case undefined: diff --git a/packages/embeds/js/package.json b/packages/embeds/js/package.json index 4d8e57809..33fe5082c 100644 --- a/packages/embeds/js/package.json +++ b/packages/embeds/js/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/js", - "version": "0.1.3", + "version": "0.1.4", "description": "Javascript library to display typebots on your website", "type": "module", "main": "dist/index.js", diff --git a/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx b/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx index d2b5e17e2..73e841290 100644 --- a/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx +++ b/packages/embeds/js/src/components/ConversationContainer/ChatChunk.tsx @@ -1,10 +1,11 @@ -import { BotContext } from '@/types' +import { BotContext, ChatChunk as ChatChunkType } from '@/types' import { isMobile } from '@/utils/isMobileSignal' import type { ChatReply, Settings, Theme } from '@typebot.io/schemas' import { createSignal, For, onMount, Show } from 'solid-js' import { HostBubble } from '../bubbles/HostBubble' import { InputChatBlock } from '../InputChatBlock' import { AvatarSideContainer } from './AvatarSideContainer' +import { StreamingBubble } from '../bubbles/StreamingBubble' type Props = Pick & { theme: Theme @@ -13,6 +14,7 @@ type Props = Pick & { context: BotContext hasError: boolean hideAvatar: boolean + streamingMessageId: ChatChunkType['streamingMessageId'] onNewBubbleDisplayed: (blockId: string) => Promise onScrollToBottom: (top?: number) => void onSubmit: (input: string) => void @@ -25,6 +27,7 @@ export const ChatChunk = (props: Props) => { const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0) onMount(() => { + if (props.streamingMessageId) return if (props.messages.length === 0) { props.onAllBubblesDisplayed() } @@ -101,6 +104,31 @@ export const ChatChunk = (props: Props) => { hasError={props.hasError} /> )} + + {(streamingMessageId) => ( +
+ + + + +
+ +
+
+ )} +
) } diff --git a/packages/embeds/js/src/components/ConversationContainer/ConversationContainer.tsx b/packages/embeds/js/src/components/ConversationContainer/ConversationContainer.tsx index a83effb1c..38f47b1fc 100644 --- a/packages/embeds/js/src/components/ConversationContainer/ConversationContainer.tsx +++ b/packages/embeds/js/src/components/ConversationContainer/ConversationContainer.tsx @@ -1,13 +1,26 @@ import { ChatReply, SendMessageInput, Theme } from '@typebot.io/schemas' import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/enums' -import { createEffect, createSignal, For, onMount, Show } from 'solid-js' +import { + createEffect, + createSignal, + createUniqueId, + For, + onMount, + Show, +} from 'solid-js' import { sendMessageQuery } from '@/queries/sendMessageQuery' import { ChatChunk } from './ChatChunk' -import { BotContext, InitialChatReply, OutgoingLog } from '@/types' +import { + BotContext, + ChatChunk as ChatChunkType, + InitialChatReply, + OutgoingLog, +} from '@/types' import { isNotDefined } from '@typebot.io/lib' import { executeClientSideAction } from '@/utils/executeClientSideActions' import { LoadingChunk } from './LoadingChunk' import { PopupBlockedToast } from './PopupBlockedToast' +import { setStreamingMessage } from '@/utils/streamingMessageSignal' const parseDynamicTheme = ( initialTheme: Theme, @@ -44,9 +57,7 @@ type Props = { export const ConversationContainer = (props: Props) => { let chatContainer: HTMLDivElement | undefined - const [chatChunks, setChatChunks] = createSignal< - Pick[] - >([ + const [chatChunks, setChatChunks] = createSignal([ { input: props.initialChatReply.input, messages: props.initialChatReply.messages, @@ -74,9 +85,13 @@ export const ConversationContainer = (props: Props) => { 'webhookToExecute' in action ) setIsSending(true) - const response = await executeClientSideAction(action, { - apiHost: props.context.apiHost, - sessionId: props.initialChatReply.sessionId, + const response = await executeClientSideAction({ + clientSideAction: action, + context: { + apiHost: props.context.apiHost, + sessionId: props.initialChatReply.sessionId, + }, + onMessageStream: streamMessage, }) if (response && 'replyToSend' in response) { sendMessage(response.replyToSend, response.logs) @@ -89,6 +104,22 @@ export const ConversationContainer = (props: Props) => { })() }) + const streamMessage = (content: string) => { + setIsSending(false) + const lastChunk = chatChunks().at(-1) + if (!lastChunk) return + const id = lastChunk.streamingMessageId ?? createUniqueId() + if (!lastChunk.streamingMessageId) + setChatChunks((displayedChunks) => [ + ...displayedChunks, + { + messages: [], + streamingMessageId: id, + }, + ]) + setStreamingMessage({ id, content }) + } + createEffect(() => { setTheme( parseDynamicTheme(props.initialChatReply.typebot.theme, dynamicTheme()) @@ -151,9 +182,13 @@ export const ConversationContainer = (props: Props) => { 'webhookToExecute' in action ) setIsSending(true) - const response = await executeClientSideAction(action, { - apiHost: props.context.apiHost, - sessionId: props.initialChatReply.sessionId, + const response = await executeClientSideAction({ + clientSideAction: action, + context: { + apiHost: props.context.apiHost, + sessionId: props.initialChatReply.sessionId, + }, + onMessageStream: streamMessage, }) if (response && 'replyToSend' in response) { sendMessage(response.replyToSend, response.logs) @@ -167,7 +202,9 @@ export const ConversationContainer = (props: Props) => { ...displayedChunks, { input: data.input, - messages: data.messages, + messages: chatChunks().at(-1)?.streamingMessageId + ? data.messages.slice(1) + : data.messages, clientSideActions: data.clientSideActions, }, ]) @@ -200,9 +237,13 @@ export const ConversationContainer = (props: Props) => { 'webhookToExecute' in action ) setIsSending(true) - const response = await executeClientSideAction(action, { - apiHost: props.context.apiHost, - sessionId: props.initialChatReply.sessionId, + const response = await executeClientSideAction({ + clientSideAction: action, + context: { + apiHost: props.context.apiHost, + sessionId: props.initialChatReply.sessionId, + }, + onMessageStream: streamMessage, }) if (response && 'replyToSend' in response) { sendMessage(response.replyToSend, response.logs) @@ -229,14 +270,19 @@ export const ConversationContainer = (props: Props) => { input={chatChunk.input} theme={theme()} settings={props.initialChatReply.typebot.settings} + streamingMessageId={chatChunk.streamingMessageId} + context={props.context} + hideAvatar={ + !chatChunk.input && + !chatChunk.streamingMessageId && + index() < chatChunks().length - 1 + } + hasError={hasError() && index() === chatChunks().length - 1} onNewBubbleDisplayed={handleNewBubbleDisplayed} onAllBubblesDisplayed={handleAllBubblesDisplayed} onSubmit={sendMessage} onScrollToBottom={autoScrollToBottom} onSkip={handleSkip} - context={props.context} - hasError={hasError() && index() === chatChunks().length - 1} - hideAvatar={!chatChunk.input && index() < chatChunks().length - 1} /> )} diff --git a/packages/embeds/js/src/components/bubbles/StreamingBubble.tsx b/packages/embeds/js/src/components/bubbles/StreamingBubble.tsx new file mode 100644 index 000000000..7d125eb5a --- /dev/null +++ b/packages/embeds/js/src/components/bubbles/StreamingBubble.tsx @@ -0,0 +1,40 @@ +import { streamingMessage } from '@/utils/streamingMessageSignal' +import { createEffect, createSignal } from 'solid-js' + +type Props = { + streamingMessageId: string +} + +export const StreamingBubble = (props: Props) => { + let ref: HTMLDivElement | undefined + const [content, setContent] = createSignal('') + + createEffect(() => { + if (streamingMessage()?.id === props.streamingMessageId) + setContent(streamingMessage()?.content ?? '') + }) + + return ( +
+
+
+
+
+ {content()} +
+
+
+
+ ) +} diff --git a/packages/embeds/js/src/features/blocks/integrations/openai/streamChat.ts b/packages/embeds/js/src/features/blocks/integrations/openai/streamChat.ts index 2eef563b3..b91246621 100644 --- a/packages/embeds/js/src/features/blocks/integrations/openai/streamChat.ts +++ b/packages/embeds/js/src/features/blocks/integrations/openai/streamChat.ts @@ -11,7 +11,7 @@ export const streamChat = content?: string | undefined role?: 'system' | 'user' | 'assistant' | undefined }[], - { onStreamedMessage }: { onStreamedMessage?: (message: string) => void } + { onMessageStream }: { onMessageStream?: (message: string) => void } ): Promise<{ message?: string; error?: object }> => { try { abortController = new AbortController() @@ -59,8 +59,8 @@ export const streamChat = break } const chunk = decoder.decode(value) - if (onStreamedMessage) onStreamedMessage(chunk) message += chunk + if (onMessageStream) onMessageStream(message) if (abortController === null) { reader.cancel() break diff --git a/packages/embeds/js/src/types.ts b/packages/embeds/js/src/types.ts index eaf9ce5ae..d498fdbad 100644 --- a/packages/embeds/js/src/types.ts +++ b/packages/embeds/js/src/types.ts @@ -27,3 +27,10 @@ export type ClientSideActionContext = { apiHost?: string sessionId: string } + +export type ChatChunk = Pick< + ChatReply, + 'messages' | 'input' | 'clientSideActions' +> & { + streamingMessageId?: string +} diff --git a/packages/embeds/js/src/utils/executeClientSideActions.ts b/packages/embeds/js/src/utils/executeClientSideActions.ts index 12ede2e32..13e47dbc0 100644 --- a/packages/embeds/js/src/utils/executeClientSideActions.ts +++ b/packages/embeds/js/src/utils/executeClientSideActions.ts @@ -11,11 +11,17 @@ import { ClientSideActionContext } from '@/types' import type { ChatReply, ReplyLog } from '@typebot.io/schemas' import { injectStartProps } from './injectStartProps' -export const executeClientSideAction = async ( - clientSideAction: NonNullable[0], - context: ClientSideActionContext, - onStreamedMessage?: (message: string) => void -): Promise< +type Props = { + clientSideAction: NonNullable[0] + context: ClientSideActionContext + onMessageStream?: (message: string) => void +} + +export const executeClientSideAction = async ({ + clientSideAction, + context, + onMessageStream, +}: Props): Promise< | { blockedPopupUrl: string } | { replyToSend: string | undefined; logs?: ReplyLog[] } | void @@ -41,7 +47,12 @@ export const executeClientSideAction = async ( if ('streamOpenAiChatCompletion' in clientSideAction) { const { error, message } = await streamChat(context)( clientSideAction.streamOpenAiChatCompletion.messages, - { onStreamedMessage } + { + onMessageStream: clientSideAction.streamOpenAiChatCompletion + .displayStream + ? onMessageStream + : undefined, + } ) if (error) return { diff --git a/packages/embeds/js/src/utils/streamingMessageSignal.ts b/packages/embeds/js/src/utils/streamingMessageSignal.ts new file mode 100644 index 000000000..2099246d2 --- /dev/null +++ b/packages/embeds/js/src/utils/streamingMessageSignal.ts @@ -0,0 +1,6 @@ +import { createSignal } from 'solid-js' + +export const [streamingMessage, setStreamingMessage] = createSignal<{ + id: string + content: string +}>() diff --git a/packages/embeds/nextjs/package.json b/packages/embeds/nextjs/package.json index 05ea15037..d49d3aadd 100644 --- a/packages/embeds/nextjs/package.json +++ b/packages/embeds/nextjs/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/nextjs", - "version": "0.1.3", + "version": "0.1.4", "description": "Convenient library to display typebots on your Next.js website", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/embeds/react/package.json b/packages/embeds/react/package.json index 2cd920956..26fbbaf92 100644 --- a/packages/embeds/react/package.json +++ b/packages/embeds/react/package.json @@ -1,6 +1,6 @@ { "name": "@typebot.io/react", - "version": "0.1.3", + "version": "0.1.4", "description": "Convenient library to display typebots on your Next.js website", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/schemas/features/chat.ts b/packages/schemas/features/chat.ts index 5f11cd58f..472766e97 100644 --- a/packages/schemas/features/chat.ts +++ b/packages/schemas/features/chat.ts @@ -247,6 +247,7 @@ const clientSideActionSchema = z messages: z.array( chatCompletionMessageSchema.pick({ content: true, role: true }) ), + displayStream: z.boolean().optional(), }), }) )