import { continueChatQuery } from '@/queries/continueChatQuery' import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery' import { BotContext, ChatChunk as ChatChunkType, InputSubmitContent, OutgoingLog, } from '@/types' import { executeClientSideAction } from '@/utils/executeClientSideActions' import { formattedMessages, setFormattedMessages, } from '@/utils/formattedMessagesSignal' import { getAnswerContent } from '@/utils/getAnswerContent' import { persist } from '@/utils/persist' import { newAnswer, setNewAnswer } from '@/utils/setAnswerSignal' import { setStreamingMessage } from '@/utils/streamingMessageSignal' import { isNotDefined } from '@indite.io/lib' import { ChatLog, ContinueChatResponse, InputBlock, Message, StartChatResponse, TextInputBlock, Theme, } from '@indite.io/schemas' import { InputBlockType } from '@indite.io/schemas/features/blocks/inputs/constants' import { defaultGuestAvatarIsEnabled, defaultHostAvatarIsEnabled } from '@indite.io/schemas/features/bot/theme/constants' import { HTTPError } from 'ky' import { createEffect, createSignal, For, onCleanup, onMount, Show } from 'solid-js' import { GuestBubble } from '../bubbles/GuestBubble' import { Input } from '../InputChatBlock' import { ChatChunk } from './ChatChunk' import { LoadingChunk } from './LoadingChunk' import { PopupBlockedToast } from './PopupBlockedToast' import { updateChatHistory } from './HistoryChunk' const autoScrollBottomToleranceScreenPercent = 0.6 const bottomSpacerHeight = 90 const parseDynamicTheme = ( initialTheme: Theme, dynamicTheme: ContinueChatResponse['dynamicTheme'] ): Theme => ({ ...initialTheme, chat: { ...initialTheme.chat, hostAvatar: initialTheme.chat?.hostAvatar && dynamicTheme?.hostAvatarUrl ? { ...initialTheme.chat.hostAvatar, url: dynamicTheme.hostAvatarUrl, } : initialTheme.chat?.hostAvatar, guestAvatar: initialTheme.chat?.guestAvatar && dynamicTheme?.guestAvatarUrl ? { ...initialTheme.chat.guestAvatar, url: dynamicTheme?.guestAvatarUrl, } : initialTheme.chat?.guestAvatar, }, }) type Bot = { id: string; theme: Record; // Adjust as necessary based on your theme structure settings: Record; // Adjust as necessary based on your settings structure }; type ConversationData = { sessionId: string; bot: Bot; messages: Message[]; input: InputBlock; }; type Props = { data: ConversationData initialChatReply: StartChatResponse context: BotContext onNewInputBlock?: (inputBlock: InputBlock) => void onAnswer?: (answer: { message: string; blockId: string }) => void onEnd?: () => void onNewLogs?: (logs: OutgoingLog[]) => void onProgressUpdate?: (progress: number) => void botContainerWidth?: string } export const ConversationContainer = (props: Props) => { let chatContainer: HTMLDivElement | undefined const [chatChunks, setChatChunks, isRecovered, setIsRecovered] = persist( createSignal([ { input: props.initialChatReply.input, messages: props.initialChatReply.messages, clientSideActions: props.initialChatReply.clientSideActions, }, ]), { key: `bot-${props.context.bot.id}-chatChunks`, storage: props.context.storage, onRecovered: () => { setTimeout(() => { chatContainer?.scrollTo(0, chatContainer.scrollHeight) }, 200) }, } ) const [dynamicTheme, setDynamicTheme] = createSignal< ContinueChatResponse['dynamicTheme'] >(props.initialChatReply.dynamicTheme) const [theme, setTheme] = createSignal(props.initialChatReply.bot.theme) const [isSending, setIsSending] = createSignal(false) const [blockedPopupUrl, setBlockedPopupUrl] = createSignal() const [hasError, setHasError] = createSignal(false) const [chunkIndex, setChunkIndex] = createSignal(0) onMount(() => { ; (async () => { const initialChunk = chatChunks()[0] if (!initialChunk.clientSideActions) return const actionsBeforeFirstBubble = initialChunk.clientSideActions.filter( (action) => isNotDefined(action.lastBubbleBlockId) ) await processClientSideActions(actionsBeforeFirstBubble) })() }) const streamMessage = ({ id, message }: { id: string; message: string }) => { setIsSending(false) const lastChunk = [...chatChunks()].pop() updateChatHistory(chatChunks()) if (!lastChunk) return if (lastChunk.streamingMessageId !== id) setChatChunks((displayedChunks) => [ ...displayedChunks, { messages: [], streamingMessageId: id, }, ]) setStreamingMessage({ id, content: message }) } createEffect(() => { setTheme( parseDynamicTheme(props.initialChatReply.bot.theme, dynamicTheme()) ) }) const saveLogs = async (clientLogs?: ChatLog[]) => { if (!clientLogs) return props.onNewLogs?.(clientLogs) if (props.context.isPreview) return await saveClientLogsQuery({ apiHost: props.context.apiHost, sessionId: props.initialChatReply.sessionId, clientLogs, }) } const sendMessage = async (answer?: InputSubmitContent) => { setIsRecovered(false) setHasError(false) const lastChunk = [...chatChunks()].pop() if (!lastChunk) return if (!lastChunk.streamingMessageId && !lastChunk.clientSideActions && answer) setNewAnswer((prevAnswers) => ({ ...prevAnswers, [chunkIndex()]: answer })) const currentInputBlock = [...chatChunks()].pop()?.input if (currentInputBlock?.id && props.onAnswer && answer) props.onAnswer({ message: getAnswerContent(answer), blockId: currentInputBlock.id, }) const longRequest = setTimeout(() => { setIsSending(true) }, 1000) autoScrollToBottom() const { data, error } = await continueChatQuery({ apiHost: props.context.apiHost, sessionId: props.initialChatReply.sessionId, message: convertSubmitContentToMessage(answer), // history: historyData, }) clearTimeout(longRequest) setIsSending(false) if (error) { setHasError(true) const errorLogs = [ { description: 'Failed to send the reply', details: error instanceof HTTPError ? { status: error.response.status, body: await error.response.json(), } : error, status: 'error', }, ] await saveClientLogsQuery({ apiHost: props.context.apiHost, sessionId: props.initialChatReply.sessionId, clientLogs: errorLogs, }) props.onNewLogs?.(errorLogs) return } if (!data) return if (data.chatLimitExceededError) window.location.reload() if (data.progress) props.onProgressUpdate?.(data.progress) if (data.lastMessageNewFormat) { setFormattedMessages([ ...formattedMessages(), { inputIndex: [...chatChunks()].length - 1, formattedMessage: data.lastMessageNewFormat as string, }, ]) } if (data.logs) props.onNewLogs?.(data.logs) if (data.dynamicTheme) setDynamicTheme(data.dynamicTheme) if (data.input && props.onNewInputBlock) { props.onNewInputBlock(data.input) } if (data.clientSideActions) { const actionsBeforeFirstBubble = data.clientSideActions.filter((action) => isNotDefined(action.lastBubbleBlockId) ) await processClientSideActions(actionsBeforeFirstBubble) if ( data.clientSideActions.length === 1 && data.clientSideActions[0].type === 'stream' && data.messages.length === 0 && data.input === undefined ) return } setChatChunks((displayedChunks) => [ ...displayedChunks, { input: data.input, messages: data.messages, clientSideActions: data.clientSideActions, }, ]) } const autoScrollToBottom = (lastElement?: HTMLDivElement, offset = 0) => { if (!chatContainer) return const bottomTolerance = chatContainer.clientHeight * autoScrollBottomToleranceScreenPercent - bottomSpacerHeight const isBottomOfLastElementInView = chatContainer.scrollTop + chatContainer.clientHeight >= chatContainer.scrollHeight - bottomTolerance if (isBottomOfLastElementInView) { setTimeout(() => { chatContainer?.scrollTo( 0, lastElement ? lastElement.offsetTop - offset : chatContainer.scrollHeight ) }, 50) } } const handleAllBubblesDisplayed = async () => { const lastChunk = [...chatChunks()].pop() if (!lastChunk) return if (isNotDefined(lastChunk.input)) { props.onEnd?.() } } const handleNewBubbleDisplayed = async (blockId: string) => { const lastChunk = [...chatChunks()].pop() if (!lastChunk) return if (lastChunk.clientSideActions) { const actionsToExecute = lastChunk.clientSideActions.filter( (action) => action.lastBubbleBlockId === blockId ) await processClientSideActions(actionsToExecute) } } const processClientSideActions = async ( actions: NonNullable ) => { if (isRecovered()) return for (const action of actions) { if ( 'streamOpenAiChatCompletion' in action || 'httpRequestToExecute' in action || 'stream' in action ) setIsSending(true) const response = await executeClientSideAction({ clientSideAction: action, context: { apiHost: props.context.apiHost, sessionId: props.initialChatReply.sessionId, }, onMessageStream: streamMessage, }) if (response && 'logs' in response) saveLogs(response.logs) if (response && 'replyToSend' in response) { setIsSending(false) sendMessage( response.replyToSend ? { type: 'text', value: response.replyToSend } : undefined ) return } if (response && 'blockedPopupUrl' in response) setBlockedPopupUrl(response.blockedPopupUrl) } } onCleanup(() => { setStreamingMessage(undefined) setFormattedMessages([]) }) const handleSkip = () => sendMessage(undefined) return (
{(chatChunk, index) => { setChunkIndex(index()) return ( <> 0 || chatChunks()[index() + 1]?.streamingMessageId !== undefined || (chatChunk.messages.length > 0 && isSending())) } hasError={hasError() && index() === chatChunks().length - 1} isTransitionDisabled={index() !== chatChunks().length - 1} onNewBubbleDisplayed={handleNewBubbleDisplayed} onAllBubblesDisplayed={handleAllBubblesDisplayed} onSubmit={sendMessage} onScrollToBottom={autoScrollToBottom} onSkip={handleSkip} /> ) }} {(blockedPopupUrl) => (
setBlockedPopupUrl(undefined)} />
)}
) } const BottomSpacer = () => (
) const convertSubmitContentToMessage = ( answer: InputSubmitContent | undefined ): Message | undefined => { if (!answer) return if (answer.type === 'text') return { type: 'text', text: answer.value, attachedFileUrls: answer.attachments?.map((attachment) => attachment.url), } if (answer.type === 'recording') return { type: 'audio', url: answer.url } }