diff --git a/docs/technical/CREATION_AGENT_RESULT_PAGE_SSE_ABORT_FIX_2026-04-24.md b/docs/technical/CREATION_AGENT_RESULT_PAGE_SSE_ABORT_FIX_2026-04-24.md new file mode 100644 index 00000000..b7e86d3a --- /dev/null +++ b/docs/technical/CREATION_AGENT_RESULT_PAGE_SSE_ABORT_FIX_2026-04-24.md @@ -0,0 +1,31 @@ +# 创作 Agent 结果页 SSE 断开修复 + +日期:`2026-04-24` + +## 1. 问题 + +RPG 世界共创草稿进入生成结果页后,前端仍可能保留上一条聊天消息的 `/messages/stream` 连接。该连接继续接收 `reply_delta` 时,会让结果页阶段仍表现为“聊天还在连着 SSE”。 + +## 2. 原因 + +当前聊天流式请求由 `streamRpgCreationMessage` 发起,底层使用 `fetch` 读取 SSE `ReadableStream`。旧实现只在请求自然结束后清理 `isStreamingAgentReply`,没有在以下 UI 生命周期主动中止网络流: + +1. 从 `agent-workspace` 跳到 `custom-world-result`。 +2. 清空或切换 `activeAgentSessionId`。 +3. 当前入口组件卸载。 + +因此,结果页虽然不再展示聊天工作区,但浏览器侧仍可能持有未完成的流读取器。 + +## 3. 修复设计 + +1. `TextStreamOptions` 增加 `signal?: AbortSignal`,让所有创作 Agent 流式读取都具备统一取消入口。 +2. RPG 共创 `/messages/stream` 的 `fetch` 透传该 `signal`。 +3. `useRpgCreationSessionController` 持有当前聊天流的 `AbortController`。 +4. 当 `selectionStage` 离开 `agent-workspace` / `custom-world-generating`,立即 `abort()` 当前聊天 SSE,并清空临时流式文本。 +5. session 切换、未登录清理、组件卸载时同样中止旧 SSE,避免慢响应回写旧工作区状态。 + +## 4. 验收 + +1. 聊天中触发草稿生成并进入结果页后,浏览器 Network 中旧 `/messages/stream` 请求应变为 canceled/aborted 或结束。 +2. 结果页不再继续追加聊天 `reply_delta`。 +3. 回到 Agent 工作区后,新的聊天消息会创建新的 SSE,不复用已中止连接。 diff --git a/src/components/rpg-entry/useRpgCreationSessionController.ts b/src/components/rpg-entry/useRpgCreationSessionController.ts index a8bdecc9..4256fbef 100644 --- a/src/components/rpg-entry/useRpgCreationSessionController.ts +++ b/src/components/rpg-entry/useRpgCreationSessionController.ts @@ -76,6 +76,9 @@ export function useRpgCreationSessionController( const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false); const isAgentDraftResultAutoOpenSuppressedRef = useRef(false); const currentAgentSessionIdRef = useRef(null); + const activeAgentReplyAbortControllerRef = useRef( + null, + ); const latestAgentSessionSyncRequestIdRef = useRef(0); const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false); @@ -128,6 +131,11 @@ export function useRpgCreationSessionController( latestAgentSessionSyncRequestIdRef.current += 1; }, []); + const abortActiveAgentReplyStream = useCallback(() => { + activeAgentReplyAbortControllerRef.current?.abort(); + activeAgentReplyAbortControllerRef.current = null; + }, []); + const mergePendingAgentUserMessageIntoSession = useCallback( ( session: CustomWorldAgentSessionSnapshot | null, @@ -223,8 +231,26 @@ export function useRpgCreationSessionController( setSelectionStage('agent-workspace'); }, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]); + useEffect(() => { + if ( + selectionStage !== 'agent-workspace' && + selectionStage !== 'custom-world-generating' + ) { + abortActiveAgentReplyStream(); + setStreamingAgentReplyText(''); + setIsStreamingAgentReply(false); + } + }, [abortActiveAgentReplyStream, selectionStage]); + + useEffect(() => { + return () => { + abortActiveAgentReplyStream(); + }; + }, [abortActiveAgentReplyStream]); + useEffect(() => { if (!activeAgentSessionId) { + abortActiveAgentReplyStream(); invalidateAgentSessionSyncRequests(); setAgentSession(null); setAgentOperation(null); @@ -238,6 +264,7 @@ export function useRpgCreationSessionController( } if (!userId) { + abortActiveAgentReplyStream(); invalidateAgentSessionSyncRequests(); setAgentSession(null); setAgentOperation(null); @@ -255,6 +282,7 @@ export function useRpgCreationSessionController( activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId; if (currentAgentSessionIdRef.current !== activeAgentSessionId) { + abortActiveAgentReplyStream(); setAgentSession(null); setAgentOperation(null); setStreamingAgentReplyText(''); @@ -306,6 +334,7 @@ export function useRpgCreationSessionController( }; }, [ activeAgentSessionId, + abortActiveAgentReplyStream, enterCreateTab, invalidateAgentSessionSyncRequests, persistAgentUiState, @@ -540,6 +569,8 @@ export function useRpgCreationSessionController( setStreamingAgentReplyText(''); setIsStreamingAgentReply(true); setPendingAgentUserMessage(pendingMessagePayload); + const replyAbortController = new AbortController(); + activeAgentReplyAbortControllerRef.current = replyAbortController; setAgentSession((current) => mergePendingAgentUserMessageIntoSession(current, pendingMessagePayload), ); @@ -550,10 +581,17 @@ export function useRpgCreationSessionController( payload, { onUpdate: (text) => { + if (replyAbortController.signal.aborted) { + return; + } setStreamingAgentReplyText(text); }, + signal: replyAbortController.signal, }, ); + if (replyAbortController.signal.aborted) { + return; + } const mergedNextSession = mergePendingAgentUserMessageIntoSession( nextSession, pendingMessagePayload, @@ -568,6 +606,9 @@ export function useRpgCreationSessionController( hasServerEchoedPendingMessage ? null : pendingMessagePayload, ); } catch (error) { + if (replyAbortController.signal.aborted) { + return; + } const errorMessage = resolveRpgCreationErrorMessage( error, '发送共创消息失败。', @@ -597,7 +638,12 @@ export function useRpgCreationSessionController( setStreamingAgentReplyText(''); persistAgentUiState(activeAgentSessionId, null); } finally { - setIsStreamingAgentReply(false); + if (activeAgentReplyAbortControllerRef.current === replyAbortController) { + activeAgentReplyAbortControllerRef.current = null; + } + if (!replyAbortController.signal.aborted) { + setIsStreamingAgentReply(false); + } } }, [ diff --git a/src/services/aiTypes.ts b/src/services/aiTypes.ts index 5ddc423f..ad0cab8c 100644 --- a/src/services/aiTypes.ts +++ b/src/services/aiTypes.ts @@ -52,6 +52,7 @@ export interface StoryRequestOptions { export interface TextStreamOptions { onUpdate?: (text: string) => void; + signal?: AbortSignal; } export interface CustomWorldSceneImageRequest { diff --git a/src/services/rpg-creation/rpgCreationAgentClient.ts b/src/services/rpg-creation/rpgCreationAgentClient.ts index 377ad81b..f4540731 100644 --- a/src/services/rpg-creation/rpgCreationAgentClient.ts +++ b/src/services/rpg-creation/rpgCreationAgentClient.ts @@ -66,6 +66,7 @@ export async function streamRpgCreationMessage( `/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`, payload, '发送共创消息失败', + options.signal, ); return readCreationAgentSessionFromSse(response, { diff --git a/src/services/rpg-creation/rpgCreationRequestHelpers.ts b/src/services/rpg-creation/rpgCreationRequestHelpers.ts index ba4a1678..7e3624f0 100644 --- a/src/services/rpg-creation/rpgCreationRequestHelpers.ts +++ b/src/services/rpg-creation/rpgCreationRequestHelpers.ts @@ -21,11 +21,13 @@ export async function openRpgCreationSsePost( url: string, payload: unknown, fallbackMessage: string, + signal?: AbortSignal, ) { const response = await fetchWithApiAuth(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + signal, }); if (!response.ok) {