@agentscope-ai/chat 1.1.65 → 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 (46) 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 +55 -4
  6. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Message.tsx +2 -1
  7. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Tool.tsx +28 -5
  8. package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/types.tsx +1 -0
  9. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.tsx +129 -33
  10. package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.tsx +74 -39
  11. package/components/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.ts +10 -0
  12. package/components/DefaultCards/Files/index.tsx +5 -1
  13. package/components/Markdown/Markdown.tsx +2 -1
  14. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Actions.js +8 -1
  15. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Builder.js +4 -0
  16. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Card.js +8 -2
  17. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Actions.js +1 -1
  18. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Builder.js +80 -12
  19. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Message.js +6 -1
  20. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Tool.js +29 -4
  21. package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/types.d.ts +1 -0
  22. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.d.ts +1 -1
  23. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.js +167 -68
  24. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.d.ts +7 -3
  25. package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.js +151 -117
  26. package/lib/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.d.ts +15 -0
  27. package/lib/DefaultCards/Files/index.js +5 -1
  28. package/lib/Markdown/Markdown.js +1 -0
  29. package/package.json +1 -1
  30. package/bin/starter_webui/README.md +0 -75
  31. package/bin/starter_webui/eslint.config.js +0 -28
  32. package/bin/starter_webui/index.html +0 -12
  33. package/bin/starter_webui/package.json +0 -34
  34. package/bin/starter_webui/src/App.tsx +0 -20
  35. package/bin/starter_webui/src/components/Chat/OptionsPanel/FormItem.tsx +0 -37
  36. package/bin/starter_webui/src/components/Chat/OptionsPanel/OptionsEditor.tsx +0 -160
  37. package/bin/starter_webui/src/components/Chat/OptionsPanel/defaultConfig.ts +0 -41
  38. package/bin/starter_webui/src/components/Chat/OptionsPanel/index.tsx +0 -27
  39. package/bin/starter_webui/src/components/Chat/index.tsx +0 -45
  40. package/bin/starter_webui/src/components/Chat/sessionApi/index.ts +0 -53
  41. package/bin/starter_webui/src/main.tsx +0 -9
  42. package/bin/starter_webui/src/vite-env.d.ts +0 -4
  43. package/bin/starter_webui/tsconfig.app.json +0 -24
  44. package/bin/starter_webui/tsconfig.json +0 -7
  45. package/bin/starter_webui/tsconfig.node.json +0 -22
  46. package/bin/starter_webui/vite.config.ts +0 -11
@@ -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
 
@@ -157,7 +188,27 @@ class AgentScopeRuntimeResponseBuilder {
157
188
  } else if (data.type === AgentScopeRuntimeContentType.IMAGE) {
158
189
  (lastContent as IImageContent).image_url = (data as IImageContent).image_url;
159
190
  } else if (data.type === AgentScopeRuntimeContentType.DATA) {
160
- (lastContent as IDataContent).data = (data as IDataContent).data;
191
+ const isStreamingToolInput = [
192
+ AgentScopeRuntimeMessageType.PLUGIN_CALL,
193
+ AgentScopeRuntimeMessageType.TOOL_CALL,
194
+ AgentScopeRuntimeMessageType.MCP_CALL,
195
+ ].includes(msg.type as AgentScopeRuntimeMessageType);
196
+
197
+ if (isStreamingToolInput) {
198
+ const oldData = (lastContent as IDataContent).data || {};
199
+ const newData = (data as IDataContent).data || {};
200
+ const merged: Record<string, any> = { ...oldData };
201
+ for (const [key, value] of Object.entries(newData)) {
202
+ if (typeof value === 'string' && typeof merged[key] === 'string') {
203
+ merged[key] = merged[key] + value;
204
+ } else {
205
+ merged[key] = value;
206
+ }
207
+ }
208
+ (lastContent as IDataContent).data = merged;
209
+ } else {
210
+ (lastContent as IDataContent).data = (data as IDataContent).data;
211
+ }
161
212
  }
162
213
  } else {
163
214
  msg.content.push(data);
@@ -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:
@@ -1,12 +1,28 @@
1
- import React from "react";
2
- import { AgentScopeRuntimeRunStatus, IAgentScopeRuntimeMessage, IDataContent } from "../types";
1
+ import React, { useEffect, useState } from "react";
2
+ import { AgentScopeRuntimeMessageType, AgentScopeRuntimeRunStatus, IAgentScopeRuntimeMessage, IDataContent } from "../types";
3
3
  import { ToolCall } from '@agentscope-ai/chat';
4
4
  import { useChatAnywhereOptions } from "../../Context/ChatAnywhereOptionsContext";
5
5
  import Approval from "./Approval";
6
6
 
7
+ // output展示后,2s自动关闭
8
+ const OUTPUT_AUTO_COLLAPSE_MS = 2000;
9
+
7
10
  const Tool = React.memo(function ({ data, isApproval = false }: { data: IAgentScopeRuntimeMessage, isApproval?: boolean }) {
8
11
  const customToolRenderConfig = useChatAnywhereOptions(v => v.customToolRenderConfig) || {};
9
12
 
13
+ const isOutput = [
14
+ AgentScopeRuntimeMessageType.PLUGIN_CALL_OUTPUT,
15
+ AgentScopeRuntimeMessageType.TOOL_CALL_OUTPUT,
16
+ AgentScopeRuntimeMessageType.MCP_CALL_OUTPUT,
17
+ ].includes(data.type);
18
+
19
+ const [autoCollapsed, setAutoCollapsed] = useState(false);
20
+ useEffect(() => {
21
+ if (!isOutput || autoCollapsed) return;
22
+ const timer = setTimeout(() => setAutoCollapsed(true), OUTPUT_AUTO_COLLAPSE_MS);
23
+ return () => clearTimeout(timer);
24
+ }, [isOutput, autoCollapsed]);
25
+
10
26
  if (!data.content?.length) return null;
11
27
  const content = data.content as IDataContent<{
12
28
  name: string;
@@ -19,13 +35,21 @@ const Tool = React.memo(function ({ data, isApproval = false }: { data: IAgentSc
19
35
  const serverLabel = `${content[0].data.server_label ? content[0].data.server_label + ' / ' : ''}`
20
36
  const title = `${serverLabel}${toolName}`
21
37
 
38
+ const isInput = [
39
+ AgentScopeRuntimeMessageType.PLUGIN_CALL,
40
+ AgentScopeRuntimeMessageType.TOOL_CALL,
41
+ AgentScopeRuntimeMessageType.MCP_CALL,
42
+ ].includes(data.type);
43
+
44
+ const defaultOpen = isInput || (isOutput && !autoCollapsed);
45
+
22
46
  let node
23
47
 
24
48
  if (customToolRenderConfig[toolName]) {
25
49
  const C = customToolRenderConfig[toolName];
26
50
  node = <C data={data} />
27
51
  } else {
28
- node = <ToolCall loading={loading} defaultOpen={false} title={title === 'undefined' ? '' : title} input={content[0]?.data?.arguments} output={content[1]?.data?.output}></ToolCall>
52
+ node = <ToolCall key={autoCollapsed ? 'collapsed' : 'open'} loading={loading} defaultOpen={defaultOpen} title={title === 'undefined' ? '' : title} input={content[0]?.data?.arguments} output={content[1]?.data?.output}></ToolCall>
29
53
  }
30
54
 
31
55
  return <>
@@ -35,5 +59,4 @@ const Tool = React.memo(function ({ data, isApproval = false }: { data: IAgentSc
35
59
  })
36
60
 
37
61
 
38
- export default Tool;
39
-
62
+ export default Tool;
@@ -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) => {