fix: abort creation chat sse on result page
This commit is contained in:
@@ -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,不复用已中止连接。
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user