@agentscope-ai/chat 1.1.66 → 1.1.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Actions.tsx +8 -4
  2. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Builder.tsx +7 -1
  3. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Card.tsx +5 -1
  4. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Actions.tsx +1 -1
  5. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Builder.tsx +34 -3
  6. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Message.tsx +2 -1
  7. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/types.tsx +1 -0
  8. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.tsx +129 -33
  9. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.tsx +74 -39
  10. package/components/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.ts +10 -0
  11. package/components/DefaultCards/Files/index.tsx +5 -1
  12. package/components/Markdown/Markdown.tsx +2 -1
  13. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Actions.js +8 -1
  14. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Builder.js +4 -0
  15. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Card.js +8 -2
  16. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Actions.js +1 -1
  17. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Builder.js +58 -12
  18. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Message.js +6 -1
  19. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/types.d.ts +1 -0
  20. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.d.ts +1 -1
  21. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.js +167 -68
  22. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.d.ts +7 -3
  23. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.js +151 -117
  24. package/lib/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.d.ts +15 -0
  25. package/lib/DefaultCards/Files/index.js +5 -1
  26. package/lib/Markdown/Markdown.js +1 -0
  27. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import React from "react";
1
2
  import { SparkCopyLine } from "@agentscope-ai/icons";
2
3
  import { AgentScopeRuntimeContentType, IAgentScopeRuntimeRequest } from "../types";
3
4
  import { Bubble } from "@agentscope-ai/chat";
@@ -27,10 +28,13 @@ export default function RequestActions(props: {
27
28
  },
28
29
  ] : [];
29
30
 
30
- const actions = (requestActionsOptions.list || defaultActions).map(i => ({
31
- ...i,
32
- onClick: () => { i.onClick?.({ data: props.data }); },
33
- }));
31
+ const actions = (requestActionsOptions.list || defaultActions).map(i => {
32
+ const res = { ...i } as any;
33
+ if (i.render) {
34
+ res.children = React.createElement(i.render, { data: props.data });
35
+ }
36
+ return { ...res, onClick: () => { i.onClick?.({ data: props.data }); } };
37
+ });
34
38
 
35
39
  if (!actions.length) return null;
36
40
 
@@ -101,6 +101,9 @@ class AgentScopeRuntimeRequestBuilder {
101
101
  }
102
102
 
103
103
  this.data = {
104
+ // Client-side send timestamp (seconds), aligns with response.created_at.
105
+ // Backend has not yet returned, so this represents the local send moment.
106
+ created_at: Math.floor(Date.now() / 1000),
104
107
  input: [
105
108
  {
106
109
  role: 'user',
@@ -113,7 +116,10 @@ class AgentScopeRuntimeRequestBuilder {
113
116
  }
114
117
 
115
118
  handleApproval(input) {
116
- this.data = { input };
119
+ this.data = {
120
+ created_at: Math.floor(Date.now() / 1000),
121
+ input,
122
+ };
117
123
  return this.data;
118
124
  }
119
125
  }
@@ -3,10 +3,13 @@ import { AgentScopeRuntimeContentType, IAgentScopeRuntimeRequest } from '../type
3
3
  import { useMemo } from 'react';
4
4
  import { Bubble } from '@agentscope-ai/chat';
5
5
  import Actions from './Actions';
6
+ import { useChatAnywhereOptions } from '../../Context/ChatAnywhereOptionsContext';
6
7
 
7
8
  export default function AgentScopeRuntimeRequestCard(props: {
8
9
  data: IAgentScopeRuntimeRequest;
9
10
  }) {
11
+ const onFileCardClick = useChatAnywhereOptions(v => v.api?.onFileCardClick);
12
+
10
13
  const cards = useMemo(() => {
11
14
 
12
15
  return props.data.input[0].content.reduce<any>((p, c) => {
@@ -63,6 +66,7 @@ export default function AgentScopeRuntimeRequestCard(props: {
63
66
  p.push({
64
67
  code: 'Files',
65
68
  data: [{ url: c.file_url, name: c.file_name || c.fileName, size: c.file_size }],
69
+ onClick: onFileCardClick,
66
70
  });
67
71
  } else {
68
72
  fileCard.data.push({ url: c.file_url, name: c.file_name || c.fileName, size: c.file_size });
@@ -70,7 +74,7 @@ export default function AgentScopeRuntimeRequestCard(props: {
70
74
  }
71
75
  return p;
72
76
  }, []);
73
- }, [props.data.input]);
77
+ }, [props.data.input, onFileCardClick]);
74
78
 
75
79
  if (!cards?.length) return null;
76
80
 
@@ -40,7 +40,7 @@ export default function Tools(props: {
40
40
 
41
41
  const actions = compact([
42
42
  ...actionsOptionsList.map(i => {
43
- const res = i;
43
+ const res = { ...i } as any;
44
44
 
45
45
  if (i.render) {
46
46
  res.children = React.createElement(i.render, { data: props });
@@ -107,10 +107,41 @@ class AgentScopeRuntimeResponseBuilder {
107
107
 
108
108
  handleResponse(data: IAgentScopeRuntimeResponse) {
109
109
  this.data = produce(this.data, (draft) => {
110
- if (!data.output) {
111
- data.output = [];
112
- }
110
+ const existingOutput = draft.output || [];
111
+ const incomingOutput = data.output;
112
+
113
113
  Object.assign(draft, data);
114
+
115
+ // If incoming response has no output or empty output, preserve the
116
+ // accumulated output from streaming to avoid losing intermediate
117
+ // tool-call messages that were already collected.
118
+ if (!incomingOutput || incomingOutput.length === 0) {
119
+ draft.output = existingOutput;
120
+ } else if (existingOutput.length > 0) {
121
+ // Merge by id: prefer the version with non-empty content to avoid
122
+ // a partial-update response wiping out previously accumulated
123
+ // tool-call data (Bug 2 of issue #4644).
124
+ const existingMap = new Map(existingOutput.map(m => [m.id, m]));
125
+ const incomingIds = new Set(incomingOutput.map(m => m.id));
126
+ const merged = incomingOutput.map(incoming => {
127
+ const existing = existingMap.get(incoming.id);
128
+ if (!existing) return incoming;
129
+ // Prefer the message with content already populated.
130
+ const incomingHasContent = incoming.content?.length > 0;
131
+ const existingHasContent = existing.content?.length > 0;
132
+ if (existingHasContent && !incomingHasContent) {
133
+ return { ...incoming, content: existing.content };
134
+ }
135
+ return incoming;
136
+ });
137
+ // Append existing-only messages (not present in incoming).
138
+ for (const existing of existingOutput) {
139
+ if (!incomingIds.has(existing.id)) {
140
+ merged.push(existing);
141
+ }
142
+ }
143
+ draft.output = merged;
144
+ }
114
145
  });
115
146
  }
116
147
 
@@ -9,6 +9,7 @@ import { useChatAnywhereOptions } from "../../Context/ChatAnywhereOptionsContext
9
9
 
10
10
  const Message = React.memo(function ({ data }: { data: IAgentScopeRuntimeMessage }) {
11
11
  const replaceMediaURL = useChatAnywhereOptions(v => v.api?.replaceMediaURL);
12
+ const onFileCardClick = useChatAnywhereOptions(v => v.api?.onFileCardClick);
12
13
  const formatMediaURL = React.useCallback((url?: string) => {
13
14
  if (!url) return url;
14
15
  return replaceMediaURL?.(url) || url;
@@ -36,7 +37,7 @@ const Message = React.memo(function ({ data }: { data: IAgentScopeRuntimeMessage
36
37
  url: formatMediaURL(item.file_url),
37
38
  name: item.file_name || item.fileName || item.file_id,
38
39
  size: item.file_size,
39
- }]}></Files>
40
+ }]} onClick={onFileCardClick}></Files>
40
41
  case AgentScopeRuntimeContentType.AUDIO:
41
42
  return <Audios key={index} data={[{ src: formatMediaURL(item.audio_url || item.data) }]}></Audios>
42
43
  default:
@@ -123,6 +123,7 @@ export interface IAgentScopeRuntimeError {
123
123
  }
124
124
 
125
125
  export interface IAgentScopeRuntimeRequest {
126
+ created_at?: number;
126
127
  input: {
127
128
  role: AgentScopeRuntimeMessageRole | string;
128
129
  type: AgentScopeRuntimeMessageType;
@@ -9,30 +9,49 @@ import { InputProps } from "../Input";
9
9
  import useChatMessageHandler from "./useChatMessageHandler";
10
10
  import useChatRequest from "./useChatRequest";
11
11
  import useChatSessionHandler from "./useChatSessionHandler";
12
+ import { useChatAnywhereOptions } from "../../Context/ChatAnywhereOptionsContext";
12
13
  import ReactDOM from "react-dom";
13
14
  // import mockdata from '../../mock/mock.json'
14
15
 
15
16
  /**
16
- * 聊天控制器 Hook - 协调所有聊天相关操作
17
+ * Chat controller hook — coordinates all chat-related operations.
17
18
  */
18
19
  export default function useChatController() {
19
20
  const setLoading = useContextSelector(ChatAnywhereInputContext, v => v.setLoading);
20
21
  const currentSessionId = useContextSelector(ChatAnywhereSessionsContext, v => v.currentSessionId);
22
+ const apiOptions = useChatAnywhereOptions(v => v.api);
23
+ const apiOptionsRef = useRef(apiOptions);
24
+ useEffect(() => {
25
+ apiOptionsRef.current = apiOptions;
26
+ }, [apiOptions]);
21
27
 
22
28
  const currentQARef = useRef<{
23
29
  request?: IAgentScopeRuntimeWebUIMessage;
24
30
  response?: IAgentScopeRuntimeWebUIMessage;
25
31
  abortController?: AbortController;
26
- }>({});
27
-
28
- // 消息处理
32
+ /**
33
+ * Unique identifier for the currently active SSE request. Incremented on
34
+ * every new submit / cancel / session-switch. processSSEResponse checks its
35
+ * own requestId against this value before every write — a mismatch means
36
+ * the stream is stale and should stop writing (prevents cross-session
37
+ * leakage and ghost writes from cancelled runs, related to issue #4644).
38
+ */
39
+ activeRequestId: number;
40
+ /**
41
+ * Snapshot of the session id associated with the active request.
42
+ * Used to detect stale requests after a session switch.
43
+ */
44
+ activeSessionId?: string;
45
+ }>({ activeRequestId: 0 });
46
+
47
+ // Message handler
29
48
  const messageHandler = useChatMessageHandler({ currentQARef });
30
49
 
31
- // 会话处理
50
+ // Session handler
32
51
  const sessionHandler = useChatSessionHandler();
33
52
 
34
53
  /**
35
- * 完成响应
54
+ * Finalize the current response and reset UI loading state.
36
55
  */
37
56
  const finishResponse = useCallback((status: 'finished' | 'interrupted' = 'finished') => {
38
57
  if (!currentQARef.current.response) return;
@@ -46,7 +65,7 @@ export default function useChatController() {
46
65
  sessionHandler.syncSessionMessages(messageHandler.getMessages());
47
66
  }, [setLoading, messageHandler, sessionHandler]);
48
67
 
49
- // API 请求处理
68
+ // API request handling
50
69
  const { request, reconnect } = useChatRequest({
51
70
  currentQARef,
52
71
  updateMessage: messageHandler.updateMessage,
@@ -55,90 +74,148 @@ export default function useChatController() {
55
74
  });
56
75
 
57
76
  /**
58
- * 处理用户提交
77
+ * Handle user message submission.
59
78
  */
60
79
  const handleSubmit = useCallback<InputProps['onSubmit']>(async (data) => {
61
- // 1. 确保会话存在
80
+ // 0. Abort any previous in-flight SSE. We do NOT call the cancel API here
81
+ // — the user is sending a new message, not explicitly cancelling.
82
+ // Cancel is only invoked from handleCancel.
83
+ currentQARef.current.abortController?.abort();
84
+
85
+ // 1. Ensure session exists FIRST. Bumping activeRequestId before this can
86
+ // race with the [currentSessionId] effect below: ensureSession may set
87
+ // a new sessionId, that effect then bumps activeRequestId again, and
88
+ // our own myRequestId becomes stale → the guard after sleep(100) bails
89
+ // out and the request is silently dropped. Establishing the session
90
+ // first guarantees the effect (if any) has flushed before we snapshot
91
+ // myRequestId.
62
92
  await sessionHandler.ensureSession(data.query);
63
93
 
64
- // 2. 更新会话名称(如果是第一条消息)
94
+ const myRequestId = ++currentQARef.current.activeRequestId;
95
+ // Snapshot current session id for downstream SSE guard checks
96
+ currentQARef.current.activeSessionId = sessionHandler.getCurrentSessionId();
97
+
98
+ // 2. Update session name (only for the first message)
65
99
  const messages = messageHandler.getMessages();
66
100
  if (sessionHandler.getCurrentSessionId()) {
67
101
  await sessionHandler.updateSessionName(data.query, messages);
68
102
  }
69
103
 
70
- // 3. 创建用户请求消息
104
+ // 3. Create user request message
71
105
  messageHandler.createRequestMessage(data);
72
106
  setLoading(true);
73
107
  await sleep(100);
74
108
 
75
- // 4. 创建助手响应消息
109
+ // If requestId changed during the sleep (session switch / cancel / new submit), bail out
110
+ if (myRequestId !== currentQARef.current.activeRequestId) return;
111
+
112
+ // 4. Create assistant response placeholder
76
113
  messageHandler.createResponseMessage();
77
114
 
78
- // 5. 获取历史消息并发起请求
115
+ // 5. Gather history messages and fire the request
79
116
  const historyMessages = messageHandler.getHistoryMessages();
80
117
  await sessionHandler.syncSessionMessages(messageHandler.getMessages());
81
118
 
82
- await request(historyMessages, data.biz_params);
119
+ await request(historyMessages, data.biz_params, myRequestId);
83
120
  // mockRequest(mockdata);
84
- }, [messageHandler, sessionHandler, request]);
121
+ }, [messageHandler, sessionHandler, request, setLoading]);
85
122
 
86
123
 
87
124
  const handleApproval = useCallback(async ({ input }) => {
125
+ currentQARef.current.abortController?.abort();
126
+ // Snapshot the current session id BEFORE bumping requestId, then bump.
127
+ // Order matches handleSubmit so a concurrent session-change effect cannot
128
+ // invalidate myRequestId between the bump and the sleep guard below.
129
+ currentQARef.current.activeSessionId = sessionHandler.getCurrentSessionId();
130
+ const myRequestId = ++currentQARef.current.activeRequestId;
131
+
88
132
  messageHandler.createApprovalMessage(input);
89
133
 
90
134
  setLoading(true);
91
135
  await sleep(100);
92
136
 
137
+ if (myRequestId !== currentQARef.current.activeRequestId) return;
138
+
93
139
  messageHandler.createResponseMessage();
94
140
  const historyMessages = messageHandler.getHistoryMessages();
95
141
  await sessionHandler.syncSessionMessages(messageHandler.getMessages());
96
142
 
97
- await request(historyMessages);
98
- }, [messageHandler, sessionHandler, request]);
143
+ await request(historyMessages, undefined, myRequestId);
144
+ }, [messageHandler, sessionHandler, request, setLoading]);
99
145
 
100
146
  /**
101
- * 处理取消
102
- * 1. 标记 interrupted 并重置 UIfinishResponse
103
- * 2. abort SSE 连接 —— Stream 内部的 Promise.race 会立即 reject AbortError,
104
- * processSSEResponse catch 会检测 interrupted 状态并调用 cancel API
147
+ * Handle cancel / stop.
148
+ * 1. Mark response as interrupted and reset UI (finishResponse).
149
+ * 2. Invoke the cancel API immediately do NOT wait for the next SSE
150
+ * chunk to deliver the cancellation (fixes "backend keeps running
151
+ * after stop" issue).
152
+ * 3. Abort the SSE connection — its catch branch will see
153
+ * msgStatus === 'interrupted' and call builder.cancel() to flip the
154
+ * in-progress TEXT content to Canceled, so the trailing Markdown
155
+ * cursor ("...") disappears.
156
+ *
157
+ * NOTE: we intentionally do NOT bump activeRequestId here. Doing so
158
+ * would make isStillActive() in processSSEResponse return false for
159
+ * this very cancel, which would short-circuit the catch branch before
160
+ * builder.cancel() runs and leave the trailing cursor blinking forever.
161
+ * Stale-chunk protection still holds: abort() breaks the SSE loop
162
+ * immediately, and the next submit / session switch will bump
163
+ * activeRequestId on its own.
105
164
  */
106
165
  const handleCancel = useCallback(() => {
107
166
  finishResponse('interrupted');
167
+ const sessionId = sessionHandler.getCurrentSessionId();
168
+ const cancelFn = apiOptionsRef.current.cancel;
169
+ if (cancelFn && sessionId) {
170
+ try {
171
+ cancelFn({ session_id: sessionId });
172
+ } catch (e) {
173
+ console.error('cancel api failed:', e);
174
+ }
175
+ }
108
176
  currentQARef.current.abortController?.abort();
109
- }, [finishResponse]);
177
+ }, [finishResponse, sessionHandler]);
110
178
 
111
179
  /**
112
- * 处理重新生成
180
+ * Handle regenerate (retry the last assistant response).
113
181
  */
114
182
  const handleRegenerate = useCallback(async (messageId: string) => {
183
+ currentQARef.current.abortController?.abort();
184
+ currentQARef.current.activeSessionId = sessionHandler.getCurrentSessionId();
185
+ const myRequestId = ++currentQARef.current.activeRequestId;
186
+
115
187
  setLoading(true);
116
188
 
117
- // 1. 移除旧消息
189
+ // 1. Remove old message
118
190
  messageHandler.removeMessageById(messageId);
119
191
 
120
- // 2. 创建新的响应消息
192
+ // 2. Create new response placeholder
121
193
  currentQARef.current.abortController = new AbortController();
122
194
  messageHandler.createResponseMessage();
123
195
 
124
- // 3. 发起请求
196
+ // 3. Fire the request
125
197
  const historyMessages = messageHandler.getHistoryMessages();
126
- await request(historyMessages);
127
- }, [messageHandler, request]);
198
+ await request(historyMessages, undefined, myRequestId);
199
+ }, [messageHandler, request, sessionHandler, setLoading]);
128
200
 
129
201
  /**
130
- * 处理 SSE 重连(切回未完成的对话时)
202
+ * Handle SSE reconnection (when switching back to an unfinished conversation).
131
203
  * If the reconnect API returns no body or the stream ends without a completion event,
132
204
  * treat it as idle: remove the empty placeholder and reset loading.
133
205
  */
134
206
  const handleReconnect = useCallback(async (sessionId: string) => {
135
207
  currentQARef.current.abortController?.abort();
136
208
  currentQARef.current.abortController = new AbortController();
209
+ const myRequestId = ++currentQARef.current.activeRequestId;
210
+ currentQARef.current.activeSessionId = sessionId;
137
211
  setLoading(true);
138
212
 
139
213
  messageHandler.createResponseMessage();
140
214
 
141
- await reconnect(sessionId);
215
+ await reconnect(sessionId, myRequestId);
216
+
217
+ // If session was switched or a new request fired during reconnect, bail out
218
+ if (myRequestId !== currentQARef.current.activeRequestId) return;
142
219
 
143
220
  // If the response is still in 'generating' state after reconnect completes,
144
221
  // onFinish() was never called (no response body, or stream closed without a completion event).
@@ -154,21 +231,40 @@ export default function useChatController() {
154
231
  }
155
232
  }, [messageHandler, reconnect, setLoading]);
156
233
 
157
- // 监听会话切换,断开当前 SSE 连接(不通知后端取消)并重置状态
234
+ // On session switch: abort current SSE (without notifying backend cancel)
235
+ // and reset state. Also increment activeRequestId so any residual SSE
236
+ // chunks from the old session are discarded, preventing cross-session leakage.
237
+ //
238
+ // IMPORTANT: only bump on a real session change. Running this on initial
239
+ // mount or when sessionId merely transitions from undefined → <same id>
240
+ // (e.g. after route navigate / refreshKey churn) would invalidate the
241
+ // myRequestId taken by an in-flight handleSubmit and silently drop the
242
+ // outgoing chat request — that was the regression that made existing
243
+ // sessions unable to send messages until a new chat was created.
158
244
  useEffect(() => {
245
+ const prevSessionId = currentQARef.current.activeSessionId;
246
+ if (!prevSessionId || prevSessionId === currentSessionId) {
247
+ // First mount, or no real switch: just sync the snapshot, do not bump.
248
+ currentQARef.current.activeSessionId = currentSessionId;
249
+ return;
250
+ }
251
+
159
252
  currentQARef.current.abortController?.abort();
160
253
  currentQARef.current = {
161
254
  request: undefined,
162
255
  response: undefined,
163
256
  abortController: undefined,
257
+ activeRequestId: currentQARef.current.activeRequestId + 1,
258
+ activeSessionId: currentSessionId,
164
259
  };
165
260
 
166
261
  return () => {
167
262
  currentQARef.current.abortController?.abort();
263
+ currentQARef.current.activeRequestId += 1;
168
264
  };
169
265
  }, [currentSessionId]);
170
266
 
171
- // 监听重连事件
267
+ // Listen for reconnect events
172
268
  useChatAnywhereEventEmitter({
173
269
  type: 'handleReconnect',
174
270
  callback: async (data) => {
@@ -176,7 +272,7 @@ export default function useChatController() {
176
272
  }
177
273
  }, [handleReconnect]);
178
274
 
179
- // 监听重新生成事件
275
+ // Listen for regenerate events
180
276
  useChatAnywhereEventEmitter({
181
277
  type: 'handleReplace',
182
278
  callback: async (data) => {
@@ -11,6 +11,10 @@ interface UseChatRequestOptions {
11
11
  request?: IAgentScopeRuntimeWebUIMessage;
12
12
  response?: IAgentScopeRuntimeWebUIMessage;
13
13
  abortController?: AbortController;
14
+ /** Active request id, maintained by the controller. Incrementing it invalidates any in-flight SSE. */
15
+ activeRequestId: number;
16
+ /** Session id snapshot for the active request. */
17
+ activeSessionId?: string;
14
18
  }>;
15
19
  updateMessage: (message: IAgentScopeRuntimeWebUIMessage) => void;
16
20
  getCurrentSessionId: () => string;
@@ -18,13 +22,13 @@ interface UseChatRequestOptions {
18
22
  }
19
23
 
20
24
  /**
21
- * 处理 API 请求和流式响应的 Hook
25
+ * Hook for handling API requests and streaming SSE responses.
22
26
  */
23
27
  export default function useChatRequest(options: UseChatRequestOptions) {
24
28
  const { currentQARef, updateMessage, getCurrentSessionId, onFinish } = options;
25
29
  const apiOptions = useChatAnywhereOptions(v => v.api);
26
30
 
27
- // 使用 ref 保存最新的 apiOptions,避免闭包陷阱
31
+ // Keep apiOptions in a ref to avoid stale closure issues
28
32
  const apiOptionsRef = useRef(apiOptions);
29
33
 
30
34
  useEffect(() => {
@@ -57,7 +61,11 @@ export default function useChatRequest(options: UseChatRequestOptions) {
57
61
  }, [])
58
62
 
59
63
 
60
- const processSSEResponse = useCallback(async (response: Response) => {
64
+ const processSSEResponse = useCallback(async (
65
+ response: Response,
66
+ myRequestId: number,
67
+ mySessionId?: string,
68
+ ) => {
61
69
  const currentApiOptions = apiOptionsRef.current;
62
70
  const agentScopeRuntimeResponseBuilder = new AgentScopeRuntimeResponseBuilder({
63
71
  id: '',
@@ -65,6 +73,19 @@ export default function useChatRequest(options: UseChatRequestOptions) {
65
73
  created_at: 0,
66
74
  });
67
75
 
76
+ /**
77
+ * Guard: check whether this SSE stream is still the active request.
78
+ * If any of the following is true, writing should stop immediately:
79
+ * - requestId mismatch: user cancelled / sent new message / switched session
80
+ * - sessionId mismatch: session was switched away, prevents cross-session leakage
81
+ */
82
+ const isStillActive = () => {
83
+ if (currentQARef.current.activeRequestId !== myRequestId) return false;
84
+ if (mySessionId && currentQARef.current.activeSessionId &&
85
+ currentQARef.current.activeSessionId !== mySessionId) return false;
86
+ return true;
87
+ };
88
+
68
89
  if (!response.ok) {
69
90
  try {
70
91
  const data = await response.json();
@@ -79,16 +100,18 @@ export default function useChatRequest(options: UseChatRequestOptions) {
79
100
  message: JSON.stringify(data),
80
101
  });
81
102
 
82
- currentQARef.current.response.cards = [
83
- {
84
- code: 'AgentScopeRuntimeResponseCard',
85
- data: res,
86
- }
87
- ];
103
+ if (isStillActive() && currentQARef.current.response) {
104
+ currentQARef.current.response.cards = [
105
+ {
106
+ code: 'AgentScopeRuntimeResponseCard',
107
+ data: res,
108
+ }
109
+ ];
110
+ }
88
111
  } catch {
89
112
  // Ignore JSON parse errors — still call onFinish to reset loading state
90
113
  }
91
- onFinish();
114
+ if (isStillActive()) onFinish();
92
115
  return;
93
116
  }
94
117
 
@@ -99,22 +122,23 @@ export default function useChatRequest(options: UseChatRequestOptions) {
99
122
  readableStream: response.body,
100
123
  signal: abortSignal,
101
124
  })) {
125
+ // Primary guard: if this SSE is no longer active, stop immediately
126
+ // to prevent ghost writes into a different session/request.
127
+ if (!isStillActive()) break;
128
+
102
129
  if (currentQARef.current.response?.msgStatus === 'interrupted') {
103
130
  currentQARef.current.abortController?.abort();
104
- if (currentApiOptions.cancel) {
105
- currentApiOptions.cancel({
106
- session_id: getCurrentSessionId(),
107
- });
131
+ // Cancel was already sent by handleCancel; don't repeat it here.
132
+
133
+ if (isStillActive() && currentQARef.current.response) {
134
+ currentQARef.current.response.cards = [
135
+ {
136
+ code: 'AgentScopeRuntimeResponseCard',
137
+ data: agentScopeRuntimeResponseBuilder.cancel(),
138
+ }
139
+ ];
140
+ updateMessage(currentQARef.current.response);
108
141
  }
109
-
110
- currentQARef.current.response.cards = [
111
- {
112
- code: 'AgentScopeRuntimeResponseCard',
113
- data: agentScopeRuntimeResponseBuilder.cancel(),
114
- }
115
- ];
116
-
117
- updateMessage(currentQARef.current.response);
118
142
  break;
119
143
  }
120
144
 
@@ -122,7 +146,9 @@ export default function useChatRequest(options: UseChatRequestOptions) {
122
146
  const chunkData = responseParser(chunk.data);
123
147
  const res = agentScopeRuntimeResponseBuilder.handle(chunkData);
124
148
 
125
- if (res.status !== AgentScopeRuntimeRunStatus.Failed && !res.output?.[0]?.content?.length) continue;
149
+ if (res.status !== AgentScopeRuntimeRunStatus.Failed && !res.output?.some(msg => msg.content?.length)) continue;
150
+
151
+ if (!isStillActive()) break;
126
152
 
127
153
  if (currentQARef.current.response) {
128
154
  currentQARef.current.response.cards = [
@@ -140,19 +166,21 @@ export default function useChatRequest(options: UseChatRequestOptions) {
140
166
  }
141
167
  }
142
168
  } catch (error) {
169
+ if (!isStillActive()) {
170
+ // Request is no longer active; do not write cards or fire cancel.
171
+ return;
172
+ }
143
173
  if (currentQARef.current.response?.msgStatus === 'interrupted') {
144
- if (currentApiOptions.cancel) {
145
- currentApiOptions.cancel({
146
- session_id: getCurrentSessionId(),
147
- });
174
+ // Cancel was already sent by handleCancel; don't repeat it here.
175
+ if (currentQARef.current.response) {
176
+ currentQARef.current.response.cards = [
177
+ {
178
+ code: 'AgentScopeRuntimeResponseCard',
179
+ data: agentScopeRuntimeResponseBuilder.cancel(),
180
+ }
181
+ ];
182
+ updateMessage(currentQARef.current.response);
148
183
  }
149
- currentQARef.current.response.cards = [
150
- {
151
- code: 'AgentScopeRuntimeResponseCard',
152
- data: agentScopeRuntimeResponseBuilder.cancel(),
153
- }
154
- ];
155
- updateMessage(currentQARef.current.response);
156
184
  } else {
157
185
  console.error(error);
158
186
  }
@@ -160,10 +188,16 @@ export default function useChatRequest(options: UseChatRequestOptions) {
160
188
  }, [getCurrentSessionId, currentQARef, updateMessage, onFinish]);
161
189
 
162
190
 
163
- const request = useCallback(async (historyMessages: any[], biz_params?: IAgentScopeRuntimeWebUIInputData['biz_params']) => {
191
+ const request = useCallback(async (
192
+ historyMessages: any[],
193
+ biz_params?: IAgentScopeRuntimeWebUIInputData['biz_params'],
194
+ myRequestId?: number,
195
+ ) => {
164
196
  const currentApiOptions = apiOptionsRef.current;
165
197
  const { enableHistoryMessages = false } = currentApiOptions;
166
198
  const abortSignal = currentQARef.current.abortController?.signal;
199
+ const requestId = myRequestId ?? currentQARef.current.activeRequestId;
200
+ const sessionId = currentQARef.current.activeSessionId ?? getCurrentSessionId();
167
201
  let response
168
202
  try {
169
203
  response = currentApiOptions.fetch ? await currentApiOptions.fetch({
@@ -188,15 +222,16 @@ export default function useChatRequest(options: UseChatRequestOptions) {
188
222
  }
189
223
 
190
224
  if (response && response.body) {
191
- await processSSEResponse(response);
225
+ await processSSEResponse(response, requestId, sessionId);
192
226
  }
193
227
  }, [getCurrentSessionId, currentQARef, processSSEResponse]);
194
228
 
195
- const reconnect = useCallback(async (sessionId: string) => {
229
+ const reconnect = useCallback(async (sessionId: string, myRequestId?: number) => {
196
230
  const currentApiOptions = apiOptionsRef.current;
197
231
  if (!currentApiOptions.reconnect) return;
198
232
 
199
233
  const abortSignal = currentQARef.current.abortController?.signal;
234
+ const requestId = myRequestId ?? currentQARef.current.activeRequestId;
200
235
  let response: Response | undefined;
201
236
  try {
202
237
  response = await currentApiOptions.reconnect({
@@ -207,7 +242,7 @@ export default function useChatRequest(options: UseChatRequestOptions) {
207
242
  }
208
243
 
209
244
  if (response && response.body) {
210
- await processSSEResponse(response);
245
+ await processSSEResponse(response, requestId, sessionId);
211
246
  }
212
247
  }, [currentQARef, processSSEResponse]);
213
248