fix: abort creation chat sse on result page

This commit is contained in:
2026-04-24 20:33:01 +08:00
parent 3aabb59945
commit 88bd4f36e9
5 changed files with 82 additions and 1 deletions

View File

@@ -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不复用已中止连接。

View File

@@ -76,6 +76,9 @@ export function useRpgCreationSessionController(
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false); const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false); const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
const currentAgentSessionIdRef = useRef<string | null>(null); const currentAgentSessionIdRef = useRef<string | null>(null);
const activeAgentReplyAbortControllerRef = useRef<AbortController | null>(
null,
);
const latestAgentSessionSyncRequestIdRef = useRef(0); const latestAgentSessionSyncRequestIdRef = useRef(0);
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false); const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
@@ -128,6 +131,11 @@ export function useRpgCreationSessionController(
latestAgentSessionSyncRequestIdRef.current += 1; latestAgentSessionSyncRequestIdRef.current += 1;
}, []); }, []);
const abortActiveAgentReplyStream = useCallback(() => {
activeAgentReplyAbortControllerRef.current?.abort();
activeAgentReplyAbortControllerRef.current = null;
}, []);
const mergePendingAgentUserMessageIntoSession = useCallback( const mergePendingAgentUserMessageIntoSession = useCallback(
( (
session: CustomWorldAgentSessionSnapshot | null, session: CustomWorldAgentSessionSnapshot | null,
@@ -223,8 +231,26 @@ export function useRpgCreationSessionController(
setSelectionStage('agent-workspace'); setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]); }, [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(() => { useEffect(() => {
if (!activeAgentSessionId) { if (!activeAgentSessionId) {
abortActiveAgentReplyStream();
invalidateAgentSessionSyncRequests(); invalidateAgentSessionSyncRequests();
setAgentSession(null); setAgentSession(null);
setAgentOperation(null); setAgentOperation(null);
@@ -238,6 +264,7 @@ export function useRpgCreationSessionController(
} }
if (!userId) { if (!userId) {
abortActiveAgentReplyStream();
invalidateAgentSessionSyncRequests(); invalidateAgentSessionSyncRequests();
setAgentSession(null); setAgentSession(null);
setAgentOperation(null); setAgentOperation(null);
@@ -255,6 +282,7 @@ export function useRpgCreationSessionController(
activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId; activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId;
if (currentAgentSessionIdRef.current !== activeAgentSessionId) { if (currentAgentSessionIdRef.current !== activeAgentSessionId) {
abortActiveAgentReplyStream();
setAgentSession(null); setAgentSession(null);
setAgentOperation(null); setAgentOperation(null);
setStreamingAgentReplyText(''); setStreamingAgentReplyText('');
@@ -306,6 +334,7 @@ export function useRpgCreationSessionController(
}; };
}, [ }, [
activeAgentSessionId, activeAgentSessionId,
abortActiveAgentReplyStream,
enterCreateTab, enterCreateTab,
invalidateAgentSessionSyncRequests, invalidateAgentSessionSyncRequests,
persistAgentUiState, persistAgentUiState,
@@ -540,6 +569,8 @@ export function useRpgCreationSessionController(
setStreamingAgentReplyText(''); setStreamingAgentReplyText('');
setIsStreamingAgentReply(true); setIsStreamingAgentReply(true);
setPendingAgentUserMessage(pendingMessagePayload); setPendingAgentUserMessage(pendingMessagePayload);
const replyAbortController = new AbortController();
activeAgentReplyAbortControllerRef.current = replyAbortController;
setAgentSession((current) => setAgentSession((current) =>
mergePendingAgentUserMessageIntoSession(current, pendingMessagePayload), mergePendingAgentUserMessageIntoSession(current, pendingMessagePayload),
); );
@@ -550,10 +581,17 @@ export function useRpgCreationSessionController(
payload, payload,
{ {
onUpdate: (text) => { onUpdate: (text) => {
if (replyAbortController.signal.aborted) {
return;
}
setStreamingAgentReplyText(text); setStreamingAgentReplyText(text);
}, },
signal: replyAbortController.signal,
}, },
); );
if (replyAbortController.signal.aborted) {
return;
}
const mergedNextSession = mergePendingAgentUserMessageIntoSession( const mergedNextSession = mergePendingAgentUserMessageIntoSession(
nextSession, nextSession,
pendingMessagePayload, pendingMessagePayload,
@@ -568,6 +606,9 @@ export function useRpgCreationSessionController(
hasServerEchoedPendingMessage ? null : pendingMessagePayload, hasServerEchoedPendingMessage ? null : pendingMessagePayload,
); );
} catch (error) { } catch (error) {
if (replyAbortController.signal.aborted) {
return;
}
const errorMessage = resolveRpgCreationErrorMessage( const errorMessage = resolveRpgCreationErrorMessage(
error, error,
'发送共创消息失败。', '发送共创消息失败。',
@@ -597,7 +638,12 @@ export function useRpgCreationSessionController(
setStreamingAgentReplyText(''); setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null); persistAgentUiState(activeAgentSessionId, null);
} finally { } finally {
setIsStreamingAgentReply(false); if (activeAgentReplyAbortControllerRef.current === replyAbortController) {
activeAgentReplyAbortControllerRef.current = null;
}
if (!replyAbortController.signal.aborted) {
setIsStreamingAgentReply(false);
}
} }
}, },
[ [

View File

@@ -52,6 +52,7 @@ export interface StoryRequestOptions {
export interface TextStreamOptions { export interface TextStreamOptions {
onUpdate?: (text: string) => void; onUpdate?: (text: string) => void;
signal?: AbortSignal;
} }
export interface CustomWorldSceneImageRequest { export interface CustomWorldSceneImageRequest {

View File

@@ -66,6 +66,7 @@ export async function streamRpgCreationMessage(
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`, `/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
payload, payload,
'发送共创消息失败', '发送共创消息失败',
options.signal,
); );
return readCreationAgentSessionFromSse<RpgAgentSessionSnapshot>(response, { return readCreationAgentSessionFromSse<RpgAgentSessionSnapshot>(response, {

View File

@@ -21,11 +21,13 @@ export async function openRpgCreationSsePost(
url: string, url: string,
payload: unknown, payload: unknown,
fallbackMessage: string, fallbackMessage: string,
signal?: AbortSignal,
) { ) {
const response = await fetchWithApiAuth(url, { const response = await fetchWithApiAuth(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
signal,
}); });
if (!response.ok) { if (!response.ok) {