@ -5145,6 +5145,9 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"displayStream": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
@ -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<ExecuteIntegrationResponse> => {
|
||||
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)
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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",
|
||||
|
@ -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<ChatReply, 'messages' | 'input'> & {
|
||||
theme: Theme
|
||||
@ -13,6 +14,7 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
|
||||
context: BotContext
|
||||
hasError: boolean
|
||||
hideAvatar: boolean
|
||||
streamingMessageId: ChatChunkType['streamingMessageId']
|
||||
onNewBubbleDisplayed: (blockId: string) => Promise<void>
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
@ -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<ChatReply, 'messages' | 'input' | 'clientSideActions'>[]
|
||||
>([
|
||||
const [chatChunks, setChatChunks] = createSignal<ChatChunkType[]>([
|
||||
{
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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
|
||||
|
@ -27,3 +27,10 @@ export type ClientSideActionContext = {
|
||||
apiHost?: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export type ChatChunk = Pick<
|
||||
ChatReply,
|
||||
'messages' | 'input' | 'clientSideActions'
|
||||
> & {
|
||||
streamingMessageId?: string
|
||||
}
|
||||
|
@ -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<ChatReply['clientSideActions']>[0],
|
||||
context: ClientSideActionContext,
|
||||
onStreamedMessage?: (message: string) => void
|
||||
): Promise<
|
||||
type Props = {
|
||||
clientSideAction: NonNullable<ChatReply['clientSideActions']>[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 {
|
||||
|
6
packages/embeds/js/src/utils/streamingMessageSignal.ts
Normal file
6
packages/embeds/js/src/utils/streamingMessageSignal.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createSignal } from 'solid-js'
|
||||
|
||||
export const [streamingMessage, setStreamingMessage] = createSignal<{
|
||||
id: string
|
||||
content: string
|
||||
}>()
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -247,6 +247,7 @@ const clientSideActionSchema = z
|
||||
messages: z.array(
|
||||
chatCompletionMessageSchema.pick({ content: true, role: true })
|
||||
),
|
||||
displayStream: z.boolean().optional(),
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
Reference in New Issue
Block a user