@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.
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Actions.tsx +8 -4
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Builder.tsx +7 -1
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Card.tsx +5 -1
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Actions.tsx +1 -1
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Builder.tsx +34 -3
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Message.tsx +2 -1
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/types.tsx +1 -0
- package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.tsx +129 -33
- package/components/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.tsx +74 -39
- package/components/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.ts +10 -0
- package/components/DefaultCards/Files/index.tsx +5 -1
- package/components/Markdown/Markdown.tsx +2 -1
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Actions.js +8 -1
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Builder.js +4 -0
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Request/Card.js +8 -2
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Actions.js +1 -1
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Builder.js +58 -12
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Message.js +6 -1
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/types.d.ts +1 -0
- package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.d.ts +1 -1
- package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatController.js +167 -68
- package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.d.ts +7 -3
- package/lib/AgentScopeRuntimeWebUI/core/Chat/hooks/useChatRequest.js +151 -117
- package/lib/AgentScopeRuntimeWebUI/core/types/IChatAnywhere.d.ts +15 -0
- package/lib/DefaultCards/Files/index.js +5 -1
- package/lib/Markdown/Markdown.js +1 -0
- 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
|
-
|
|
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 = {
|
|
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
|
|
|
@@ -107,10 +107,41 @@ class AgentScopeRuntimeResponseBuilder {
|
|
|
107
107
|
|
|
108
108
|
handleResponse(data: IAgentScopeRuntimeResponse) {
|
|
109
109
|
this.data = produce(this.data, (draft) => {
|
|
110
|
-
|
|
111
|
-
|
|
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:
|
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
103
|
-
* 2.
|
|
104
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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?.
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
|
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
|
|