feat: enhance chat functionality with workflow resumption and support regeneration (#31281)

This commit is contained in:
Wu Tianwei
2026-01-20 16:52:04 +08:00
committed by GitHub
parent 1014852ebd
commit f3ec6ad53c
18 changed files with 747 additions and 183 deletions

View File

@ -5,7 +5,7 @@ import type {
ChatItemInTree,
OnSend,
} from '../types'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import AnswerIcon from '@/app/components/base/answer-icon'
import AppIcon from '@/app/components/base/app-icon'
import InputsForm from '@/app/components/base/chat/chat-with-history/inputs-form'
@ -70,10 +70,10 @@ const ChatWrapper = () => {
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
handleResume,
handleSwitchSibling,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
@ -136,33 +136,38 @@ const ChatWrapper = () => {
}, [respondingState, setIsResponding])
// Resume paused workflows when chat history is loaded
const resumedWorkflowsRef = useRef<Set<string>>(new Set())
useEffect(() => {
if (!appPrevChatTree || appPrevChatTree.length === 0)
return
// Find all answer items with workflow_run_id that need resumption
const checkForPausedWorkflows = (nodes: ChatItemInTree[]) => {
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
let lastPausedNode: ChatItemInTree | undefined
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
nodes.forEach((node) => {
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0) {
// This is a paused workflow waiting for human input
const workflowKey = `${node.workflow_run_id}-${node.id}`
if (!resumedWorkflowsRef.current.has(workflowKey)) {
resumedWorkflowsRef.current.add(workflowKey)
// Re-subscribe to workflow events
handleResume(
node.id,
node.workflow_run_id,
!isInstalledApp,
)
}
}
// DFS: recurse to children first
if (node.children && node.children.length > 0)
checkForPausedWorkflows(node.children)
findLastPausedWorkflow(node.children)
// Track the last node with humanInputFormDataList
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
lastPausedNode = node
})
}
checkForPausedWorkflows(appPrevChatTree)
findLastPausedWorkflow(appPrevChatTree)
// Only resume the last paused workflow
if (lastPausedNode) {
handleResume(
lastPausedNode.id,
lastPausedNode.workflow_run_id!,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
},
)
}
}, [])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
@ -191,6 +196,14 @@ const ChatWrapper = () => {
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
const doSwitchSibling = useCallback((siblingMessageId: string) => {
handleSwitchSibling(siblingMessageId, {
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
})
}, [handleSwitchSibling, isInstalledApp, appId, currentConversationId, handleNewConversationCompleted])
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
@ -325,7 +338,7 @@ const ChatWrapper = () => {
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
sidebarCollapseState={sidebarCollapseState}
questionIcon={

View File

@ -76,8 +76,10 @@ const Answer: FC<AnswerProps> = ({
const [containerWidth, setContainerWidth] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
const [humanInputFormContainerWidth, setHumanInputFormContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const humanInputFormContainerRef = useRef<HTMLDivElement>(null)
const {
getHumanInputNodeData,
@ -101,12 +103,23 @@ const Answer: FC<AnswerProps> = ({
getContentWidth()
}, [responding])
const getHumanInputFormContainerWidth = () => {
if (humanInputFormContainerRef.current)
setHumanInputFormContainerWidth(humanInputFormContainerRef.current?.clientWidth)
}
useEffect(() => {
if (hasHumanInputs)
getHumanInputFormContainerWidth()
}, [hasHumanInputs])
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
useEffect(() => {
if (!containerRef.current)
return
const resizeObserver = new ResizeObserver(() => {
getContentWidth()
getHumanInputFormContainerWidth()
})
resizeObserver.observe(containerRef.current)
return () => {
@ -144,8 +157,23 @@ const Answer: FC<AnswerProps> = ({
{hasHumanInputs && (
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
<div
ref={humanInputFormContainerRef}
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', (workflowProcess || hasHumanInputs) && 'w-full')}
>
{
!responding && contentIsEmpty && !hasAgentThoughts && (
<Operation
hasWorkflowProcess={!!workflowProcess}
maxSize={containerWidth - humanInputFormContainerWidth - 4}
contentWidth={humanInputFormContainerWidth}
item={item}
question={question}
index={index}
showPromptLog={showPromptLog}
noChatInput={noChatInput}
/>
)
}
{/** Render workflow process */}
{
workflowProcess && (
@ -173,6 +201,23 @@ const Answer: FC<AnswerProps> = ({
/>
)
}
{
item.siblingCount
&& item.siblingCount > 1
&& item.siblingIndex !== undefined
&& !responding
&& contentIsEmpty
&& !hasAgentThoughts
&& (
<ContentSwitch
count={item.siblingCount}
currentIndex={item.siblingIndex}
prevDisabled={!item.prevSibling}
nextDisabled={!item.nextSibling}
switchSibling={handleSwitchSibling}
/>
)
}
</div>
</div>
)}

View File

@ -187,7 +187,7 @@ const Operation: FC<OperationProps> = ({
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
>
{shouldShowUserFeedbackBar && (
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
@ -227,7 +227,7 @@ const Operation: FC<OperationProps> = ({
)}
</div>
)}
{shouldShowAdminFeedbackBar && (
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
@ -304,28 +304,30 @@ const Operation: FC<OperationProps> = ({
<Log logItem={item} />
</div>
)}
{!isOpeningStatement && !humanInputFormDataList?.length && (
{!isOpeningStatement && (
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
{(config?.text_to_speech?.enabled) && (
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
<NewAudioButton
id={id}
value={content}
voice={config?.text_to_speech?.voice}
/>
)}
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4" />
</ActionButton>
{!humanInputFormDataList?.length && (
<ActionButton onClick={() => {
copy(content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4" />
</ActionButton>
)}
{!noChatInput && (
<ActionButton onClick={() => onRegenerate?.(item)}>
<RiResetLeftLine className="h-4 w-4" />
</ActionButton>
)}
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
<AnnotationCtrlButton
appId={config?.appId || ''}
messageId={id}

View File

@ -9,6 +9,7 @@ import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { Annotation } from '@/models/log'
import type {
IOnDataMoreInfo,
IOtherOptions,
} from '@/service/base'
import { uniqBy } from 'es-toolkit/compat'
@ -209,10 +210,14 @@ export const useChat = (
return getOrCreatePlayer
}, [params.token, params.appId, pathname])
const handleResume = useCallback((
const handleResume = useCallback(async (
messageId: string,
workflowRunId: string,
isPublicAPI?: boolean,
{
onGetSuggestedQuestions,
onConversationComplete,
isPublicAPI,
}: SendCallback,
) => {
const getOrCreatePlayer = createAudioPlayerManager()
// Re-subscribe to workflow events for the specific message
@ -223,7 +228,7 @@ export const useChat = (
getAbortController: (abortController) => {
workflowEventsAbortControllerRef.current = abortController
},
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, taskId }: any) => {
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: IOnDataMoreInfo) => {
updateChatTreeNode(messageId, (responseItem) => {
const isAgentMode = responseItem.agent_thoughts && responseItem.agent_thoughts.length > 0
if (!isAgentMode) {
@ -234,6 +239,8 @@ export const useChat = (
if (lastThought)
lastThought.thought = lastThought.thought + message
}
if (messageId)
responseItem.id = messageId
})
if (isFirstMessage && newConversationId)
@ -242,8 +249,28 @@ export const useChat = (
if (taskId)
taskIdRef.current = taskId
},
async onCompleted() {
async onCompleted(hasError?: boolean) {
handleResponding(false)
if (hasError)
return
if (onConversationComplete)
onConversationComplete(conversationId.current)
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
try {
const { data }: any = await onGetSuggestedQuestions(
messageId,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestQuestions(data)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (e) {
setSuggestQuestions([])
}
}
},
onFile(file) {
updateChatTreeNode(messageId, (responseItem) => {
@ -300,20 +327,12 @@ export const useChat = (
onError() {
handleResponding(false)
},
onWorkflowStarted: ({ workflow_run_id, task_id, data: { is_resumption } }) => {
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
handleResponding(true)
hasStopResponded.current = false
updateChatTreeNode(messageId, (responseItem) => {
if (is_resumption) {
if (responseItem.workflowProcess) {
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
}
else {
responseItem.workflowProcess = {
status: WorkflowRunningStatus.Running,
tracing: [],
}
}
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
}
else {
taskIdRef.current = task_id
@ -366,20 +385,12 @@ export const useChat = (
if (!responseItem.workflowProcess.tracing)
responseItem.workflowProcess.tracing = []
const { is_resumption } = nodeStartedData
if (is_resumption) {
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: NodeRunningStatus.Running,
})
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
// if the node is already started, update the node
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
@ -502,12 +513,15 @@ export const useChat = (
},
}
if (workflowEventsAbortControllerRef.current)
workflowEventsAbortControllerRef.current.abort()
sseGet(
url,
{},
otherOptions,
)
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager])
}, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer])
const updateCurrentQAOnTree = useCallback(({
parentId,
@ -810,9 +824,20 @@ export const useChat = (
parentId: data.parent_message_id,
})
},
onWorkflowStarted: ({ workflow_run_id, task_id, data: { is_resumption } }) => {
if (is_resumption) {
responseItem.workflowProcess!.status = WorkflowRunningStatus.Running
onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => {
// If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow.
if (conversation_id) {
conversationId.current = conversation_id
}
if (message_id && !hasSetResponseId) {
questionItem.id = `question-${message_id}`
responseItem.id = message_id
responseItem.parentMessageId = questionItem.id
hasSetResponseId = true
}
if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
responseItem.workflowProcess.status = WorkflowRunningStatus.Running
}
else {
taskIdRef.current = task_id
@ -868,14 +893,16 @@ export const useChat = (
})
},
onNodeStarted: ({ data: nodeStartedData }) => {
const { is_resumption } = nodeStartedData
if (is_resumption) {
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess!.tracing![currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
if (!responseItem.workflowProcess)
return
if (!responseItem.workflowProcess.tracing)
responseItem.workflowProcess.tracing = []
const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
if (currentIndex > -1) {
responseItem.workflowProcess.tracing[currentIndex] = {
...nodeStartedData,
status: NodeRunningStatus.Running,
}
}
else {
@ -885,7 +912,7 @@ export const useChat = (
if (data.loop_id)
return
responseItem.workflowProcess!.tracing!.push({
responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
@ -1021,6 +1048,10 @@ export const useChat = (
},
}
// Abort the previous workflow events SSE request
if (workflowEventsAbortControllerRef.current)
workflowEventsAbortControllerRef.current.abort()
ssePost(
url,
{
@ -1096,6 +1127,36 @@ export const useChat = (
})
}, [chatList, updateChatTreeNode])
const handleSwitchSibling = useCallback((
siblingMessageId: string,
callbacks: SendCallback,
) => {
setTargetMessageId(siblingMessageId)
// Helper to find message in tree
const findMessageInTree = (nodes: ChatItemInTree[], targetId: string): ChatItemInTree | undefined => {
for (const node of nodes) {
if (node.id === targetId)
return node
if (node.children) {
const found = findMessageInTree(node.children, targetId)
if (found)
return found
}
}
return undefined
}
const targetMessage = findMessageInTree(chatTreeRef.current, siblingMessageId)
if (targetMessage?.workflow_run_id && targetMessage.humanInputFormDataList && targetMessage.humanInputFormDataList.length > 0) {
handleResume(
targetMessage.id,
targetMessage.workflow_run_id,
callbacks,
)
}
}, [setTargetMessageId, handleResume])
useEffect(() => {
if (clearChatList)
handleRestart(() => clearChatListCallback?.(false))
@ -1108,6 +1169,7 @@ export const useChat = (
setIsResponding,
handleSend,
handleResume,
handleSwitchSibling,
suggestedQuestions,
handleRestart,
handleStop,

View File

@ -68,9 +68,9 @@ const ChatWrapper = () => {
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
setTargetMessageId,
handleSend,
handleStop,
handleSwitchSibling,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
@ -154,6 +154,12 @@ const ChatWrapper = () => {
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
const doSwitchSibling = useCallback((siblingMessageId: string) => {
handleSwitchSibling(siblingMessageId, {
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
})
}, [handleSwitchSibling, isInstalledApp, appId])
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
@ -268,7 +274,7 @@ const ChatWrapper = () => {
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
questionIcon={
initUserVariables?.avatar_url