UNPKG

@eeacms/volto-chatbot

Version:

@eeacms/volto-chatbot: Volto add-on

484 lines (436 loc) 15.1 kB
import React, { useState } from 'react'; import { buildLatestMessageChain, createChatSession, CurrentMessageFIFO, delay, fetchRelatedQuestions, getLastSuccessfulMessageId, removeMessage, updateCurrentMessageFIFO, updateParentChildren, } from './lib'; const TEMP_USER_MESSAGE_ID = -1; const TEMP_ASSISTANT_MESSAGE_ID = -2; const SYSTEM_MESSAGE_ID = -3; export const RetrievalType = { None: 'none', Search: 'search', SelectedDocs: 'selectedDocs', }; export const ChatFileType = { IMAGE: 'image', DOCUMENT: 'document', PLAIN_TEXT: 'plain_text', }; function upsertToCompleteMessageMap({ chatSessionId, completeMessageDetail, completeMessageMapOverride, makeLatestChildMessage = false, messages, replacementsMap = null, setCompleteMessageDetail, }) { // deep copy const frozenCompleteMessageMap = completeMessageMapOverride || completeMessageDetail.messageMap; // eslint is old, structuredClone is builtin // eslint-disable-next-line no-undef const newCompleteMessageMap = structuredClone(frozenCompleteMessageMap); if (newCompleteMessageMap.size === 0) { const systemMessageId = messages[0].parentMessageId || SYSTEM_MESSAGE_ID; const firstMessageId = messages[0].messageId; const dummySystemMessage = { messageId: systemMessageId, message: '', type: 'system', files: [], toolCalls: [], parentMessageId: null, childrenMessageIds: [firstMessageId], latestChildMessageId: firstMessageId, }; newCompleteMessageMap.set(dummySystemMessage.messageId, dummySystemMessage); messages[0].parentMessageId = systemMessageId; } messages.forEach((message) => { const idToReplace = replacementsMap?.get(message.messageId); if (idToReplace) { removeMessage(idToReplace, newCompleteMessageMap); } // update childrenMessageIds for the parent if ( !newCompleteMessageMap.has(message.messageId) && message.parentMessageId !== null ) { updateParentChildren(message, newCompleteMessageMap, true); } newCompleteMessageMap.set(message.messageId, message); }); // if specified, make these new message the latest of the current message chain if (makeLatestChildMessage) { const currentMessageChain = buildLatestMessageChain( frozenCompleteMessageMap, ); const latestMessage = currentMessageChain[currentMessageChain.length - 1]; if (latestMessage) { newCompleteMessageMap.get(latestMessage.messageId).latestChildMessageId = messages[0].messageId; } } const newCompleteMessageDetail = { sessionId: chatSessionId || completeMessageDetail.sessionId, messageMap: newCompleteMessageMap, }; setCompleteMessageDetail(newCompleteMessageDetail); return newCompleteMessageDetail; } class SubmitHandler { constructor({ persona, setIsStreaming, isCancelledRef, setIsCancelled, messageHistory, completeMessageDetail, currChatSessionId, setCurrChatSessionId, setCompleteMessageDetail, chatTitle, qgenAsistantId, enableQgen, setIsFetchingRelatedQuestions, }) { this.persona = persona; this.chatTitle = chatTitle; this.setIsStreaming = setIsStreaming; this.isCancelledRef = isCancelledRef; this.setIsCancelled = setIsCancelled; this.messageHistory = messageHistory; this.completeMessageDetail = completeMessageDetail; this.currChatSessionId = currChatSessionId; this.setCurrChatSessionId = setCurrChatSessionId; this.setCompleteMessageDetail = setCompleteMessageDetail; this.qgenAsistantId = qgenAsistantId; this.enableQgen = enableQgen; this.setIsFetchingRelatedQuestions = setIsFetchingRelatedQuestions; this.onSubmit = this.onSubmit.bind(this); } async onSubmit({ messageIdToResend, messageOverride, queryOverride, forceSearch, isSeededChat, message, } = {}) { if (this.currChatSessionId === null) { this.currChatSessionId = await createChatSession( this.persona.id, this.chatTitle, ); this.setCurrChatSessionId(this.currChatSessionId); } let newCompleteMessageDetail = {}; const messageToResend = this.messageHistory.find( (message) => message.messageId === messageIdToResend, ); const messageMap = this.completeMessageDetail.messageMap; const messageToResendParent = messageToResend?.parentMessageId !== null && messageToResend?.parentMessageId !== undefined ? messageMap.get(messageToResend.parentMessageId) : null; const messageToResendIndex = messageToResend ? this.messageHistory.indexOf(messageToResend) : null; if (!messageToResend && messageIdToResend !== undefined) { // eslint-disable-next-line no-console console.log({ message: 'Failed to re-send message - please refresh the page and try again.', type: 'error', }); return; } let currMessage = messageToResend ? messageToResend.message : message; if (messageOverride) { currMessage = messageOverride; } const currMessageHistory = messageToResendIndex !== null ? this.messageHistory.slice(0, messageToResendIndex) : this.messageHistory; let parentMessage = messageToResendParent || (currMessageHistory.length > 0 ? currMessageHistory[currMessageHistory.length - 1] : null) || (messageMap.size === 1 ? Array.from(messageMap.values())[0] : null); // if we're resending, set the parent's child to null // we will use tempMessages until the regenerated message is complete const messageUpdates = [ { messageId: TEMP_USER_MESSAGE_ID, message: currMessage, type: 'user', files: [], // currentMessageFiles, toolCalls: [], parentMessageId: parentMessage?.messageId || null, }, ]; if (parentMessage) { messageUpdates.push({ ...parentMessage, childrenMessageIds: (parentMessage.childrenMessageIds || []).concat([ TEMP_USER_MESSAGE_ID, ]), latestChildMessageId: TEMP_USER_MESSAGE_ID, }); } const info = { messages: messageUpdates, chatSessionId: this.currChatSessionId, completeMessageDetail: this.completeMessageDetail, setCompleteMessageDetail: this.setCompleteMessageDetail, }; const _res = upsertToCompleteMessageMap(info); const { messageMap: frozenMessageMap, sessionId: frozenSessionId } = _res; // on initial message send, we insert a dummy system message // set this as the parent here if no parent is set if (!parentMessage && frozenMessageMap.size === 2) { parentMessage = frozenMessageMap.get(SYSTEM_MESSAGE_ID) || null; } const currentAssistantId = this.persona.id; this.setIsStreaming(true); let answer = ''; let query = null; let retrievalType = RetrievalType.None; let documents = []; // selectedDocuments; let aiMessageImages = null; let error = null; let finalMessage = null; let toolCalls = []; const glsm = getLastSuccessfulMessageId; const lastSuccessfulMessageId = glsm(currMessageHistory); const stack = new CurrentMessageFIFO(); // here is the problem const params = { message: currMessage, alternateAssistantId: currentAssistantId, fileDescriptors: [], parentMessageId: lastSuccessfulMessageId, chatSessionId: this.currChatSessionId, promptId: 0, filters: {}, selectedDocumentIds: [], queryOverride, forceSearch, useExistingUserMessage: isSeededChat, }; const promise = updateCurrentMessageFIFO( params, this.isCancelledRef, this.setIsCancelled, ); await delay(50); for await (const bit of promise) { if (bit.error) { stack.error = bit.error; } else if (bit.isComplete) { stack.isComplete = true; } else { stack.push(bit.packet); } if (stack.isComplete || stack.isEmpty()) { // console.log('breaking', stack.isComplete, stack.isEmpty(), stack.stack); break; } await delay(2); if (!stack.isEmpty()) { const packet = stack.nextPacket(); if (packet) { // console.log('inside packagt', packet, { // has_message_id: Object.hasOwn(packet, 'message_id'), // has_answer_piece: Object.hasOwn(packet, 'answer_piece'), // has_top_docs: Object.hasOwn(packet, 'top_documents'), // has_tool_name: Object.hasOwn(packet, 'tool_name'), // has_file_ids: Object.hasOwn(packet, 'file_ids'), // has_error: Object.hasOwn(packet, 'error'), // }); if (Object.hasOwn(packet, 'answer_piece')) { answer += packet.answer_piece; } else if (Object.hasOwn(packet, 'top_documents')) { documents = packet.top_documents; query = packet.rephrased_query; retrievalType = RetrievalType.Search; if (documents && documents.length > 0) { // point to the latest message (we don't know the messageId yet, which is why // we have to use -1) // setSelectedMessageForDocDisplay(TEMP_USER_MESSAGE_ID); } } else if (Object.hasOwn(packet, 'tool_name')) { toolCalls = [ { tool_name: packet.tool_name, tool_args: packet.tool_args, tool_result: packet.tool_result, }, ]; } else if (Object.hasOwn(packet, 'file_ids')) { aiMessageImages = packet.file_ids.map((fileId) => { return { id: fileId, type: ChatFileType.IMAGE, }; }); } else if (packet.error) { error = packet.error; } else if (Object.hasOwn(packet, 'message_id')) { finalMessage = packet; } const newUserMessageId = finalMessage?.parent_message || TEMP_USER_MESSAGE_ID; const newAssistantMessageId = finalMessage?.message_id || TEMP_ASSISTANT_MESSAGE_ID; const localMessages = [ { messageId: newUserMessageId, message: currMessage, type: 'user', files: [], toolCalls: [], parentMessageId: parentMessage?.messageId || null, childrenMessageIds: [newAssistantMessageId], latestChildMessageId: newAssistantMessageId, }, { messageId: newAssistantMessageId, message: error || answer, type: error ? 'error' : 'assistant', retrievalType, query: finalMessage?.rephrased_query || query, documents: finalMessage?.context_docs?.top_documents || finalMessage?.final_context_docs || documents, citations: finalMessage?.citations || {}, files: finalMessage?.files || aiMessageImages || [], toolCalls: finalMessage?.tool_calls || toolCalls, parentMessageId: newUserMessageId, alternateAssistantID: null, // alternativeAssistant?.id, }, ]; // if (finalMessage) { // console.log({ finalMessage }); // } const replacementsMap = finalMessage ? new Map([ [localMessages[0].messageId, TEMP_USER_MESSAGE_ID], [localMessages[1].messageId, TEMP_ASSISTANT_MESSAGE_ID], ]) : null; const info = { chatSessionId: frozenSessionId, completeMessageMapOverride: frozenMessageMap, messages: localMessages, replacementsMap: replacementsMap, setCompleteMessageDetail: this.setCompleteMessageDetail, }; newCompleteMessageDetail = upsertToCompleteMessageMap(info); } if (this.isCancelledRef.current) { this.setIsCancelled(false); break; } } } if ( newCompleteMessageDetail.messageMap && this.enableQgen && typeof this.qgenAsistantId !== 'undefined' ) { // check if last message comes from assistant const { messageMap } = newCompleteMessageDetail; const messageList = buildLatestMessageChain(messageMap).reverse(); const lastMessage = messageList.find((m) => m.type === 'assistant'); const userMessage = messageList.find((m) => m.type === 'user'); if (lastMessage && userMessage) { const query = userMessage.message; const answer = lastMessage.message; this.setIsFetchingRelatedQuestions(true); const relatedQuestionsText = await fetchRelatedQuestions( { query, answer }, this.qgenAsistantId, ); lastMessage.relatedQuestions = extractJSON(relatedQuestionsText); this.setCompleteMessageDetail({ ...newCompleteMessageDetail, messageMap, }); this.setIsFetchingRelatedQuestions(false); } } this.setIsStreaming(false); } } function extractJSON(str) { const regex = /\[([\s\S]*?)\]/; const match = str.match(regex); if (match) { const jsonText = match[0]; // TODO: do we need safety here? return JSON.parse(jsonText); } else { return str.split('\n').map((question) => ({ question })); } } export function useBackendChat({ persona, qgenAsistantId, enableQgen }) { const [isStreaming, setIsStreaming] = React.useState(false); const [isFetchingRelatedQuestions, setIsFetchingRelatedQuestions] = React.useState(false); const [isCancelled, setIsCancelled] = React.useState(false); const isCancelledRef = React.useRef(isCancelled); // scroll is cancelled const [currChatSessionId, setCurrChatSessionId] = React.useState(null); React.useEffect(() => { isCancelledRef.current = isCancelled; }, [isCancelled]); const [completeMessageDetail, setCompleteMessageDetail] = useState({ sessionId: null, messageMap: new Map(), }); const messageHistory = buildLatestMessageChain( completeMessageDetail.messageMap, ); const submitHandler = new SubmitHandler({ completeMessageDetail, currChatSessionId, isCancelledRef, messageHistory, persona, setCompleteMessageDetail, setCurrChatSessionId, setIsCancelled, setIsStreaming, qgenAsistantId, enableQgen, setIsFetchingRelatedQuestions, }); const clearChat = () => { setCompleteMessageDetail({ sessionId: null, messageMap: new Map(), }); setCurrChatSessionId(null); }; // console.log('history', messageHistory); return { messages: messageHistory, onSubmit: submitHandler.onSubmit, isStreaming, isCancelled, clearChat, isFetchingRelatedQuestions, }; }