@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.
- 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 +55 -4
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Message.tsx +2 -1
- package/components/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Tool.tsx +28 -5
- 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 +80 -12
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Message.js +6 -1
- package/lib/AgentScopeRuntimeWebUI/core/AgentScopeRuntime/Response/Tool.js +29 -4
- 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
- package/bin/starter_webui/README.md +0 -75
- package/bin/starter_webui/eslint.config.js +0 -28
- package/bin/starter_webui/index.html +0 -12
- package/bin/starter_webui/package.json +0 -34
- package/bin/starter_webui/src/App.tsx +0 -20
- package/bin/starter_webui/src/components/Chat/OptionsPanel/FormItem.tsx +0 -37
- package/bin/starter_webui/src/components/Chat/OptionsPanel/OptionsEditor.tsx +0 -160
- package/bin/starter_webui/src/components/Chat/OptionsPanel/defaultConfig.ts +0 -41
- package/bin/starter_webui/src/components/Chat/OptionsPanel/index.tsx +0 -27
- package/bin/starter_webui/src/components/Chat/index.tsx +0 -45
- package/bin/starter_webui/src/components/Chat/sessionApi/index.ts +0 -53
- package/bin/starter_webui/src/main.tsx +0 -9
- package/bin/starter_webui/src/vite-env.d.ts +0 -4
- package/bin/starter_webui/tsconfig.app.json +0 -24
- package/bin/starter_webui/tsconfig.json +0 -7
- package/bin/starter_webui/tsconfig.node.json +0 -22
- 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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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={
|
|
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;
|
|
@@ -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) => {
|