From b8d400279ec7895db026335a31bb38d779456a3c Mon Sep 17 00:00:00 2001 From: Chenyme <118253778+chenyme@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:07:35 +0800 Subject: [PATCH] fix: enhance resume state management and cleanup in chat data hook --- frontend/features/chat/hooks/use-chat-data.ts | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/frontend/features/chat/hooks/use-chat-data.ts b/frontend/features/chat/hooks/use-chat-data.ts index a0b165d5..55b535d5 100644 --- a/frontend/features/chat/hooks/use-chat-data.ts +++ b/frontend/features/chat/hooks/use-chat-data.ts @@ -99,6 +99,15 @@ export function useChatData( const pendingAssistantContentRef = React.useRef(""); const resumeTextReplayByRunRef = React.useRef>({}); const activeResumeStreamRef = React.useRef(null); + // 恢复游标只在对应的可见内容仍被保留时有效,两者必须同步清理。 + const clearResumeCheckpoint = React.useCallback((runID: string) => { + const normalizedRunID = runID.trim(); + if (!normalizedRunID) { + return; + } + delete resumeSeqByRunRef.current[normalizedRunID]; + delete resumeTextReplayByRunRef.current[normalizedRunID]; + }, []); React.useEffect(() => { let cancelled = false; @@ -287,6 +296,7 @@ export function useChatData( } active.controller.abort(); + clearResumeCheckpoint(active.runID); setResumingRunID(""); const token = active.accessToken ?? (await resolveAccessToken()); @@ -297,7 +307,7 @@ export function useChatData( const result = await cancelMessageGeneration(token, active.runID).catch(() => null); reload(); return Boolean(result?.canceled); - }, [reload]); + }, [clearResumeCheckpoint, reload]); const pendingAssistant = React.useMemo(() => { for (let index = state.messages.length - 1; index >= 0; index -= 1) { @@ -334,6 +344,10 @@ export function useChatData( const clearResumeTextReplay = () => { delete resumeTextReplayByRun[pendingRunID]; }; + const isResumeInactive = () => closed || controller.signal.aborted; + const updateResumeState = (update: (current: ChatDataState) => ChatDataState) => { + setState((current) => isResumeInactive() ? current : update(current)); + }; resumeTextReplayByRun[pendingRunID] = { baseContent, replayedContent: afterSeq > 0 ? baseContent : "", @@ -359,6 +373,9 @@ export function useChatData( signal: controller.signal, afterSeq, onEventSeq: (seq) => { + if (isResumeInactive()) { + return; + } resumeSeqByRunRef.current[pendingRunID] = Math.max(resumeSeqByRunRef.current[pendingRunID] ?? 0, seq); }, onMediaStatus: (event) => { @@ -372,7 +389,7 @@ export function useChatData( : status === "saving_artifact" ? tSubmit(contentType === "video" ? "mediaStatus.videoSavingArtifact" : "mediaStatus.savingArtifact") : event.message.trim() || status; - setState((prev) => ({ + updateResumeState((prev) => ({ ...prev, messages: prev.messages.map((message) => message.runID === pendingRunID && message.role === "assistant" && message.status === "pending" @@ -382,12 +399,15 @@ export function useChatData( })); }, onMediaImageDelta: (event) => { + if (isResumeInactive()) { + return; + } clearResumeTextReplay(); const previewMarkdown = buildMediaImagePreviewMarkdown(event, tSubmit("imagePreviewAlt")); if (!previewMarkdown) { return; } - setState((prev) => ({ + updateResumeState((prev) => ({ ...prev, messages: prev.messages.map((message) => message.runID === pendingRunID && message.role === "assistant" && message.status === "pending" @@ -397,6 +417,9 @@ export function useChatData( })); }, onDelta: (delta) => { + if (isResumeInactive()) { + return; + } let replayState = resumeTextReplayByRun[pendingRunID]; if (!replayState) { replayState = { @@ -407,7 +430,7 @@ export function useChatData( resumeTextReplayByRun[pendingRunID] = replayState; } const nextContent = appendResumedTextDelta(replayState, delta); - setState((prev) => ({ + updateResumeState((prev) => ({ ...prev, messages: prev.messages.map((message) => message.runID === pendingRunID && message.role === "assistant" && message.status === "pending" @@ -417,7 +440,7 @@ export function useChatData( })); }, onProcessUpdate: (event) => { - setState((prev) => ({ + updateResumeState((prev) => ({ ...prev, messages: prev.messages.map((message) => message.runID === pendingRunID && message.role === "assistant" && message.status === "pending" @@ -427,10 +450,13 @@ export function useChatData( })); }, onUpstreamThinkDelta: (event) => { + if (isResumeInactive()) { + return; + } upsertLiveUpstreamThinkTrace(pendingRunID, event); }, onUsage: (event) => { - setState((prev) => ({ + updateResumeState((prev) => ({ ...prev, messages: prev.messages.map((message) => message.runID === pendingRunID && message.role === "assistant" && message.status === "pending" @@ -451,17 +477,16 @@ export function useChatData( }, }); if (!controller.signal.aborted && completed === null) { - clearResumeTextReplay(); + clearResumeCheckpoint(pendingRunID); reload(); } if (!controller.signal.aborted && completed) { - delete resumeSeqByRunRef.current[pendingRunID]; - clearResumeTextReplay(); + clearResumeCheckpoint(pendingRunID); reload(); } } catch (error) { if (!controller.signal.aborted && error instanceof Error && error.name !== "AbortError") { - clearResumeTextReplay(); + clearResumeCheckpoint(pendingRunID); setResumingRunID(""); reload(); } @@ -479,12 +504,20 @@ export function useChatData( return () => { closed = true; controller.abort(); - clearResumeTextReplay(); + clearResumeCheckpoint(pendingRunID); if (activeResumeStreamRef.current?.controller === controller) { activeResumeStreamRef.current = null; } }; - }, [activeGenerationRunsRef, conversationID, failedGenerationRunsRef, pendingRunID, reload, tSubmit]); + }, [ + activeGenerationRunsRef, + clearResumeCheckpoint, + conversationID, + failedGenerationRunsRef, + pendingRunID, + reload, + tSubmit, + ]); React.useEffect(() => { if (