From c0d0c63592bba0de5a8319376a53f3116dfa3193 Mon Sep 17 00:00:00 2001 From: Hash Brown Date: Fri, 31 Jan 2025 13:05:10 +0800 Subject: [PATCH] feat: switch to chat messages before regenerated (#11301) Co-authored-by: zuodongxu <192560071+zuodongxu@users.noreply.github.com> --- api/controllers/console/explore/message.py | 2 +- api/controllers/web/message.py | 2 +- .../debug-with-multiple-model/chat-item.tsx | 5 +- .../debug/debug-with-single-model/index.tsx | 34 +- .../chat/chat-with-history/chat-wrapper.tsx | 41 +- .../base/chat/chat-with-history/context.tsx | 6 +- .../base/chat/chat-with-history/hooks.tsx | 37 +- .../base/chat/chat-with-history/index.tsx | 12 +- .../base/chat/chat/answer/index.tsx | 10 +- web/app/components/base/chat/chat/hooks.ts | 555 +++++++++--------- .../chat/embedded-chatbot/chat-wrapper.tsx | 40 +- web/app/components/base/chat/types.ts | 7 +- web/app/components/base/chat/utils.ts | 65 +- .../panel/debug-and-preview/chat-wrapper.tsx | 36 +- .../workflow/panel/debug-and-preview/hooks.ts | 303 +++++----- 15 files changed, 576 insertions(+), 579 deletions(-) diff --git a/api/controllers/console/explore/message.py b/api/controllers/console/explore/message.py index 405d5ed607..ff12959a65 100644 --- a/api/controllers/console/explore/message.py +++ b/api/controllers/console/explore/message.py @@ -50,7 +50,7 @@ class MessageListApi(InstalledAppResource): try: return MessageService.pagination_by_first_id( - app_model, current_user, args["conversation_id"], args["first_id"], args["limit"], "desc" + app_model, current_user, args["conversation_id"], args["first_id"], args["limit"] ) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") diff --git a/api/controllers/web/message.py b/api/controllers/web/message.py index 2afc11f601..e6e546690c 100644 --- a/api/controllers/web/message.py +++ b/api/controllers/web/message.py @@ -91,7 +91,7 @@ class MessageListApi(WebApiResource): try: return MessageService.pagination_by_first_id( - app_model, end_user, args["conversation_id"], args["first_id"], args["limit"], "desc" + app_model, end_user, args["conversation_id"], args["first_id"], args["limit"] ) except services.errors.conversation.ConversationNotExistsError: raise NotFound("Conversation Not Exists.") diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx index 119db34b16..5d2f33a005 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/chat-item.tsx @@ -67,7 +67,6 @@ const ChatItem: FC = ({ }, [modelConfig.configs.prompt_variables]) const { chatList, - chatListRef, isResponding, handleSend, suggestedQuestions, @@ -102,7 +101,7 @@ const ChatItem: FC = ({ query: message, inputs, model_config: configData, - parent_message_id: getLastAnswer(chatListRef.current)?.id || null, + parent_message_id: getLastAnswer(chatList)?.id || null, } if ((config.file_upload as any).enabled && files?.length && supportVision) @@ -116,7 +115,7 @@ const ChatItem: FC = ({ onGetSuggestedQuestions: (responseItemId, getAbortController) => fetchSuggestedQuestions(appId, responseItemId, getAbortController), }, ) - }, [appId, config, handleSend, inputs, modelAndParameter, textGenerationModelList, chatListRef]) + }, [appId, chatList, config, handleSend, inputs, modelAndParameter.model, modelAndParameter.parameters, modelAndParameter.provider, textGenerationModelList]) const { eventEmitter } = useEventEmitterContextContext() eventEmitter?.useSubscription((v: any) => { diff --git a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx index 48e1e55de4..2b3c3b8fe2 100644 --- a/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx +++ b/web/app/components/app/configuration/debug/debug-with-single-model/index.tsx @@ -12,7 +12,7 @@ import { import Chat from '@/app/components/base/chat/chat' import { useChat } from '@/app/components/base/chat/chat/hooks' import { useDebugConfigurationContext } from '@/context/debug-configuration' -import type { ChatConfig, ChatItem, OnSend } from '@/app/components/base/chat/types' +import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types' import { useProviderContext } from '@/context/provider-context' import { fetchConversationMessages, @@ -24,7 +24,7 @@ import { useAppContext } from '@/context/app-context' import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useStore as useAppStore } from '@/app/components/app/store' import { useFeatures } from '@/app/components/base/features/hooks' -import { getLastAnswer } from '@/app/components/base/chat/utils' +import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' import type { InputForm } from '@/app/components/base/chat/chat/type' type DebugWithSingleModelProps = { @@ -68,12 +68,11 @@ const DebugWithSingleModel = forwardRef { + const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { if (checkCanSend && !checkCanSend()) return const currentProvider = textGenerationModelList.find(item => item.provider === modelConfig.provider) @@ -110,7 +109,7 @@ const DebugWithSingleModel = forwardRef fetchSuggestedQuestions(appId, responseItemId, getAbortController), }, ) - }, [chatListRef, appId, checkCanSend, completionParams, config, handleSend, inputs, modelConfig, textGenerationModelList]) + }, [appId, chatList, checkCanSend, completionParams, config, handleSend, inputs, modelConfig.mode, modelConfig.model_id, modelConfig.provider, textGenerationModelList]) - const doRegenerate = useCallback((chatItem: ChatItem) => { - const index = chatList.findIndex(item => item.id === chatItem.id) - if (index === -1) - return - - const prevMessages = chatList.slice(0, index) - const question = prevMessages.pop() - const lastAnswer = getLastAnswer(prevMessages) - - if (!question) - return - - handleUpdateChatList(prevMessages) - doSend(question.content, question.message_files, lastAnswer) - }, [chatList, handleUpdateChatList, doSend]) + const doRegenerate = useCallback((chatItem: ChatItemInTree) => { + const question = chatList.find(item => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find(item => item.id === question.parentMessageId) + doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) + }, [chatList, doSend]) const allToolIcons = useMemo(() => { const icons: Record = {} @@ -173,6 +162,7 @@ const DebugWithSingleModel = forwardRef setTargetMessageId(siblingMessageId)} onStopResponding={handleStop} showPromptLog questionIcon={} diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 724ef78e75..77259201ba 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -3,10 +3,11 @@ import Chat from '../chat' import type { ChatConfig, ChatItem, + ChatItemInTree, OnSend, } from '../types' import { useChat } from '../chat/hooks' -import { getLastAnswer } from '../utils' +import { getLastAnswer, isValidGeneratedAnswer } from '../utils' import { useChatWithHistoryContext } from './context' import Header from './header' import ConfigPanel from './config-panel' @@ -20,7 +21,7 @@ import AnswerIcon from '@/app/components/base/answer-icon' const ChatWrapper = () => { const { appParams, - appPrevChatList, + appPrevChatTree, currentConversationId, currentConversationItem, inputsForms, @@ -50,8 +51,7 @@ const ChatWrapper = () => { }, [appParams, currentConversationItem?.introduction, currentConversationId]) const { chatList, - chatListRef, - handleUpdateChatList, + setTargetMessageId, handleSend, handleStop, isResponding, @@ -62,7 +62,7 @@ const ChatWrapper = () => { inputs: (currentConversationId ? currentConversationItem?.inputs : newConversationInputs) as any, inputsForm: inputsForms, }, - appPrevChatList, + appPrevChatTree, taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId), ) @@ -72,13 +72,13 @@ const ChatWrapper = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const doSend: OnSend = useCallback((message, files, last_answer) => { + const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { const data: any = { query: message, files, inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs, conversation_id: currentConversationId, - parent_message_id: last_answer?.id || getLastAnswer(chatListRef.current)?.id || null, + parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, } handleSend( @@ -91,31 +91,21 @@ const ChatWrapper = () => { }, ) }, [ - chatListRef, + chatList, + handleNewConversationCompleted, + handleSend, currentConversationId, currentConversationItem, - handleSend, newConversationInputs, - handleNewConversationCompleted, isInstalledApp, appId, ]) - const doRegenerate = useCallback((chatItem: ChatItem) => { - const index = chatList.findIndex(item => item.id === chatItem.id) - if (index === -1) - return - - const prevMessages = chatList.slice(0, index) - const question = prevMessages.pop() - const lastAnswer = getLastAnswer(prevMessages) - - if (!question) - return - - handleUpdateChatList(prevMessages) - doSend(question.content, question.message_files, lastAnswer) - }, [chatList, handleUpdateChatList, doSend]) + const doRegenerate = useCallback((chatItem: ChatItemInTree) => { + const question = chatList.find(item => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find(item => item.id === question.parentMessageId) + doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) + }, [chatList, doSend]) const chatNode = useMemo(() => { if (inputsForms.length) { @@ -187,6 +177,7 @@ const ChatWrapper = () => { answerIcon={answerIcon} hideProcessDetail themeBuilder={themeBuilder} + switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)} /> ) diff --git a/web/app/components/base/chat/chat-with-history/context.tsx b/web/app/components/base/chat/chat-with-history/context.tsx index 060c178993..4b5817c726 100644 --- a/web/app/components/base/chat/chat-with-history/context.tsx +++ b/web/app/components/base/chat/chat-with-history/context.tsx @@ -5,7 +5,7 @@ import { createContext, useContext } from 'use-context-selector' import type { Callback, ChatConfig, - ChatItem, + ChatItemInTree, Feedback, } from '../types' import type { ThemeBuilder } from '../embedded-chatbot/theme/theme-context' @@ -25,7 +25,7 @@ export type ChatWithHistoryContextValue = { appChatListDataLoading?: boolean currentConversationId: string currentConversationItem?: ConversationItem - appPrevChatList: ChatItem[] + appPrevChatTree: ChatItemInTree[] pinnedConversationList: AppConversationData['data'] conversationList: AppConversationData['data'] showConfigPanelBeforeChat: boolean @@ -53,7 +53,7 @@ export type ChatWithHistoryContextValue = { export const ChatWithHistoryContext = createContext({ currentConversationId: '', - appPrevChatList: [], + appPrevChatTree: [], pinnedConversationList: [], conversationList: [], showConfigPanelBeforeChat: false, diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index a67cc3cd88..64dbb13acf 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -12,10 +12,13 @@ import produce from 'immer' import type { Callback, ChatConfig, + ChatItem, Feedback, } from '../types' import { CONVERSATION_ID_INFO } from '../constants' -import { getPrevChatList } from '../utils' +import { buildChatItemTree } from '../utils' +import { addFileInfos, sortAgentSorts } from '../../../tools/utils' +import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' import { delConversation, fetchAppInfo, @@ -40,6 +43,32 @@ import { useAppFavicon } from '@/hooks/use-app-favicon' import { InputVarType } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' +function getFormattedChatList(messages: any[]) { + const newChatList: ChatItem[] = [] + messages.forEach((item) => { + const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] + newChatList.push({ + id: `question-${item.id}`, + content: item.query, + isAnswer: false, + message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))), + parentMessageId: item.parent_message_id || undefined, + }) + const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] + newChatList.push({ + id: item.id, + content: item.answer, + agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files), + feedback: item.feedback, + isAnswer: true, + citation: item.retriever_resources, + message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))), + parentMessageId: `question-${item.id}`, + }) + }) + return newChatList +} + export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo) @@ -109,9 +138,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const { data: appConversationData, isLoading: appConversationDataLoading, mutate: mutateAppConversationData } = useSWR(['appConversationData', isInstalledApp, appId, false], () => fetchConversations(isInstalledApp, appId, undefined, false, 100)) const { data: appChatListData, isLoading: appChatListDataLoading } = useSWR(chatShouldReloadKey ? ['appChatList', chatShouldReloadKey, isInstalledApp, appId] : null, () => fetchChatList(chatShouldReloadKey, isInstalledApp, appId)) - const appPrevChatList = useMemo( + const appPrevChatTree = useMemo( () => (currentConversationId && appChatListData?.data.length) - ? getPrevChatList(appChatListData.data) + ? buildChatItemTree(getFormattedChatList(appChatListData.data)) : [], [appChatListData, currentConversationId], ) @@ -403,7 +432,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { appConversationDataLoading, appChatListData, appChatListDataLoading, - appPrevChatList, + appPrevChatTree, pinnedConversationList, conversationList, showConfigPanelBeforeChat, diff --git a/web/app/components/base/chat/chat-with-history/index.tsx b/web/app/components/base/chat/chat-with-history/index.tsx index 16524406d4..7282dd4216 100644 --- a/web/app/components/base/chat/chat-with-history/index.tsx +++ b/web/app/components/base/chat/chat-with-history/index.tsx @@ -30,7 +30,7 @@ const ChatWithHistory: FC = ({ appInfoError, appData, appInfoLoading, - appPrevChatList, + appPrevChatTree, showConfigPanelBeforeChat, appChatListDataLoading, chatShouldReloadKey, @@ -38,7 +38,7 @@ const ChatWithHistory: FC = ({ themeBuilder, } = useChatWithHistoryContext() - const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatList.length) + const chatReady = (!showConfigPanelBeforeChat || !!appPrevChatTree.length) const customConfig = appData?.custom_config const site = appData?.site @@ -76,9 +76,9 @@ const ChatWithHistory: FC = ({ ) } -
+
{ - showConfigPanelBeforeChat && !appChatListDataLoading && !appPrevChatList.length && ( + showConfigPanelBeforeChat && !appChatListDataLoading && !appPrevChatTree.length && (
@@ -120,7 +120,7 @@ const ChatWithHistoryWrap: FC = ({ appChatListDataLoading, currentConversationId, currentConversationItem, - appPrevChatList, + appPrevChatTree, pinnedConversationList, conversationList, showConfigPanelBeforeChat, @@ -154,7 +154,7 @@ const ChatWithHistoryWrap: FC = ({ appChatListDataLoading, currentConversationId, currentConversationItem, - appPrevChatList, + appPrevChatTree, pinnedConversationList, conversationList, showConfigPanelBeforeChat, diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 2ceaf81e78..3217a3f4dd 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -209,19 +209,19 @@ const Answer: FC = ({ } {item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined &&
- {item.siblingIndex + 1} / {item.siblingCount} + {item.siblingIndex + 1} / {item.siblingCount}
}
diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts index fa923ca009..bcd08c8ce6 100644 --- a/web/app/components/base/chat/chat/hooks.ts +++ b/web/app/components/base/chat/chat/hooks.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useMemo, useRef, useState, } from 'react' @@ -12,8 +13,10 @@ import { v4 as uuidV4 } from 'uuid' import type { ChatConfig, ChatItem, + ChatItemInTree, Inputs, } from '../types' +import { getThreadMessages } from '../utils' import type { InputForm } from './type' import { getProcessedInputs, @@ -46,7 +49,7 @@ export const useChat = ( inputs: Inputs inputsForm: InputForm[] }, - prevChatList?: ChatItem[], + prevChatTree?: ChatItemInTree[], stopChat?: (taskId: string) => void, ) => { const { t } = useTranslation() @@ -56,14 +59,48 @@ export const useChat = ( const hasStopResponded = useRef(false) const [isResponding, setIsResponding] = useState(false) const isRespondingRef = useRef(false) - const [chatList, setChatList] = useState(prevChatList || []) - const chatListRef = useRef(prevChatList || []) const taskIdRef = useRef('') const [suggestedQuestions, setSuggestQuestions] = useState([]) const conversationMessagesAbortControllerRef = useRef(null) const suggestedQuestionsAbortControllerRef = useRef(null) const params = useParams() const pathname = usePathname() + + const [chatTree, setChatTree] = useState(prevChatTree || []) + const chatTreeRef = useRef(chatTree) + const [targetMessageId, setTargetMessageId] = useState() + const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId]) + + const getIntroduction = useCallback((str: string) => { + return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || []) + }, [formSettings?.inputs, formSettings?.inputsForm]) + + /** Final chat list that will be rendered */ + const chatList = useMemo(() => { + const ret = [...threadMessages] + if (config?.opening_statement) { + const index = threadMessages.findIndex(item => item.isOpeningStatement) + + if (index > -1) { + ret[index] = { + ...ret[index], + content: getIntroduction(config.opening_statement), + suggestedQuestions: config.suggested_questions, + } + } + else { + ret.unshift({ + id: `${Date.now()}`, + content: getIntroduction(config.opening_statement), + isAnswer: true, + isOpeningStatement: true, + suggestedQuestions: config.suggested_questions, + }) + } + } + return ret + }, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions]) + useEffect(() => { setAutoFreeze(false) return () => { @@ -71,43 +108,50 @@ export const useChat = ( } }, []) - const handleUpdateChatList = useCallback((newChatList: ChatItem[]) => { - setChatList(newChatList) - chatListRef.current = newChatList + /** Find the target node by bfs and then operate on it */ + const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => { + return produce(chatTreeRef.current, (draft) => { + const queue: ChatItemInTree[] = [...draft] + while (queue.length > 0) { + const current = queue.shift()! + if (current.id === targetId) { + operation(current) + break + } + if (current.children) + queue.push(...current.children) + } + }) }, []) + + type UpdateChatTreeNode = { + (id: string, fields: Partial): void + (id: string, update: (node: ChatItemInTree) => void): void + } + + const updateChatTreeNode: UpdateChatTreeNode = useCallback(( + id: string, + fieldsOrUpdate: Partial | ((node: ChatItemInTree) => void), + ) => { + const nextState = produceChatTreeNode(id, (node) => { + if (typeof fieldsOrUpdate === 'function') { + fieldsOrUpdate(node) + } + else { + Object.keys(fieldsOrUpdate).forEach((key) => { + (node as any)[key] = (fieldsOrUpdate as any)[key] + }) + } + }) + setChatTree(nextState) + chatTreeRef.current = nextState + }, [produceChatTreeNode]) + const handleResponding = useCallback((isResponding: boolean) => { setIsResponding(isResponding) isRespondingRef.current = isResponding }, []) - const getIntroduction = useCallback((str: string) => { - return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || []) - }, [formSettings?.inputs, formSettings?.inputsForm]) - useEffect(() => { - if (config?.opening_statement) { - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const index = draft.findIndex(item => item.isOpeningStatement) - - if (index > -1) { - draft[index] = { - ...draft[index], - content: getIntroduction(config.opening_statement), - suggestedQuestions: config.suggested_questions, - } - } - else { - draft.unshift({ - id: `${Date.now()}`, - content: getIntroduction(config.opening_statement), - isAnswer: true, - isOpeningStatement: true, - suggestedQuestions: config.suggested_questions, - }) - } - })) - } - }, [config?.opening_statement, getIntroduction, config?.suggested_questions, handleUpdateChatList]) - const handleStop = useCallback(() => { hasStopResponded.current = true handleResponding(false) @@ -123,50 +167,50 @@ export const useChat = ( conversationId.current = '' taskIdRef.current = '' handleStop() - const newChatList = config?.opening_statement - ? [{ - id: `${Date.now()}`, - content: config.opening_statement, - isAnswer: true, - isOpeningStatement: true, - suggestedQuestions: config.suggested_questions, - }] - : [] - handleUpdateChatList(newChatList) + setChatTree([]) setSuggestQuestions([]) - }, [ - config, - handleStop, - handleUpdateChatList, - ]) + }, [handleStop]) - const updateCurrentQA = useCallback(({ + const updateCurrentQAOnTree = useCallback(({ + parentId, responseItem, - questionId, - placeholderAnswerId, + placeholderQuestionId, questionItem, }: { + parentId?: string responseItem: ChatItem - questionId: string - placeholderAnswerId: string + placeholderQuestionId: string questionItem: ChatItem }) => { - const newListWithAnswer = produce( - chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), - (draft) => { - if (!draft.find(item => item.id === questionId)) - draft.push({ ...questionItem }) - - draft.push({ ...responseItem }) + let nextState: ChatItemInTree[] + const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] } + if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) { + // QA whose parent is not provided is considered as a first message of the conversation, + // and it should be a root node of the chat tree + nextState = produce(chatTree, (draft) => { + draft.push(currentQA) }) - handleUpdateChatList(newListWithAnswer) - }, [handleUpdateChatList]) + } + else { + // find the target QA in the tree and update it; if not found, insert it to its parent node + nextState = produceChatTreeNode(parentId!, (parentNode) => { + const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id)) + if (questionNodeIndex === -1) + parentNode.children!.push(currentQA) + else + parentNode.children![questionNodeIndex] = currentQA + }) + } + setChatTree(nextState) + chatTreeRef.current = nextState + }, [chatTree, produceChatTreeNode]) const handleSend = useCallback(async ( url: string, data: { query: string files?: FileEntity[] + parent_message_id?: string [key: string]: any }, { @@ -183,12 +227,15 @@ export const useChat = ( return false } - const questionId = `question-${Date.now()}` + const parentMessage = threadMessages.find(item => item.id === data.parent_message_id) + + const placeholderQuestionId = `question-${Date.now()}` const questionItem = { - id: questionId, + id: placeholderQuestionId, content: data.query, isAnswer: false, message_files: data.files, + parentMessageId: data.parent_message_id, } const placeholderAnswerId = `answer-placeholder-${Date.now()}` @@ -196,18 +243,27 @@ export const useChat = ( id: placeholderAnswerId, content: '', isAnswer: true, + parentMessageId: questionItem.id, + siblingIndex: parentMessage?.children?.length ?? chatTree.length, } - const newList = [...chatListRef.current, questionItem, placeholderAnswerItem] - handleUpdateChatList(newList) + setTargetMessageId(parentMessage?.id) + updateCurrentQAOnTree({ + parentId: data.parent_message_id, + responseItem: placeholderAnswerItem, + placeholderQuestionId, + questionItem, + }) // answer - const responseItem: ChatItem = { + const responseItem: ChatItemInTree = { id: placeholderAnswerId, content: '', agent_thoughts: [], message_files: [], isAnswer: true, + parentMessageId: questionItem.id, + siblingIndex: parentMessage?.children?.length ?? chatTree.length, } handleResponding(true) @@ -268,7 +324,9 @@ export const useChat = ( } if (messageId && !hasSetResponseId) { + questionItem.id = `question-${messageId}` responseItem.id = messageId + responseItem.parentMessageId = questionItem.id hasSetResponseId = true } @@ -279,11 +337,11 @@ export const useChat = ( if (messageId) responseItem.id = messageId - updateCurrentQA({ - responseItem, - questionId, - placeholderAnswerId, + updateCurrentQAOnTree({ + placeholderQuestionId, questionItem, + responseItem, + parentId: data.parent_message_id, }) }, async onCompleted(hasError?: boolean) { @@ -304,43 +362,32 @@ export const useChat = ( if (!newResponseItem) return - const newChatList = produce(chatListRef.current, (draft) => { - const index = draft.findIndex(item => item.id === responseItem.id) - if (index !== -1) { - const question = draft[index - 1] - draft[index - 1] = { - ...question, - } - draft[index] = { - ...draft[index], - content: newResponseItem.answer, - log: [ - ...newResponseItem.message, - ...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant' - ? [ - { - role: 'assistant', - text: newResponseItem.answer, - files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [], - }, - ] - : []), - ], - more: { - time: formatTime(newResponseItem.created_at, 'hh:mm A'), - tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens, - latency: newResponseItem.provider_response_latency.toFixed(2), - }, - // for agent log - conversationId: conversationId.current, - input: { - inputs: newResponseItem.inputs, - query: newResponseItem.query, - }, - } - } + updateChatTreeNode(responseItem.id, { + content: newResponseItem.answer, + log: [ + ...newResponseItem.message, + ...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant' + ? [ + { + role: 'assistant', + text: newResponseItem.answer, + files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [], + }, + ] + : []), + ], + more: { + time: formatTime(newResponseItem.created_at, 'hh:mm A'), + tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens, + latency: newResponseItem.provider_response_latency.toFixed(2), + }, + // for agent log + conversationId: conversationId.current, + input: { + inputs: newResponseItem.inputs, + query: newResponseItem.query, + }, }) - handleUpdateChatList(newChatList) } if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) { try { @@ -360,11 +407,11 @@ export const useChat = ( if (lastThought) responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, file] - updateCurrentQA({ - responseItem, - questionId, - placeholderAnswerId, + updateCurrentQAOnTree({ + placeholderQuestionId, questionItem, + responseItem, + parentId: data.parent_message_id, }) }, onThought(thought) { @@ -372,6 +419,7 @@ export const useChat = ( const response = responseItem as any if (thought.message_id && !hasSetResponseId) response.id = thought.message_id + if (response.agent_thoughts.length === 0) { response.agent_thoughts.push(thought) } @@ -387,11 +435,11 @@ export const useChat = ( responseItem.agent_thoughts!.push(thought) } } - updateCurrentQA({ - responseItem, - questionId, - placeholderAnswerId, + updateCurrentQAOnTree({ + placeholderQuestionId, questionItem, + responseItem, + parentId: data.parent_message_id, }) }, onMessageEnd: (messageEnd) => { @@ -401,43 +449,36 @@ export const useChat = ( id: messageEnd.metadata.annotation_reply.id, authorName: messageEnd.metadata.annotation_reply.account.name, }) - const baseState = chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId) - const newListWithAnswer = produce( - baseState, - (draft) => { - if (!draft.find(item => item.id === questionId)) - draft.push({ ...questionItem }) - - draft.push({ - ...responseItem, - }) - }) - handleUpdateChatList(newListWithAnswer) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) return } responseItem.citation = messageEnd.metadata?.retriever_resources || [] const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || []) responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id') - const newListWithAnswer = produce( - chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), - (draft) => { - if (!draft.find(item => item.id === questionId)) - draft.push({ ...questionItem }) - - draft.push({ ...responseItem }) - }) - handleUpdateChatList(newListWithAnswer) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) }, onMessageReplace: (messageReplace) => { responseItem.content = messageReplace.answer }, onError() { handleResponding(false) - const newChatList = produce(chatListRef.current, (draft) => { - draft.splice(draft.findIndex(item => item.id === placeholderAnswerId), 1) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, }) - handleUpdateChatList(newChatList) }, onWorkflowStarted: ({ workflow_run_id, task_id }) => { taskIdRef.current = task_id @@ -446,89 +487,84 @@ export const useChat = ( status: WorkflowRunningStatus.Running, tracing: [], } - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) }, - onWorkflowFinished: ({ data }) => { - responseItem.workflowProcess!.status = data.status as WorkflowRunningStatus - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + onWorkflowFinished: ({ data: workflowFinishedData }) => { + responseItem.workflowProcess!.status = workflowFinishedData.status as WorkflowRunningStatus + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) }, - onIterationStart: ({ data }) => { + onIterationStart: ({ data: iterationStartedData }) => { responseItem.workflowProcess!.tracing!.push({ - ...data, + ...iterationStartedData, status: WorkflowRunningStatus.Running, } as any) - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) }, - onIterationFinish: ({ data }) => { + onIterationFinish: ({ data: iterationFinishedData }) => { const tracing = responseItem.workflowProcess!.tracing! - const iterationIndex = tracing.findIndex(item => item.node_id === data.node_id - && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! + const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id + && (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))! tracing[iterationIndex] = { ...tracing[iterationIndex], - ...data, + ...iterationFinishedData, status: WorkflowRunningStatus.Succeeded, } as any - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) }, - onNodeStarted: ({ data }) => { - if (data.iteration_id) + onNodeStarted: ({ data: nodeStartedData }) => { + if (nodeStartedData.iteration_id) return responseItem.workflowProcess!.tracing!.push({ - ...data, + ...nodeStartedData, status: WorkflowRunningStatus.Running, } as any) - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, + }) }, - onNodeFinished: ({ data }) => { - if (data.iteration_id) + onNodeFinished: ({ data: nodeFinishedData }) => { + if (nodeFinishedData.iteration_id) return const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => { if (!item.execution_metadata?.parallel_id) - return item.node_id === data.node_id + return item.node_id === nodeFinishedData.node_id - return item.node_id === data.node_id && (item.execution_metadata?.parallel_id === data.execution_metadata.parallel_id) + return item.node_id === nodeFinishedData.node_id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata.parallel_id) + }) + responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any + + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: data.parent_message_id, }) - responseItem.workflowProcess!.tracing[currentIndex] = data as any - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) }, onTTSChunk: (messageId: string, audio: string) => { if (!audio || audio === '') @@ -542,11 +578,13 @@ export const useChat = ( }) return true }, [ - config?.suggested_questions_after_answer, - updateCurrentQA, t, + chatTree.length, + threadMessages, + config?.suggested_questions_after_answer, + updateCurrentQAOnTree, + updateChatTreeNode, notify, - handleUpdateChatList, handleResponding, formatTime, params.token, @@ -556,76 +594,61 @@ export const useChat = ( ]) const handleAnnotationEdited = useCallback((query: string, answer: string, index: number) => { - handleUpdateChatList(chatListRef.current.map((item, i) => { - if (i === index - 1) { - return { - ...item, - content: query, - } - } - if (i === index) { - return { - ...item, - content: answer, - annotation: { - ...item.annotation, - logAnnotation: undefined, - } as any, - } - } - return item - })) - }, [handleUpdateChatList]) + const targetQuestionId = chatList[index - 1].id + const targetAnswerId = chatList[index].id + + updateChatTreeNode(targetQuestionId, { + content: query, + }) + updateChatTreeNode(targetAnswerId, { + content: answer, + annotation: { + ...chatList[index].annotation, + logAnnotation: undefined, + } as any, + }) + }, [chatList, updateChatTreeNode]) + const handleAnnotationAdded = useCallback((annotationId: string, authorName: string, query: string, answer: string, index: number) => { - handleUpdateChatList(chatListRef.current.map((item, i) => { - if (i === index - 1) { - return { - ...item, - content: query, - } - } - if (i === index) { - const answerItem = { - ...item, - content: item.content, - annotation: { - id: annotationId, - authorName, - logAnnotation: { - content: answer, - account: { - id: '', - name: authorName, - email: '', - }, - }, - } as Annotation, - } - return answerItem - } - return item - })) - }, [handleUpdateChatList]) - const handleAnnotationRemoved = useCallback((index: number) => { - handleUpdateChatList(chatListRef.current.map((item, i) => { - if (i === index) { - return { - ...item, - content: item.content, - annotation: { - ...(item.annotation || {}), + const targetQuestionId = chatList[index - 1].id + const targetAnswerId = chatList[index].id + + updateChatTreeNode(targetQuestionId, { + content: query, + }) + + updateChatTreeNode(targetAnswerId, { + content: chatList[index].content, + annotation: { + id: annotationId, + authorName, + logAnnotation: { + content: answer, + account: { id: '', - } as Annotation, - } - } - return item - })) - }, [handleUpdateChatList]) + name: authorName, + email: '', + }, + }, + } as Annotation, + }) + }, [chatList, updateChatTreeNode]) + + const handleAnnotationRemoved = useCallback((index: number) => { + const targetAnswerId = chatList[index].id + + updateChatTreeNode(targetAnswerId, { + content: chatList[index].content, + annotation: { + ...(chatList[index].annotation || {}), + id: '', + } as Annotation, + }) + }, [chatList, updateChatTreeNode]) return { chatList, - chatListRef, - handleUpdateChatList, + setTargetMessageId, conversationId: conversationId.current, isResponding, setIsResponding, diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx index 04f65b549c..8d0af02f8f 100644 --- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx +++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx @@ -3,10 +3,11 @@ import Chat from '../chat' import type { ChatConfig, ChatItem, + ChatItemInTree, OnSend, } from '../types' import { useChat } from '../chat/hooks' -import { getLastAnswer } from '../utils' +import { getLastAnswer, isValidGeneratedAnswer } from '../utils' import { useEmbeddedChatbotContext } from './context' import ConfigPanel from './config-panel' import { isDify } from './utils' @@ -51,13 +52,12 @@ const ChatWrapper = () => { } as ChatConfig }, [appParams, currentConversationItem?.introduction, currentConversationId]) const { - chatListRef, chatList, + setTargetMessageId, handleSend, handleStop, isResponding, suggestedQuestions, - handleUpdateChatList, } = useChat( appConfig, { @@ -71,15 +71,15 @@ const ChatWrapper = () => { useEffect(() => { if (currentChatInstanceRef.current) currentChatInstanceRef.current.handleStop = handleStop - }, []) + }, [currentChatInstanceRef, handleStop]) - const doSend: OnSend = useCallback((message, files, last_answer) => { + const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { const data: any = { query: message, files, inputs: currentConversationId ? currentConversationItem?.inputs : newConversationInputs, conversation_id: currentConversationId, - parent_message_id: last_answer?.id || getLastAnswer(chatListRef.current)?.id || null, + parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, } handleSend( @@ -92,32 +92,21 @@ const ChatWrapper = () => { }, ) }, [ - chatListRef, - appConfig, + chatList, + handleNewConversationCompleted, + handleSend, currentConversationId, currentConversationItem, - handleSend, newConversationInputs, - handleNewConversationCompleted, isInstalledApp, appId, ]) - const doRegenerate = useCallback((chatItem: ChatItem) => { - const index = chatList.findIndex(item => item.id === chatItem.id) - if (index === -1) - return - - const prevMessages = chatList.slice(0, index) - const question = prevMessages.pop() - const lastAnswer = getLastAnswer(prevMessages) - - if (!question) - return - - handleUpdateChatList(prevMessages) - doSend(question.content, question.message_files, lastAnswer) - }, [chatList, handleUpdateChatList, doSend]) + const doRegenerate = useCallback((chatItem: ChatItemInTree) => { + const question = chatList.find(item => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find(item => item.id === question.parentMessageId) + doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) + }, [chatList, doSend]) const chatNode = useMemo(() => { if (inputsForms.length) { @@ -172,6 +161,7 @@ const ChatWrapper = () => { answerIcon={answerIcon} hideProcessDetail themeBuilder={themeBuilder} + switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)} /> ) } diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts index 8d9dacdcd7..851c82d8e4 100644 --- a/web/app/components/base/chat/types.ts +++ b/web/app/components/base/chat/types.ts @@ -67,9 +67,12 @@ export type ChatItem = IChatItem & { export type ChatItemInTree = { children?: ChatItemInTree[] -} & IChatItem +} & ChatItem -export type OnSend = (message: string, files?: FileEntity[], last_answer?: ChatItem | null) => void +export type OnSend = { + (message: string, files?: FileEntity[]): void + (message: string, files: FileEntity[] | undefined, isRegenerate: boolean, lastAnswer?: ChatItem | null): void +} export type OnRegenerate = (chatItem: ChatItem) => void diff --git a/web/app/components/base/chat/utils.ts b/web/app/components/base/chat/utils.ts index 326805c930..ce7a7c09b3 100644 --- a/web/app/components/base/chat/utils.ts +++ b/web/app/components/base/chat/utils.ts @@ -1,8 +1,6 @@ -import { addFileInfos, sortAgentSorts } from '../../tools/utils' import { UUID_NIL } from './constants' import type { IChatItem } from './chat/type' import type { ChatItem, ChatItemInTree } from './types' -import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' async function decodeBase64AndDecompress(base64String: string) { const binaryString = atob(base64String) @@ -21,67 +19,24 @@ function getProcessedInputsFromUrlParams(): Record { return inputs } -function getLastAnswer(chatList: ChatItem[]) { +function isValidGeneratedAnswer(item?: ChatItem | ChatItemInTree): boolean { + return !!item && item.isAnswer && !item.id.startsWith('answer-placeholder-') && !item.isOpeningStatement +} + +function getLastAnswer(chatList: T[]): T | null { for (let i = chatList.length - 1; i >= 0; i--) { const item = chatList[i] - if (item.isAnswer && !item.id.startsWith('answer-placeholder-') && !item.isOpeningStatement) + if (isValidGeneratedAnswer(item)) return item } return null } -function appendQAToChatList(chatList: ChatItem[], item: any) { - // we append answer first and then question since will reverse the whole chatList later - const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [] - chatList.push({ - id: item.id, - content: item.answer, - agent_thoughts: addFileInfos(item.agent_thoughts ? sortAgentSorts(item.agent_thoughts) : item.agent_thoughts, item.message_files), - feedback: item.feedback, - isAnswer: true, - citation: item.retriever_resources, - message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id }))), - }) - const questionFiles = item.message_files?.filter((file: any) => file.belongs_to === 'user') || [] - chatList.push({ - id: `question-${item.id}`, - content: item.query, - isAnswer: false, - message_files: getProcessedFilesFromResponse(questionFiles.map((item: any) => ({ ...item, related_id: item.id }))), - }) -} - /** - * Computes the latest thread messages from all messages of the conversation. - * Same logic as backend codebase `api/core/prompt/utils/extract_thread_messages.py` - * - * @param fetchedMessages - The history chat list data from the backend, sorted by created_at in descending order. This includes all flattened history messages of the conversation. - * @returns An array of ChatItems representing the latest thread. + * Build a chat item tree from a chat list + * @param allMessages - The chat list, sorted from oldest to newest + * @returns The chat item tree */ -function getPrevChatList(fetchedMessages: any[]) { - const ret: ChatItem[] = [] - let nextMessageId = null - - for (const item of fetchedMessages) { - if (!item.parent_message_id) { - appendQAToChatList(ret, item) - break - } - - if (!nextMessageId) { - appendQAToChatList(ret, item) - nextMessageId = item.parent_message_id - } - else { - if (item.id === nextMessageId || nextMessageId === UUID_NIL) { - appendQAToChatList(ret, item) - nextMessageId = item.parent_message_id - } - } - } - return ret.reverse() -} - function buildChatItemTree(allMessages: IChatItem[]): ChatItemInTree[] { const map: Record = {} const rootNodes: ChatItemInTree[] = [] @@ -208,7 +163,7 @@ function getThreadMessages(tree: ChatItemInTree[], targetMessageId?: string): Ch export { getProcessedInputsFromUrlParams, - getPrevChatList, + isValidGeneratedAnswer, getLastAnswer, buildChatItemTree, getThreadMessages, diff --git a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx index 42c30df7cf..9285516935 100644 --- a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx @@ -19,14 +19,14 @@ import ConversationVariableModal from './conversation-variable-modal' import { useChat } from './hooks' import type { ChatWrapperRefType } from './index' import Chat from '@/app/components/base/chat/chat' -import type { ChatItem, OnSend } from '@/app/components/base/chat/types' +import type { ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types' import { useFeatures } from '@/app/components/base/features/hooks' import { fetchSuggestedQuestions, stopChatMessageResponding, } from '@/service/debug' import { useStore as useAppStore } from '@/app/components/app/store' -import { getLastAnswer } from '@/app/components/base/chat/utils' +import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils' type ChatWrapperProps = { showConversationVariableModal: boolean @@ -65,13 +65,12 @@ const ChatWrapper = forwardRef(({ const { conversationId, chatList, - chatListRef, - handleUpdateChatList, handleStop, isResponding, suggestedQuestions, handleSend, handleRestart, + setTargetMessageId, } = useChat( config, { @@ -82,36 +81,26 @@ const ChatWrapper = forwardRef(({ taskId => stopChatMessageResponding(appDetail!.id, taskId), ) - const doSend = useCallback((query, files, last_answer) => { + const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => { handleSend( { - query, + query: message, files, inputs: workflowStore.getState().inputs, conversation_id: conversationId, - parent_message_id: last_answer?.id || getLastAnswer(chatListRef.current)?.id || null, + parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || undefined, }, { onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController), }, ) - }, [chatListRef, conversationId, handleSend, workflowStore, appDetail]) + }, [handleSend, workflowStore, conversationId, chatList, appDetail]) - const doRegenerate = useCallback((chatItem: ChatItem) => { - const index = chatList.findIndex(item => item.id === chatItem.id) - if (index === -1) - return - - const prevMessages = chatList.slice(0, index) - const question = prevMessages.pop() - const lastAnswer = getLastAnswer(prevMessages) - - if (!question) - return - - handleUpdateChatList(prevMessages) - doSend(question.content, question.message_files, lastAnswer) - }, [chatList, handleUpdateChatList, doSend]) + const doRegenerate = useCallback((chatItem: ChatItemInTree) => { + const question = chatList.find(item => item.id === chatItem.parentMessageId)! + const parentAnswer = chatList.find(item => item.id === question.parentMessageId) + doSend(question.content, question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null) + }, [chatList, doSend]) useImperativeHandle(ref, () => { return { @@ -159,6 +148,7 @@ const ChatWrapper = forwardRef(({ suggestedQuestions={suggestedQuestions} showPromptLog chatAnswerContainerInner='!pr-2' + switchSibling={setTargetMessageId} /> {showConversationVariableModal && ( void @@ -39,7 +42,7 @@ export const useChat = ( inputs: Inputs inputsForm: InputForm[] }, - prevChatList?: ChatItem[], + prevChatTree?: ChatItemInTree[], stopChat?: (taskId: string) => void, ) => { const { t } = useTranslation() @@ -49,16 +52,54 @@ export const useChat = ( const workflowStore = useWorkflowStore() const conversationId = useRef('') const taskIdRef = useRef('') - const [chatList, setChatList] = useState(prevChatList || []) - const chatListRef = useRef(prevChatList || []) const [isResponding, setIsResponding] = useState(false) const isRespondingRef = useRef(false) const [suggestedQuestions, setSuggestQuestions] = useState([]) const suggestedQuestionsAbortControllerRef = useRef(null) - const { setIterTimes, } = workflowStore.getState() + + const handleResponding = useCallback((isResponding: boolean) => { + setIsResponding(isResponding) + isRespondingRef.current = isResponding + }, []) + + const [chatTree, setChatTree] = useState(prevChatTree || []) + const chatTreeRef = useRef(chatTree) + const [targetMessageId, setTargetMessageId] = useState() + const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId]) + + const getIntroduction = useCallback((str: string) => { + return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || []) + }, [formSettings?.inputs, formSettings?.inputsForm]) + + /** Final chat list that will be rendered */ + const chatList = useMemo(() => { + const ret = [...threadMessages] + if (config?.opening_statement) { + const index = threadMessages.findIndex(item => item.isOpeningStatement) + + if (index > -1) { + ret[index] = { + ...ret[index], + content: getIntroduction(config.opening_statement), + suggestedQuestions: config.suggested_questions, + } + } + else { + ret.unshift({ + id: `${Date.now()}`, + content: getIntroduction(config.opening_statement), + isAnswer: true, + isOpeningStatement: true, + suggestedQuestions: config.suggested_questions, + }) + } + } + return ret + }, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions]) + useEffect(() => { setAutoFreeze(false) return () => { @@ -66,43 +107,21 @@ export const useChat = ( } }, []) - const handleUpdateChatList = useCallback((newChatList: ChatItem[]) => { - setChatList(newChatList) - chatListRef.current = newChatList - }, []) - - const handleResponding = useCallback((isResponding: boolean) => { - setIsResponding(isResponding) - isRespondingRef.current = isResponding - }, []) - - const getIntroduction = useCallback((str: string) => { - return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || []) - }, [formSettings?.inputs, formSettings?.inputsForm]) - useEffect(() => { - if (config?.opening_statement) { - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const index = draft.findIndex(item => item.isOpeningStatement) - - if (index > -1) { - draft[index] = { - ...draft[index], - content: getIntroduction(config.opening_statement), - suggestedQuestions: config.suggested_questions, - } + /** Find the target node by bfs and then operate on it */ + const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => { + return produce(chatTreeRef.current, (draft) => { + const queue: ChatItemInTree[] = [...draft] + while (queue.length > 0) { + const current = queue.shift()! + if (current.id === targetId) { + operation(current) + break } - else { - draft.unshift({ - id: `${Date.now()}`, - content: getIntroduction(config.opening_statement), - isAnswer: true, - isOpeningStatement: true, - suggestedQuestions: config.suggested_questions, - }) - } - })) - } - }, [config?.opening_statement, getIntroduction, config?.suggested_questions, handleUpdateChatList]) + if (current.children) + queue.push(...current.children) + } + }) + }, []) const handleStop = useCallback(() => { hasStopResponded.current = true @@ -119,50 +138,52 @@ export const useChat = ( taskIdRef.current = '' handleStop() setIterTimes(DEFAULT_ITER_TIMES) - const newChatList = config?.opening_statement - ? [{ - id: `${Date.now()}`, - content: config.opening_statement, - isAnswer: true, - isOpeningStatement: true, - suggestedQuestions: config.suggested_questions, - }] - : [] - handleUpdateChatList(newChatList) + setChatTree([]) setSuggestQuestions([]) }, [ - config, handleStop, - handleUpdateChatList, setIterTimes, ]) - const updateCurrentQA = useCallback(({ + const updateCurrentQAOnTree = useCallback(({ + parentId, responseItem, - questionId, - placeholderAnswerId, + placeholderQuestionId, questionItem, }: { + parentId?: string responseItem: ChatItem - questionId: string - placeholderAnswerId: string + placeholderQuestionId: string questionItem: ChatItem }) => { - const newListWithAnswer = produce( - chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), - (draft) => { - if (!draft.find(item => item.id === questionId)) - draft.push({ ...questionItem }) - - draft.push({ ...responseItem }) + let nextState: ChatItemInTree[] + const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] } + if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) { + // QA whose parent is not provided is considered as a first message of the conversation, + // and it should be a root node of the chat tree + nextState = produce(chatTree, (draft) => { + draft.push(currentQA) }) - handleUpdateChatList(newListWithAnswer) - }, [handleUpdateChatList]) + } + else { + // find the target QA in the tree and update it; if not found, insert it to its parent node + nextState = produceChatTreeNode(parentId!, (parentNode) => { + const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id)) + if (questionNodeIndex === -1) + parentNode.children!.push(currentQA) + else + parentNode.children![questionNodeIndex] = currentQA + }) + } + setChatTree(nextState) + chatTreeRef.current = nextState + }, [chatTree, produceChatTreeNode]) const handleSend = useCallback(( params: { query: string files?: FileEntity[] + parent_message_id?: string [key: string]: any }, { @@ -174,12 +195,15 @@ export const useChat = ( return false } - const questionId = `question-${Date.now()}` + const parentMessage = threadMessages.find(item => item.id === params.parent_message_id) + + const placeholderQuestionId = `question-${Date.now()}` const questionItem = { - id: questionId, + id: placeholderQuestionId, content: params.query, isAnswer: false, message_files: params.files, + parentMessageId: params.parent_message_id, } const placeholderAnswerId = `answer-placeholder-${Date.now()}` @@ -187,10 +211,17 @@ export const useChat = ( id: placeholderAnswerId, content: '', isAnswer: true, + parentMessageId: questionItem.id, + siblingIndex: parentMessage?.children?.length ?? chatTree.length, } - const newList = [...chatListRef.current, questionItem, placeholderAnswerItem] - handleUpdateChatList(newList) + setTargetMessageId(parentMessage?.id) + updateCurrentQAOnTree({ + parentId: params.parent_message_id, + responseItem: placeholderAnswerItem, + placeholderQuestionId, + questionItem, + }) // answer const responseItem: ChatItem = { @@ -199,6 +230,8 @@ export const useChat = ( agent_thoughts: [], message_files: [], isAnswer: true, + parentMessageId: questionItem.id, + siblingIndex: parentMessage?.children?.length ?? chatTree.length, } handleResponding(true) @@ -230,7 +263,9 @@ export const useChat = ( responseItem.content = responseItem.content + message if (messageId && !hasSetResponseId) { + questionItem.id = `question-${messageId}` responseItem.id = messageId + responseItem.parentMessageId = questionItem.id hasSetResponseId = true } @@ -241,11 +276,11 @@ export const useChat = ( if (messageId) responseItem.id = messageId - updateCurrentQA({ - responseItem, - questionId, - placeholderAnswerId, + updateCurrentQAOnTree({ + placeholderQuestionId, questionItem, + responseItem, + parentId: params.parent_message_id, }) }, async onCompleted(hasError?: boolean, errorMessage?: string) { @@ -255,15 +290,12 @@ export const useChat = ( if (errorMessage) { responseItem.content = errorMessage responseItem.isError = true - const newListWithAnswer = produce( - chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), - (draft) => { - if (!draft.find(item => item.id === questionId)) - draft.push({ ...questionItem }) - - draft.push({ ...responseItem }) - }) - handleUpdateChatList(newListWithAnswer) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) } return } @@ -286,15 +318,12 @@ export const useChat = ( const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || []) responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id') - const newListWithAnswer = produce( - chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId), - (draft) => { - if (!draft.find(item => item.id === questionId)) - draft.push({ ...questionItem }) - - draft.push({ ...responseItem }) - }) - handleUpdateChatList(newListWithAnswer) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) }, onMessageReplace: (messageReplace) => { responseItem.content = messageReplace.answer @@ -309,23 +338,21 @@ export const useChat = ( status: WorkflowRunningStatus.Running, tracing: [], } - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) }, onWorkflowFinished: ({ data }) => { responseItem.workflowProcess!.status = data.status as WorkflowRunningStatus - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) }, onIterationStart: ({ data }) => { responseItem.workflowProcess!.tracing!.push({ @@ -333,13 +360,12 @@ export const useChat = ( status: NodeRunningStatus.Running, details: [], } as any) - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) }, onIterationNext: ({ data }) => { const tracing = responseItem.workflowProcess!.tracing! @@ -347,10 +373,12 @@ export const useChat = ( && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))! iterations.details!.push([]) - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.length - 1 - draft[currentIndex] = responseItem - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) }, onIterationFinish: ({ data }) => { const tracing = responseItem.workflowProcess!.tracing! @@ -361,10 +389,12 @@ export const useChat = ( ...data, status: NodeRunningStatus.Succeeded, } as any - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.length - 1 - draft[currentIndex] = responseItem - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) }, onNodeStarted: ({ data }) => { if (data.iteration_id) @@ -374,13 +404,12 @@ export const useChat = ( ...data, status: NodeRunningStatus.Running, } as any) - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) }, onNodeRetry: ({ data }) => { if (data.iteration_id) @@ -422,23 +451,21 @@ export const useChat = ( : {}), ...data, } as any - handleUpdateChatList(produce(chatListRef.current, (draft) => { - const currentIndex = draft.findIndex(item => item.id === responseItem.id) - draft[currentIndex] = { - ...draft[currentIndex], - ...responseItem, - } - })) + updateCurrentQAOnTree({ + placeholderQuestionId, + questionItem, + responseItem, + parentId: params.parent_message_id, + }) }, }, ) - }, [handleRun, handleResponding, handleUpdateChatList, notify, t, updateCurrentQA, config.suggested_questions_after_answer?.enabled, formSettings]) + }, [threadMessages, chatTree.length, updateCurrentQAOnTree, handleResponding, formSettings?.inputsForm, handleRun, notify, t, config?.suggested_questions_after_answer?.enabled]) return { conversationId: conversationId.current, chatList, - chatListRef, - handleUpdateChatList, + setTargetMessageId, handleSend, handleStop, handleRestart,