2
0

Stream bubble content if placed right after Op…

Closes #617
This commit is contained in:
Baptiste Arnaud
2023-07-19 11:20:44 +02:00
parent 6c540657a6
commit 3952ae2755
14 changed files with 231 additions and 34 deletions

View File

@ -5145,6 +5145,9 @@
}, },
"additionalProperties": false "additionalProperties": false
} }
},
"displayStream": {
"type": "boolean"
} }
}, },
"required": [ "required": [

View File

@ -1,12 +1,12 @@
import { ExecuteIntegrationResponse } from '@/features/chat/types' import { ExecuteIntegrationResponse } from '@/features/chat/types'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { SessionState } from '@typebot.io/schemas' import { Block, BubbleBlockType, SessionState } from '@typebot.io/schemas'
import { import {
ChatCompletionOpenAIOptions, ChatCompletionOpenAIOptions,
OpenAICredentials, OpenAICredentials,
chatCompletionMessageRoles, chatCompletionMessageRoles,
} from '@typebot.io/schemas/features/blocks/integrations/openai' } 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 { decrypt, isCredentialsV2 } from '@typebot.io/lib/api/encryption'
import { updateVariables } from '@/features/variables/updateVariables' import { updateVariables } from '@/features/variables/updateVariables'
import { parseVariableNumber } from '@/features/variables/parseVariableNumber' import { parseVariableNumber } from '@/features/variables/parseVariableNumber'
@ -20,7 +20,12 @@ export const createChatCompletionOpenAI = async (
{ {
outgoingEdgeId, outgoingEdgeId,
options, options,
}: { outgoingEdgeId?: string; options: ChatCompletionOpenAIOptions } blockId,
}: {
outgoingEdgeId?: string
options: ChatCompletionOpenAIOptions
blockId: string
}
): Promise<ExecuteIntegrationResponse> => { ): Promise<ExecuteIntegrationResponse> => {
let newSessionState = state let newSessionState = state
const noCredentialsError = { const noCredentialsError = {
@ -60,7 +65,14 @@ export const createChatCompletionOpenAI = async (
isPlaneteScale() && isPlaneteScale() &&
isCredentialsV2(credentials) && isCredentialsV2(credentials) &&
newSessionState.isStreamEnabled newSessionState.isStreamEnabled
) ) {
const assistantMessageVariableName = state.typebot.variables.find(
(variable) =>
options.responseMapping.find(
(m) => m.valueToExtract === 'Message content'
)?.variableId === variable.id
)?.name
return { return {
clientSideActions: [ clientSideActions: [
{ {
@ -69,12 +81,17 @@ export const createChatCompletionOpenAI = async (
content?: string content?: string
role: (typeof chatCompletionMessageRoles)[number] role: (typeof chatCompletionMessageRoles)[number]
}[], }[],
displayStream: isNextBubbleMessageWithAssistantMessage(
state.typebot
)(blockId, assistantMessageVariableName),
}, },
}, },
], ],
outgoingEdgeId, outgoingEdgeId,
newSessionState, newSessionState,
} }
}
const { response, logs } = await executeChatCompletionOpenAIRequest({ const { response, logs } = await executeChatCompletionOpenAIRequest({
apiKey, apiKey,
messages, messages,
@ -98,3 +115,40 @@ export const createChatCompletionOpenAI = async (
logs, logs,
})(messageContent, totalTokens) })(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)
}

View File

@ -12,6 +12,7 @@ export const executeOpenAIBlock = async (
return createChatCompletionOpenAI(state, { return createChatCompletionOpenAI(state, {
options: block.options, options: block.options,
outgoingEdgeId: block.outgoingEdgeId, outgoingEdgeId: block.outgoingEdgeId,
blockId: block.id,
}) })
case 'Create image': case 'Create image':
case undefined: case undefined:

View File

@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/js", "name": "@typebot.io/js",
"version": "0.1.3", "version": "0.1.4",
"description": "Javascript library to display typebots on your website", "description": "Javascript library to display typebots on your website",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@ -1,10 +1,11 @@
import { BotContext } from '@/types' import { BotContext, ChatChunk as ChatChunkType } from '@/types'
import { isMobile } from '@/utils/isMobileSignal' import { isMobile } from '@/utils/isMobileSignal'
import type { ChatReply, Settings, Theme } from '@typebot.io/schemas' import type { ChatReply, Settings, Theme } from '@typebot.io/schemas'
import { createSignal, For, onMount, Show } from 'solid-js' import { createSignal, For, onMount, Show } from 'solid-js'
import { HostBubble } from '../bubbles/HostBubble' import { HostBubble } from '../bubbles/HostBubble'
import { InputChatBlock } from '../InputChatBlock' import { InputChatBlock } from '../InputChatBlock'
import { AvatarSideContainer } from './AvatarSideContainer' import { AvatarSideContainer } from './AvatarSideContainer'
import { StreamingBubble } from '../bubbles/StreamingBubble'
type Props = Pick<ChatReply, 'messages' | 'input'> & { type Props = Pick<ChatReply, 'messages' | 'input'> & {
theme: Theme theme: Theme
@ -13,6 +14,7 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
context: BotContext context: BotContext
hasError: boolean hasError: boolean
hideAvatar: boolean hideAvatar: boolean
streamingMessageId: ChatChunkType['streamingMessageId']
onNewBubbleDisplayed: (blockId: string) => Promise<void> onNewBubbleDisplayed: (blockId: string) => Promise<void>
onScrollToBottom: (top?: number) => void onScrollToBottom: (top?: number) => void
onSubmit: (input: string) => void onSubmit: (input: string) => void
@ -25,6 +27,7 @@ export const ChatChunk = (props: Props) => {
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0) const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
onMount(() => { onMount(() => {
if (props.streamingMessageId) return
if (props.messages.length === 0) { if (props.messages.length === 0) {
props.onAllBubblesDisplayed() props.onAllBubblesDisplayed()
} }
@ -101,6 +104,31 @@ export const ChatChunk = (props: Props) => {
hasError={props.hasError} hasError={props.hasError}
/> />
)} )}
<Show when={props.streamingMessageId} keyed>
{(streamingMessageId) => (
<div class={'flex' + (isMobile() ? ' gap-1' : ' gap-2')}>
<Show when={props.theme.chat.hostAvatar?.isEnabled}>
<AvatarSideContainer
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
hideAvatar={props.hideAvatar}
/>
</Show>
<div
class="flex flex-col flex-1 gap-2"
style={{
'margin-right': props.theme.chat.guestAvatar?.isEnabled
? isMobile()
? '32px'
: '48px'
: undefined,
}}
>
<StreamingBubble streamingMessageId={streamingMessageId} />
</div>
</div>
)}
</Show>
</div> </div>
) )
} }

View File

@ -1,13 +1,26 @@
import { ChatReply, SendMessageInput, Theme } from '@typebot.io/schemas' import { ChatReply, SendMessageInput, Theme } from '@typebot.io/schemas'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/enums' 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 { sendMessageQuery } from '@/queries/sendMessageQuery'
import { ChatChunk } from './ChatChunk' 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 { isNotDefined } from '@typebot.io/lib'
import { executeClientSideAction } from '@/utils/executeClientSideActions' import { executeClientSideAction } from '@/utils/executeClientSideActions'
import { LoadingChunk } from './LoadingChunk' import { LoadingChunk } from './LoadingChunk'
import { PopupBlockedToast } from './PopupBlockedToast' import { PopupBlockedToast } from './PopupBlockedToast'
import { setStreamingMessage } from '@/utils/streamingMessageSignal'
const parseDynamicTheme = ( const parseDynamicTheme = (
initialTheme: Theme, initialTheme: Theme,
@ -44,9 +57,7 @@ type Props = {
export const ConversationContainer = (props: Props) => { export const ConversationContainer = (props: Props) => {
let chatContainer: HTMLDivElement | undefined let chatContainer: HTMLDivElement | undefined
const [chatChunks, setChatChunks] = createSignal< const [chatChunks, setChatChunks] = createSignal<ChatChunkType[]>([
Pick<ChatReply, 'messages' | 'input' | 'clientSideActions'>[]
>([
{ {
input: props.initialChatReply.input, input: props.initialChatReply.input,
messages: props.initialChatReply.messages, messages: props.initialChatReply.messages,
@ -74,9 +85,13 @@ export const ConversationContainer = (props: Props) => {
'webhookToExecute' in action 'webhookToExecute' in action
) )
setIsSending(true) setIsSending(true)
const response = await executeClientSideAction(action, { const response = await executeClientSideAction({
apiHost: props.context.apiHost, clientSideAction: action,
sessionId: props.initialChatReply.sessionId, context: {
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
},
onMessageStream: streamMessage,
}) })
if (response && 'replyToSend' in response) { if (response && 'replyToSend' in response) {
sendMessage(response.replyToSend, response.logs) 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(() => { createEffect(() => {
setTheme( setTheme(
parseDynamicTheme(props.initialChatReply.typebot.theme, dynamicTheme()) parseDynamicTheme(props.initialChatReply.typebot.theme, dynamicTheme())
@ -151,9 +182,13 @@ export const ConversationContainer = (props: Props) => {
'webhookToExecute' in action 'webhookToExecute' in action
) )
setIsSending(true) setIsSending(true)
const response = await executeClientSideAction(action, { const response = await executeClientSideAction({
apiHost: props.context.apiHost, clientSideAction: action,
sessionId: props.initialChatReply.sessionId, context: {
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
},
onMessageStream: streamMessage,
}) })
if (response && 'replyToSend' in response) { if (response && 'replyToSend' in response) {
sendMessage(response.replyToSend, response.logs) sendMessage(response.replyToSend, response.logs)
@ -167,7 +202,9 @@ export const ConversationContainer = (props: Props) => {
...displayedChunks, ...displayedChunks,
{ {
input: data.input, input: data.input,
messages: data.messages, messages: chatChunks().at(-1)?.streamingMessageId
? data.messages.slice(1)
: data.messages,
clientSideActions: data.clientSideActions, clientSideActions: data.clientSideActions,
}, },
]) ])
@ -200,9 +237,13 @@ export const ConversationContainer = (props: Props) => {
'webhookToExecute' in action 'webhookToExecute' in action
) )
setIsSending(true) setIsSending(true)
const response = await executeClientSideAction(action, { const response = await executeClientSideAction({
apiHost: props.context.apiHost, clientSideAction: action,
sessionId: props.initialChatReply.sessionId, context: {
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
},
onMessageStream: streamMessage,
}) })
if (response && 'replyToSend' in response) { if (response && 'replyToSend' in response) {
sendMessage(response.replyToSend, response.logs) sendMessage(response.replyToSend, response.logs)
@ -229,14 +270,19 @@ export const ConversationContainer = (props: Props) => {
input={chatChunk.input} input={chatChunk.input}
theme={theme()} theme={theme()}
settings={props.initialChatReply.typebot.settings} 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} onNewBubbleDisplayed={handleNewBubbleDisplayed}
onAllBubblesDisplayed={handleAllBubblesDisplayed} onAllBubblesDisplayed={handleAllBubblesDisplayed}
onSubmit={sendMessage} onSubmit={sendMessage}
onScrollToBottom={autoScrollToBottom} onScrollToBottom={autoScrollToBottom}
onSkip={handleSkip} onSkip={handleSkip}
context={props.context}
hasError={hasError() && index() === chatChunks().length - 1}
hideAvatar={!chatChunk.input && index() < chatChunks().length - 1}
/> />
)} )}
</For> </For>

View File

@ -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<string>('')
createEffect(() => {
if (streamingMessage()?.id === props.streamingMessageId)
setContent(streamingMessage()?.content ?? '')
})
return (
<div class="flex flex-col animate-fade-in" ref={ref}>
<div class="flex w-full items-center">
<div class="flex relative items-start typebot-host-bubble">
<div
class="flex items-center absolute px-4 py-2 bubble-typing "
style={{
width: '100%',
height: '100%',
}}
data-testid="host-bubble"
/>
<div
class={
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative text-ellipsis opacity-100 h-full'
}
>
{content()}
</div>
</div>
</div>
</div>
)
}

View File

@ -11,7 +11,7 @@ export const streamChat =
content?: string | undefined content?: string | undefined
role?: 'system' | 'user' | 'assistant' | undefined role?: 'system' | 'user' | 'assistant' | undefined
}[], }[],
{ onStreamedMessage }: { onStreamedMessage?: (message: string) => void } { onMessageStream }: { onMessageStream?: (message: string) => void }
): Promise<{ message?: string; error?: object }> => { ): Promise<{ message?: string; error?: object }> => {
try { try {
abortController = new AbortController() abortController = new AbortController()
@ -59,8 +59,8 @@ export const streamChat =
break break
} }
const chunk = decoder.decode(value) const chunk = decoder.decode(value)
if (onStreamedMessage) onStreamedMessage(chunk)
message += chunk message += chunk
if (onMessageStream) onMessageStream(message)
if (abortController === null) { if (abortController === null) {
reader.cancel() reader.cancel()
break break

View File

@ -27,3 +27,10 @@ export type ClientSideActionContext = {
apiHost?: string apiHost?: string
sessionId: string sessionId: string
} }
export type ChatChunk = Pick<
ChatReply,
'messages' | 'input' | 'clientSideActions'
> & {
streamingMessageId?: string
}

View File

@ -11,11 +11,17 @@ import { ClientSideActionContext } from '@/types'
import type { ChatReply, ReplyLog } from '@typebot.io/schemas' import type { ChatReply, ReplyLog } from '@typebot.io/schemas'
import { injectStartProps } from './injectStartProps' import { injectStartProps } from './injectStartProps'
export const executeClientSideAction = async ( type Props = {
clientSideAction: NonNullable<ChatReply['clientSideActions']>[0], clientSideAction: NonNullable<ChatReply['clientSideActions']>[0]
context: ClientSideActionContext, context: ClientSideActionContext
onStreamedMessage?: (message: string) => void onMessageStream?: (message: string) => void
): Promise< }
export const executeClientSideAction = async ({
clientSideAction,
context,
onMessageStream,
}: Props): Promise<
| { blockedPopupUrl: string } | { blockedPopupUrl: string }
| { replyToSend: string | undefined; logs?: ReplyLog[] } | { replyToSend: string | undefined; logs?: ReplyLog[] }
| void | void
@ -41,7 +47,12 @@ export const executeClientSideAction = async (
if ('streamOpenAiChatCompletion' in clientSideAction) { if ('streamOpenAiChatCompletion' in clientSideAction) {
const { error, message } = await streamChat(context)( const { error, message } = await streamChat(context)(
clientSideAction.streamOpenAiChatCompletion.messages, clientSideAction.streamOpenAiChatCompletion.messages,
{ onStreamedMessage } {
onMessageStream: clientSideAction.streamOpenAiChatCompletion
.displayStream
? onMessageStream
: undefined,
}
) )
if (error) if (error)
return { return {

View File

@ -0,0 +1,6 @@
import { createSignal } from 'solid-js'
export const [streamingMessage, setStreamingMessage] = createSignal<{
id: string
content: string
}>()

View File

@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/nextjs", "name": "@typebot.io/nextjs",
"version": "0.1.3", "version": "0.1.4",
"description": "Convenient library to display typebots on your Next.js website", "description": "Convenient library to display typebots on your Next.js website",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/react", "name": "@typebot.io/react",
"version": "0.1.3", "version": "0.1.4",
"description": "Convenient library to display typebots on your Next.js website", "description": "Convenient library to display typebots on your Next.js website",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -247,6 +247,7 @@ const clientSideActionSchema = z
messages: z.array( messages: z.array(
chatCompletionMessageSchema.pick({ content: true, role: true }) chatCompletionMessageSchema.pick({ content: true, role: true })
), ),
displayStream: z.boolean().optional(),
}), }),
}) })
) )