@springbrand/chat-client 0.1.3-alpha.9 → 0.3.0-alpha.2
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/package.json +8 -6
- package/src/chat-sessions.ts +7 -3
- package/src/index.ts +3 -0
- package/src/use-universal-agent-chat.ts +609 -86
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/chat-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-alpha.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
".": "./src/index.ts"
|
|
14
14
|
},
|
|
15
15
|
"peerDependencies": {
|
|
16
|
-
"
|
|
16
|
+
"@cloudflare/ai-chat": "^0.12.0",
|
|
17
|
+
"agents": "^0.23.0",
|
|
17
18
|
"ai": "^7.0.0",
|
|
18
19
|
"react": "^19.0.0"
|
|
19
20
|
},
|
|
@@ -21,11 +22,12 @@
|
|
|
21
22
|
"nanoid": "^5.1.16"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
|
-
"@
|
|
25
|
-
"@types/react
|
|
26
|
-
"react-dom": "^19.2.
|
|
25
|
+
"@cloudflare/ai-chat": "0.12.0",
|
|
26
|
+
"@types/react": "^19.2.18",
|
|
27
|
+
"@types/react-dom": "^19.2.5",
|
|
28
|
+
"react-dom": "^19.2.8",
|
|
27
29
|
"typescript": "^7.0.2",
|
|
28
|
-
"@springbrand/agent-runtime": "0.
|
|
30
|
+
"@springbrand/agent-runtime": "0.3.0-alpha.3"
|
|
29
31
|
},
|
|
30
32
|
"scripts": {
|
|
31
33
|
"typecheck": "tsc --noEmit"
|
package/src/chat-sessions.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { useAgent } from "agents/react";
|
|
3
3
|
|
|
4
|
-
interface InboxState<Session> {
|
|
4
|
+
export interface InboxState<Session> {
|
|
5
5
|
chats: Session[];
|
|
6
6
|
workspaceRev?: number;
|
|
7
7
|
}
|
|
@@ -33,8 +33,11 @@ export async function loadChatSessions<Session>(
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Browser client for the user Inbox. Routing and presentation stay with the host UI. */
|
|
36
|
-
export function useChatSessions<
|
|
37
|
-
|
|
36
|
+
export function useChatSessions<
|
|
37
|
+
Session,
|
|
38
|
+
State extends InboxState<Session> = InboxState<Session>,
|
|
39
|
+
>(options: ChatSessionsOptions = {}) {
|
|
40
|
+
const inbox = useAgent<State>({
|
|
38
41
|
agent: "Inbox",
|
|
39
42
|
basePath: CURRENT_INBOX_AGENT_PATH,
|
|
40
43
|
...(options.protocols === undefined ? {} : { protocols: options.protocols }),
|
|
@@ -93,6 +96,7 @@ export function useChatSessions<Session>(options: ChatSessionsOptions = {}) {
|
|
|
93
96
|
synced: inbox.state !== undefined || snapshot !== undefined,
|
|
94
97
|
connected: inbox.identified,
|
|
95
98
|
workspaceRev: inbox.state?.workspaceRev ?? 0,
|
|
99
|
+
inboxState: inbox.state,
|
|
96
100
|
createChat,
|
|
97
101
|
forkChat,
|
|
98
102
|
renameChat,
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export {
|
|
2
|
+
UniversalAgentChatProvider,
|
|
2
3
|
useUniversalAgentChat,
|
|
3
4
|
type AgentToolRun,
|
|
5
|
+
type ChatConnectionStatus,
|
|
4
6
|
type ChatConnectionOptions,
|
|
5
7
|
type ChatRuntime,
|
|
6
8
|
} from "./use-universal-agent-chat";
|
|
@@ -21,4 +23,5 @@ export {
|
|
|
21
23
|
useChatSessions,
|
|
22
24
|
type ChatSessionsOptions,
|
|
23
25
|
type ChatSessions,
|
|
26
|
+
type InboxState,
|
|
24
27
|
} from "./chat-sessions";
|
|
@@ -1,11 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
createElement,
|
|
4
|
+
Suspense,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
2
13
|
import { nanoid } from "nanoid";
|
|
3
14
|
import type { ChatStatus, FileUIPart, UIMessage } from "ai";
|
|
4
15
|
import type { AgentToolRunState } from "agents";
|
|
16
|
+
import type { AgentConnectionError } from "agents/client";
|
|
5
17
|
import { MessageType } from "agents/chat";
|
|
6
|
-
import { getAgentMessages, useAgentChat } from "
|
|
18
|
+
import { getAgentMessages, useAgentChat } from "@cloudflare/ai-chat/react";
|
|
7
19
|
import { useAgent, useAgentToolEvents } from "agents/react";
|
|
8
20
|
import {
|
|
21
|
+
type ApprovalDecision,
|
|
9
22
|
type MessageDelivery,
|
|
10
23
|
type MessageDispatchReceipt,
|
|
11
24
|
type RequestedCapability,
|
|
@@ -13,15 +26,32 @@ import {
|
|
|
13
26
|
type RuntimeConfigUpdateResult,
|
|
14
27
|
type RuntimeQueuedSubmission,
|
|
15
28
|
type RuntimeState,
|
|
29
|
+
type RuntimeTurnState,
|
|
16
30
|
} from "@springbrand/agent-runtime/contracts";
|
|
17
31
|
import { createSafeUIMessageAgentConnection } from "./ui-message-stream-guard";
|
|
18
32
|
import { CURRENT_INBOX_AGENT_PATH } from "./chat-sessions";
|
|
19
33
|
|
|
20
34
|
type ChatMessageMetadata = Record<string, unknown> & {
|
|
35
|
+
completedAt?: number;
|
|
36
|
+
error?: string;
|
|
37
|
+
interruptedByUser?: boolean;
|
|
38
|
+
// AI SDK keeps one assistant UIMessage for the live stream. This anchor is
|
|
39
|
+
// persisted in the user sidecar so live and hydrated presentation agree.
|
|
40
|
+
optimisticSteer?: {
|
|
41
|
+
assistantMessageId: string;
|
|
42
|
+
afterStep: number;
|
|
43
|
+
};
|
|
21
44
|
requestedCapabilities?: readonly RequestedCapability[];
|
|
45
|
+
turnId?: string;
|
|
46
|
+
userMessageId?: string;
|
|
22
47
|
turnStatus?: string;
|
|
23
48
|
};
|
|
24
49
|
type ChatMessage = UIMessage<ChatMessageMetadata>;
|
|
50
|
+
export type ChatConnectionStatus =
|
|
51
|
+
| "connecting"
|
|
52
|
+
| "connected"
|
|
53
|
+
| "rejected"
|
|
54
|
+
| "error";
|
|
25
55
|
type FailedDispatch = {
|
|
26
56
|
message: ChatMessage;
|
|
27
57
|
delivery: MessageDelivery;
|
|
@@ -62,35 +92,20 @@ type ChatApproval = {
|
|
|
62
92
|
requestId: string;
|
|
63
93
|
};
|
|
64
94
|
|
|
65
|
-
type ChatQueuedSubmission = {
|
|
66
|
-
submissionId: string;
|
|
67
|
-
messageId: string;
|
|
68
|
-
preview: string;
|
|
69
|
-
position: number;
|
|
70
|
-
createdAt: number;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
type ChatTurn = {
|
|
74
|
-
activeSubmissionId?: string;
|
|
75
|
-
activeRequestId?: string;
|
|
76
|
-
recoveryAttempt?: number;
|
|
77
|
-
recoveryMax?: number;
|
|
78
|
-
recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
|
|
79
|
-
steerable: boolean;
|
|
80
|
-
hasPendingSteer: boolean;
|
|
81
|
-
queued: ChatQueuedSubmission[];
|
|
82
|
-
};
|
|
83
|
-
|
|
84
95
|
export interface ChatRuntime {
|
|
85
96
|
messages: ChatMessage[];
|
|
86
97
|
status: ChatStatus;
|
|
98
|
+
connectionStatus: ChatConnectionStatus;
|
|
99
|
+
connectionError: AgentConnectionError | undefined;
|
|
87
100
|
runtimeLoad: ChatRuntimeLoadState | undefined;
|
|
88
101
|
isStreaming: boolean;
|
|
89
102
|
error: string | Error | undefined;
|
|
103
|
+
/** Reusing messageId makes delivery idempotent across caller retries. */
|
|
90
104
|
sendText: (
|
|
91
105
|
text: string,
|
|
92
106
|
files?: readonly FileUIPart[],
|
|
93
107
|
capabilities?: readonly RequestedCapability[],
|
|
108
|
+
messageId?: string,
|
|
94
109
|
) => Promise<boolean>;
|
|
95
110
|
steerText: (
|
|
96
111
|
text: string,
|
|
@@ -115,6 +130,10 @@ export interface ChatRuntime {
|
|
|
115
130
|
toolCallId: string,
|
|
116
131
|
response: unknown,
|
|
117
132
|
) => Promise<boolean>;
|
|
133
|
+
decideApproval: (
|
|
134
|
+
executionId: string,
|
|
135
|
+
decision: ApprovalDecision,
|
|
136
|
+
) => Promise<{ ok: boolean }>;
|
|
118
137
|
/**
|
|
119
138
|
* 读取该 Session facet 当前真正装配出来的 Runtime。
|
|
120
139
|
*
|
|
@@ -124,6 +143,10 @@ export interface ChatRuntime {
|
|
|
124
143
|
* 装配未就绪还是连接断了,收敛成 `undefined` 会让调试面板无从显示原因。
|
|
125
144
|
*/
|
|
126
145
|
getRuntimeAssembly: () => Promise<RuntimeAssemblyView>;
|
|
146
|
+
getRuntimeBinding: <Binding = unknown>() => Promise<Binding>;
|
|
147
|
+
readChatMetadata: <Metadata = unknown>() => Promise<Metadata>;
|
|
148
|
+
renameChat: <Metadata = unknown>(title: string) => Promise<Metadata>;
|
|
149
|
+
rebindRuntime: <Input = unknown, Result = unknown>(input: Input) => Promise<Result>;
|
|
127
150
|
updateConfig: <Change = unknown>(
|
|
128
151
|
command: unknown,
|
|
129
152
|
) => Promise<RuntimeConfigUpdateResult<Change>>;
|
|
@@ -136,7 +159,7 @@ export interface ChatRuntime {
|
|
|
136
159
|
isToolContinuation: boolean;
|
|
137
160
|
approvals: ChatApproval[] | undefined;
|
|
138
161
|
approvalsLoaded: boolean;
|
|
139
|
-
turn:
|
|
162
|
+
turn: RuntimeTurnState | undefined;
|
|
140
163
|
turnActive: boolean;
|
|
141
164
|
canSteer: boolean;
|
|
142
165
|
}
|
|
@@ -145,18 +168,83 @@ type SharedChatConnectionOptions = {
|
|
|
145
168
|
host?: string;
|
|
146
169
|
credentials?: RequestCredentials;
|
|
147
170
|
protocols?: string | string[];
|
|
171
|
+
onMessage?: (message: MessageEvent) => void;
|
|
148
172
|
inboxAgent?: string;
|
|
149
173
|
sessionAgent?: string;
|
|
174
|
+
/** Skip history loading when the caller has just created this Chat. */
|
|
175
|
+
skipInitialMessages?: boolean;
|
|
176
|
+
/** Host projection used only until the selected Session sends its first state. */
|
|
177
|
+
initialTurnActive?: boolean;
|
|
178
|
+
/** Optional Agent RPC method injected by the Host to receive Turn timings. */
|
|
179
|
+
performanceRpc?: string;
|
|
150
180
|
};
|
|
151
181
|
|
|
152
182
|
export type ChatConnectionOptions = SharedChatConnectionOptions & (
|
|
153
|
-
| { inboxName: string; basePath?: never }
|
|
154
|
-
| { inboxName?: never; basePath?: string }
|
|
183
|
+
| { inboxName: string; basePath?: never; sessionBasePath?: never }
|
|
184
|
+
| { inboxName?: never; basePath?: string; sessionBasePath?: never }
|
|
185
|
+
| { inboxName?: never; basePath?: never; sessionBasePath: string }
|
|
155
186
|
);
|
|
156
187
|
|
|
188
|
+
type SessionAgentConnection = ReturnType<typeof useAgent<RuntimeState>>;
|
|
189
|
+
type UniversalAgentChatContextValue = {
|
|
190
|
+
chatId: string;
|
|
191
|
+
connection: ChatConnectionOptions;
|
|
192
|
+
agent: SessionAgentConnection;
|
|
193
|
+
chatAgent: SessionAgentConnection;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const UniversalAgentChatContext = createContext<UniversalAgentChatContextValue | null>(null);
|
|
197
|
+
|
|
198
|
+
export function UniversalAgentChatProvider({
|
|
199
|
+
chatId,
|
|
200
|
+
connection = {},
|
|
201
|
+
fallback = null,
|
|
202
|
+
children,
|
|
203
|
+
}: {
|
|
204
|
+
chatId: string;
|
|
205
|
+
connection?: ChatConnectionOptions;
|
|
206
|
+
fallback?: ReactNode;
|
|
207
|
+
children: ReactNode;
|
|
208
|
+
}) {
|
|
209
|
+
const inboxAgent = connection.inboxAgent ?? "Inbox";
|
|
210
|
+
const routed = connection.sessionBasePath !== undefined;
|
|
211
|
+
const agent = useAgent<RuntimeState>({
|
|
212
|
+
agent: routed ? connection.sessionAgent ?? "UniversalAgent" : inboxAgent,
|
|
213
|
+
...(routed
|
|
214
|
+
? { basePath: connection.sessionBasePath }
|
|
215
|
+
: connection.inboxName === undefined
|
|
216
|
+
? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
|
|
217
|
+
: { name: connection.inboxName }),
|
|
218
|
+
...(connection.host === undefined ? {} : { host: connection.host }),
|
|
219
|
+
...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
|
|
220
|
+
...(connection.onMessage === undefined ? {} : { onMessage: connection.onMessage }),
|
|
221
|
+
connectionTimeout: 6_000,
|
|
222
|
+
...(routed ? {} : {
|
|
223
|
+
sub: [{
|
|
224
|
+
agent: connection.sessionAgent ?? "UniversalAgent",
|
|
225
|
+
name: chatId,
|
|
226
|
+
}],
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
const chatAgent = useMemo(
|
|
230
|
+
() => createSafeUIMessageAgentConnection(agent),
|
|
231
|
+
[agent],
|
|
232
|
+
);
|
|
233
|
+
const value = useMemo(
|
|
234
|
+
() => ({ chatId, connection, agent, chatAgent }),
|
|
235
|
+
[agent, chatAgent, chatId, connection],
|
|
236
|
+
);
|
|
237
|
+
return createElement(
|
|
238
|
+
UniversalAgentChatContext.Provider,
|
|
239
|
+
{ value },
|
|
240
|
+
createElement(Suspense, { fallback }, children),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
157
244
|
type ChatTurnTiming = {
|
|
158
245
|
chatId: string;
|
|
159
246
|
messageId: string;
|
|
247
|
+
submissionId?: string;
|
|
160
248
|
baselineAssistantIds: Set<string>;
|
|
161
249
|
click: number;
|
|
162
250
|
rpcReceipt?: number;
|
|
@@ -165,6 +253,37 @@ type ChatTurnTiming = {
|
|
|
165
253
|
ready?: number;
|
|
166
254
|
};
|
|
167
255
|
|
|
256
|
+
type ClientPerformanceMetrics = {
|
|
257
|
+
clickToRpcMs: number;
|
|
258
|
+
rpcToStreamMs: number;
|
|
259
|
+
streamToFirstTextMs?: number;
|
|
260
|
+
firstTextToReadyMs?: number;
|
|
261
|
+
ttftMs?: number;
|
|
262
|
+
totalMs: number;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
export function clientPerformanceMetrics(
|
|
266
|
+
timing: ChatTurnTiming,
|
|
267
|
+
): ClientPerformanceMetrics | null {
|
|
268
|
+
if (
|
|
269
|
+
timing.rpcReceipt === undefined ||
|
|
270
|
+
timing.streamStart === undefined ||
|
|
271
|
+
timing.ready === undefined
|
|
272
|
+
) return null;
|
|
273
|
+
return {
|
|
274
|
+
clickToRpcMs: timing.rpcReceipt - timing.click,
|
|
275
|
+
rpcToStreamMs: timing.streamStart - timing.rpcReceipt,
|
|
276
|
+
...(timing.firstText === undefined
|
|
277
|
+
? {}
|
|
278
|
+
: {
|
|
279
|
+
streamToFirstTextMs: timing.firstText - timing.streamStart,
|
|
280
|
+
firstTextToReadyMs: timing.ready - timing.firstText,
|
|
281
|
+
ttftMs: timing.firstText - timing.click,
|
|
282
|
+
}),
|
|
283
|
+
totalMs: timing.ready - timing.click,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
168
287
|
const CHAT_TURN_SEGMENTS = {
|
|
169
288
|
rpcReceipt: ["click", "universal-agent.chat.click_to_rpc_receipt"],
|
|
170
289
|
streamStart: [
|
|
@@ -210,6 +329,7 @@ function createChatMessage(
|
|
|
210
329
|
text: string,
|
|
211
330
|
files?: readonly FileUIPart[],
|
|
212
331
|
capabilities: readonly RequestedCapability[] = [],
|
|
332
|
+
messageId?: string,
|
|
213
333
|
): ChatMessage | null {
|
|
214
334
|
const normalizedText = text.trim();
|
|
215
335
|
if (!normalizedText && !files?.length) return null;
|
|
@@ -222,7 +342,7 @@ function createChatMessage(
|
|
|
222
342
|
return true;
|
|
223
343
|
});
|
|
224
344
|
return {
|
|
225
|
-
id: nanoid(),
|
|
345
|
+
id: messageId ?? nanoid(),
|
|
226
346
|
role: "user",
|
|
227
347
|
parts: [
|
|
228
348
|
...(files ?? []),
|
|
@@ -243,10 +363,204 @@ export function mergeOptimisticMessages<T extends UIMessage>(
|
|
|
243
363
|
messages: T[],
|
|
244
364
|
optimistic: T[],
|
|
245
365
|
): T[] {
|
|
246
|
-
if (optimistic.length === 0) return messages;
|
|
247
366
|
const ids = new Set(messages.map(({ id }) => id));
|
|
248
367
|
const pending = optimistic.filter(({ id }) => !ids.has(id));
|
|
249
|
-
|
|
368
|
+
const assistantsByTurn = new Map<string, T[]>();
|
|
369
|
+
for (const message of messages) {
|
|
370
|
+
const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
|
|
371
|
+
if (message.role !== "assistant" || typeof turnId !== "string") continue;
|
|
372
|
+
const assistants = assistantsByTurn.get(turnId) ?? [];
|
|
373
|
+
assistants.push(message);
|
|
374
|
+
assistantsByTurn.set(turnId, assistants);
|
|
375
|
+
}
|
|
376
|
+
const assistantPosition = new Map<string, {
|
|
377
|
+
assistantMessageId: string;
|
|
378
|
+
stepOffset: number;
|
|
379
|
+
}>();
|
|
380
|
+
for (const assistants of assistantsByTurn.values()) {
|
|
381
|
+
let stepOffset = 0;
|
|
382
|
+
for (const assistant of assistants) {
|
|
383
|
+
assistantPosition.set(assistant.id, {
|
|
384
|
+
assistantMessageId: assistants[0]!.id,
|
|
385
|
+
stepOffset,
|
|
386
|
+
});
|
|
387
|
+
stepOffset += assistant.parts.filter(
|
|
388
|
+
({ type }) => type === "step-start",
|
|
389
|
+
).length;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const normalizeAnchor = (anchor: NonNullable<
|
|
393
|
+
ChatMessageMetadata["optimisticSteer"]
|
|
394
|
+
>) => {
|
|
395
|
+
const position = assistantPosition.get(anchor.assistantMessageId);
|
|
396
|
+
return position
|
|
397
|
+
? {
|
|
398
|
+
assistantMessageId: position.assistantMessageId,
|
|
399
|
+
afterStep: position.stepOffset + anchor.afterStep,
|
|
400
|
+
}
|
|
401
|
+
: anchor;
|
|
402
|
+
};
|
|
403
|
+
const logicalMessages: T[] = [];
|
|
404
|
+
const logicalAssistantIndex = new Map<string, number>();
|
|
405
|
+
for (const message of messages) {
|
|
406
|
+
const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
|
|
407
|
+
if (message.role !== "assistant" || typeof turnId !== "string") {
|
|
408
|
+
logicalMessages.push(message);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const existingIndex = logicalAssistantIndex.get(turnId);
|
|
412
|
+
if (existingIndex === undefined) {
|
|
413
|
+
logicalAssistantIndex.set(turnId, logicalMessages.length);
|
|
414
|
+
logicalMessages.push(message);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const existing = logicalMessages[existingIndex]!;
|
|
418
|
+
logicalMessages[existingIndex] = {
|
|
419
|
+
...existing,
|
|
420
|
+
parts: [...existing.parts, ...message.parts],
|
|
421
|
+
metadata: {
|
|
422
|
+
...(existing.metadata as Record<string, unknown> | undefined),
|
|
423
|
+
...(message.metadata as Record<string, unknown> | undefined),
|
|
424
|
+
},
|
|
425
|
+
} as T;
|
|
426
|
+
}
|
|
427
|
+
const authoritativeAnchors = messages.flatMap((message, messageIndex) => {
|
|
428
|
+
const anchor = (message.metadata as ChatMessageMetadata | undefined)
|
|
429
|
+
?.optimisticSteer;
|
|
430
|
+
if (anchor) return [{ message, anchor: normalizeAnchor(anchor) }];
|
|
431
|
+
if (message.role !== "user") return [];
|
|
432
|
+
const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
|
|
433
|
+
const assistants = typeof turnId === "string"
|
|
434
|
+
? assistantsByTurn.get(turnId)
|
|
435
|
+
: undefined;
|
|
436
|
+
if (!assistants || assistants.length === 0) return [];
|
|
437
|
+
const openingMessageId = (assistants[0]!.metadata as
|
|
438
|
+
| ChatMessageMetadata
|
|
439
|
+
| undefined)?.userMessageId;
|
|
440
|
+
if (message.id === openingMessageId) return [];
|
|
441
|
+
const preceding = messages.slice(0, messageIndex).filter((candidate) =>
|
|
442
|
+
candidate.role === "assistant" &&
|
|
443
|
+
(candidate.metadata as ChatMessageMetadata | undefined)?.turnId === turnId
|
|
444
|
+
);
|
|
445
|
+
return [{
|
|
446
|
+
message,
|
|
447
|
+
anchor: {
|
|
448
|
+
assistantMessageId: assistants[0]!.id,
|
|
449
|
+
afterStep: preceding.reduce((total, assistant) =>
|
|
450
|
+
total + assistant.parts.filter(
|
|
451
|
+
({ type }) => type === "step-start",
|
|
452
|
+
).length, 0),
|
|
453
|
+
},
|
|
454
|
+
}];
|
|
455
|
+
});
|
|
456
|
+
const pendingAnchors = pending.flatMap((message) => {
|
|
457
|
+
const anchor = (message.metadata as ChatMessageMetadata | undefined)
|
|
458
|
+
?.optimisticSteer;
|
|
459
|
+
return anchor ? [{ message, anchor: normalizeAnchor(anchor) }] : [];
|
|
460
|
+
});
|
|
461
|
+
const anchoredSteers = [...authoritativeAnchors, ...pendingAnchors];
|
|
462
|
+
if (pending.length === 0 && anchoredSteers.length === 0) return messages;
|
|
463
|
+
const anchoredIds = new Set(
|
|
464
|
+
anchoredSteers.map(({ message }) => message.id),
|
|
465
|
+
);
|
|
466
|
+
const pendingById = new Map(
|
|
467
|
+
pending
|
|
468
|
+
.filter(({ id }) => !anchoredIds.has(id))
|
|
469
|
+
.map((message) => [message.id, message]),
|
|
470
|
+
);
|
|
471
|
+
const withPrompts = logicalMessages
|
|
472
|
+
.filter(({ id }) => !anchoredIds.has(id))
|
|
473
|
+
.flatMap((message) => {
|
|
474
|
+
const userMessageId = message.role === "assistant"
|
|
475
|
+
? (message.metadata as ChatMessageMetadata | undefined)?.userMessageId
|
|
476
|
+
: undefined;
|
|
477
|
+
if (typeof userMessageId !== "string") return [message];
|
|
478
|
+
const prompt = pendingById.get(userMessageId);
|
|
479
|
+
if (!prompt) return [message];
|
|
480
|
+
pendingById.delete(userMessageId);
|
|
481
|
+
return [prompt, message];
|
|
482
|
+
});
|
|
483
|
+
const unmatchedPrompts = pending.filter(({ id }) => pendingById.has(id));
|
|
484
|
+
if (anchoredSteers.length === 0) {
|
|
485
|
+
return [
|
|
486
|
+
...withPrompts,
|
|
487
|
+
...unmatchedPrompts,
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
const targetAssistantIds = new Set(
|
|
491
|
+
anchoredSteers.map(({ anchor }) => anchor.assistantMessageId),
|
|
492
|
+
);
|
|
493
|
+
const firstTarget = withPrompts.findIndex(({ id, role }) =>
|
|
494
|
+
role === "assistant" && targetAssistantIds.has(id)
|
|
495
|
+
);
|
|
496
|
+
const base = firstTarget < 0
|
|
497
|
+
? [...withPrompts, ...unmatchedPrompts]
|
|
498
|
+
: [
|
|
499
|
+
...withPrompts.slice(0, firstTarget),
|
|
500
|
+
...unmatchedPrompts,
|
|
501
|
+
...withPrompts.slice(firstTarget),
|
|
502
|
+
];
|
|
503
|
+
const byAssistant = new Map<string, typeof anchoredSteers>();
|
|
504
|
+
for (const anchored of anchoredSteers) {
|
|
505
|
+
const group = byAssistant.get(anchored.anchor.assistantMessageId) ?? [];
|
|
506
|
+
group.push(anchored);
|
|
507
|
+
byAssistant.set(anchored.anchor.assistantMessageId, group);
|
|
508
|
+
}
|
|
509
|
+
const placed = new Set<string>();
|
|
510
|
+
const merged = base.flatMap((message) => {
|
|
511
|
+
if (message.role !== "assistant") return [message];
|
|
512
|
+
const steers = byAssistant.get(message.id);
|
|
513
|
+
if (!steers) return [message];
|
|
514
|
+
|
|
515
|
+
const projected: T[] = [];
|
|
516
|
+
const stepStarts = message.parts.flatMap((part, index) =>
|
|
517
|
+
part.type === "step-start" ? [index] : []
|
|
518
|
+
);
|
|
519
|
+
let cursor = 0;
|
|
520
|
+
let minimumBoundary = 1;
|
|
521
|
+
for (let index = 0; index < steers.length;) {
|
|
522
|
+
const boundary = Math.max(
|
|
523
|
+
minimumBoundary,
|
|
524
|
+
steers[index]!.anchor.afterStep,
|
|
525
|
+
);
|
|
526
|
+
minimumBoundary = boundary;
|
|
527
|
+
let end = index + 1;
|
|
528
|
+
while (
|
|
529
|
+
end < steers.length &&
|
|
530
|
+
steers[end]!.anchor.afterStep <= boundary
|
|
531
|
+
) {
|
|
532
|
+
end += 1;
|
|
533
|
+
}
|
|
534
|
+
const segmentEnd = stepStarts[boundary] ?? message.parts.length;
|
|
535
|
+
if (segmentEnd > cursor) {
|
|
536
|
+
projected.push({
|
|
537
|
+
...message,
|
|
538
|
+
id: `${message.id}:segment:${projected.length}`,
|
|
539
|
+
parts: message.parts.slice(cursor, segmentEnd),
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
for (const anchored of steers.slice(index, end)) {
|
|
543
|
+
projected.push(anchored.message);
|
|
544
|
+
placed.add(anchored.message.id);
|
|
545
|
+
}
|
|
546
|
+
cursor = segmentEnd;
|
|
547
|
+
index = end;
|
|
548
|
+
}
|
|
549
|
+
if (cursor < message.parts.length) {
|
|
550
|
+
projected.push({
|
|
551
|
+
...message,
|
|
552
|
+
id: `${message.id}:segment:${projected.length}`,
|
|
553
|
+
parts: message.parts.slice(cursor),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
return projected;
|
|
557
|
+
});
|
|
558
|
+
return [
|
|
559
|
+
...merged,
|
|
560
|
+
...anchoredSteers
|
|
561
|
+
.filter(({ message }) => !placed.has(message.id))
|
|
562
|
+
.map(({ message }) => message),
|
|
563
|
+
];
|
|
250
564
|
}
|
|
251
565
|
|
|
252
566
|
function queuedPreview(message: ChatMessage): string {
|
|
@@ -260,10 +574,12 @@ function queuedPreview(message: ChatMessage): string {
|
|
|
260
574
|
function loadInitialMessages(
|
|
261
575
|
url: string | undefined,
|
|
262
576
|
credentials: RequestCredentials | undefined,
|
|
577
|
+
view: "hydration" | "full" = "hydration",
|
|
263
578
|
): Promise<ChatMessage[]> {
|
|
264
579
|
if (!url) return Promise.resolve([]);
|
|
265
580
|
const messagesUrl = new URL(url);
|
|
266
581
|
messagesUrl.pathname = `${messagesUrl.pathname.replace(/\/$/, "")}/get-messages`;
|
|
582
|
+
if (view === "hydration") messagesUrl.searchParams.set("view", "hydration");
|
|
267
583
|
return getAgentMessages({
|
|
268
584
|
url: messagesUrl.toString(),
|
|
269
585
|
...(credentials === undefined ? {} : { credentials }),
|
|
@@ -281,10 +597,12 @@ function loadInitialMessages(
|
|
|
281
597
|
* sub 数组由客户端 kebab 化;服务端按 ctx.exports 反解回 CamelCase className,
|
|
282
598
|
* 与 Inbox.onBeforeSubAgent 的严格门卫(hasSubAgent)对齐。
|
|
283
599
|
*/
|
|
284
|
-
export function useUniversalAgentChat(
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
600
|
+
export function useUniversalAgentChat(): ChatRuntime {
|
|
601
|
+
const context = useContext(UniversalAgentChatContext);
|
|
602
|
+
if (!context) {
|
|
603
|
+
throw new Error("useUniversalAgentChat must be used within UniversalAgentChatProvider");
|
|
604
|
+
}
|
|
605
|
+
const { chatId, connection, agent, chatAgent } = context;
|
|
288
606
|
const [optimisticMessages, setOptimisticMessages] =
|
|
289
607
|
useState<ChatMessage[]>([]);
|
|
290
608
|
const [optimisticQueued, setOptimisticQueued] =
|
|
@@ -292,25 +610,7 @@ export function useUniversalAgentChat(
|
|
|
292
610
|
const [dispatchError, setDispatchError] = useState<string>();
|
|
293
611
|
const [canRetry, setCanRetry] = useState(false);
|
|
294
612
|
const failedDispatchRef = useRef<FailedDispatch | undefined>(undefined);
|
|
295
|
-
const
|
|
296
|
-
const inboxAgent = connection.inboxAgent ?? "Inbox";
|
|
297
|
-
const agent = useAgent<RuntimeState>({
|
|
298
|
-
agent: inboxAgent,
|
|
299
|
-
...(connection.inboxName === undefined
|
|
300
|
-
? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
|
|
301
|
-
: { name: connection.inboxName }),
|
|
302
|
-
...(connection.host === undefined ? {} : { host: connection.host }),
|
|
303
|
-
...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
|
|
304
|
-
sub: [{
|
|
305
|
-
agent: connection.sessionAgent ?? "UniversalAgent",
|
|
306
|
-
name: chatId,
|
|
307
|
-
}],
|
|
308
|
-
});
|
|
309
|
-
const chatAgent = useMemo(
|
|
310
|
-
() => createSafeUIMessageAgentConnection(agent),
|
|
311
|
-
[agent],
|
|
312
|
-
);
|
|
313
|
-
|
|
613
|
+
const stopRevisionRef = useRef(0);
|
|
314
614
|
// 轮询→推送:这条 chat 连接连的是 UniversalAgent facet,`agent.state` 即 facet 的
|
|
315
615
|
// AgentState 广播(useAgent 内部 useState,收到 cf_agent_state 即 re-render)。待批项
|
|
316
616
|
// 随它推来 —— 审批读侧复用这条已开的连接,不另开第二条 WebSocket。三态(原则 VII):
|
|
@@ -326,27 +626,32 @@ export function useUniversalAgentChat(
|
|
|
326
626
|
isStreaming,
|
|
327
627
|
error,
|
|
328
628
|
clearError,
|
|
629
|
+
setMessages,
|
|
630
|
+
stop: stopChat,
|
|
329
631
|
// autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
|
|
330
632
|
// 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
|
|
331
633
|
isServerStreaming,
|
|
332
|
-
isRecovering,
|
|
634
|
+
isRecovering: sdkIsRecovering,
|
|
333
635
|
isToolContinuation,
|
|
334
|
-
connectionError,
|
|
636
|
+
connectionError: sdkConnectionError,
|
|
335
637
|
} = useAgentChat<RuntimeState, ChatMessage>({
|
|
336
638
|
agent: chatAgent,
|
|
337
|
-
getInitialMessages:
|
|
338
|
-
|
|
639
|
+
getInitialMessages: connection.skipInitialMessages
|
|
640
|
+
? null
|
|
641
|
+
: ({ url }) => loadInitialMessages(url, connection.credentials),
|
|
339
642
|
...(connection.credentials === undefined
|
|
340
643
|
? {}
|
|
341
644
|
: { credentials: connection.credentials }),
|
|
342
645
|
syncMessagesToServer: false,
|
|
343
|
-
throttle: 100,
|
|
344
646
|
});
|
|
647
|
+
// The SDK keeps its advisory recovery flag until terminal settlement; a
|
|
648
|
+
// live server stream proves the recovered Turn is executing again.
|
|
649
|
+
const isRecovering = sdkIsRecovering && !isServerStreaming;
|
|
345
650
|
const visibleMessages = useMemo(
|
|
346
651
|
() => mergeOptimisticMessages(messages, optimisticMessages),
|
|
347
652
|
[messages, optimisticMessages],
|
|
348
653
|
);
|
|
349
|
-
const
|
|
654
|
+
const reportedTurn = facetState?.turn;
|
|
350
655
|
const stoppedByUser = error?.message === USER_STOP_REASON;
|
|
351
656
|
const latestMessage = visibleMessages.at(-1);
|
|
352
657
|
const latestTurnStatus = latestMessage?.role === "assistant"
|
|
@@ -356,15 +661,40 @@ export function useUniversalAgentChat(
|
|
|
356
661
|
latestTurnStatus === "error" ||
|
|
357
662
|
latestTurnStatus === "aborted" ||
|
|
358
663
|
latestTurnStatus === "skipped";
|
|
664
|
+
// Messages and Agent state arrive in separate frames. A terminal message for
|
|
665
|
+
// the same Submission is newer than a still-active state projection; a
|
|
666
|
+
// different active ID belongs to the next Turn and must remain live.
|
|
667
|
+
const authoritativeTurn = reportedTurn &&
|
|
668
|
+
hasTerminalMessage &&
|
|
669
|
+
latestMessage?.metadata?.turnId === reportedTurn.activeSubmissionId
|
|
670
|
+
? {
|
|
671
|
+
steerable: false,
|
|
672
|
+
hasPendingSteer: false,
|
|
673
|
+
queued: reportedTurn.queued,
|
|
674
|
+
}
|
|
675
|
+
: reportedTurn;
|
|
359
676
|
const normalizedSdkStatus = sdkStatus === "error" &&
|
|
360
677
|
(stoppedByUser || hasTerminalMessage)
|
|
361
678
|
? "ready"
|
|
362
679
|
: sdkStatus;
|
|
363
680
|
const sdkError = stoppedByUser || hasTerminalMessage ? undefined : error;
|
|
681
|
+
const connectionError = sdkConnectionError ?? undefined;
|
|
682
|
+
const connectionStatus: ChatConnectionStatus = connectionError
|
|
683
|
+
? connectionError.code === 1008 ? "rejected" : "error"
|
|
684
|
+
: agent.identified ? "connected" : "connecting";
|
|
685
|
+
const runtimeError = dispatchError ?? sdkError ?? connectionError;
|
|
686
|
+
const runtimeErrorMessage = runtimeError instanceof Error
|
|
687
|
+
? runtimeError.message
|
|
688
|
+
: runtimeError;
|
|
689
|
+
const presentationError = isServerStreaming || isRecovering ||
|
|
690
|
+
(latestTurnStatus === "error" && latestMessage?.metadata?.error === runtimeErrorMessage)
|
|
691
|
+
? undefined
|
|
692
|
+
: runtimeError;
|
|
364
693
|
// RPC admissions bypass useChat's request lifecycle. Project the durable
|
|
365
694
|
// Turn here so presentation stays correct without a second send path.
|
|
366
|
-
const authoritativeStatus =
|
|
367
|
-
|
|
695
|
+
const authoritativeStatus = isServerStreaming
|
|
696
|
+
? "streaming"
|
|
697
|
+
: normalizedSdkStatus === "ready" &&
|
|
368
698
|
!isRecovering &&
|
|
369
699
|
approvals?.length === 0 &&
|
|
370
700
|
authoritativeTurn?.activeSubmissionId
|
|
@@ -372,13 +702,57 @@ export function useUniversalAgentChat(
|
|
|
372
702
|
? "streaming"
|
|
373
703
|
: "submitted"
|
|
374
704
|
: normalizedSdkStatus;
|
|
705
|
+
const hydratingTurnActive = facetState === undefined &&
|
|
706
|
+
connection.initialTurnActive === true;
|
|
375
707
|
const status = dispatchError
|
|
376
708
|
? "error"
|
|
377
|
-
: optimisticMessages.length > 0
|
|
709
|
+
: (optimisticMessages.length > 0 || hydratingTurnActive) &&
|
|
710
|
+
authoritativeStatus === "ready"
|
|
378
711
|
? "submitted"
|
|
379
712
|
: authoritativeStatus;
|
|
380
713
|
const messagesRef = useRef(messages);
|
|
381
714
|
messagesRef.current = messages;
|
|
715
|
+
// SDK callback identities can change without a new recovery request.
|
|
716
|
+
const replayActionsRef = useRef({ setMessages, clearError });
|
|
717
|
+
replayActionsRef.current = { setMessages, clearError };
|
|
718
|
+
useEffect(() => {
|
|
719
|
+
const activeMessageId = facetState?.turn?.activeMessageId;
|
|
720
|
+
if (
|
|
721
|
+
!activeMessageId ||
|
|
722
|
+
sdkStatus !== "error" ||
|
|
723
|
+
sdkIsRecovering ||
|
|
724
|
+
isServerStreaming
|
|
725
|
+
) return;
|
|
726
|
+
const url = chatAgent.getHttpUrl();
|
|
727
|
+
if (!url) return;
|
|
728
|
+
const baseline = messagesRef.current;
|
|
729
|
+
let cancelled = false;
|
|
730
|
+
void loadInitialMessages(url, connection.credentials, "full")
|
|
731
|
+
.then((snapshot) => {
|
|
732
|
+
if (cancelled) return;
|
|
733
|
+
// The SDK protects its own snapshots while streaming. This HTTP
|
|
734
|
+
// fallback must also reject a response older than the current store.
|
|
735
|
+
const actions = replayActionsRef.current;
|
|
736
|
+
let applied = false;
|
|
737
|
+
actions.setMessages((current) => {
|
|
738
|
+
if (current !== baseline) return current;
|
|
739
|
+
applied = true;
|
|
740
|
+
return snapshot;
|
|
741
|
+
});
|
|
742
|
+
if (applied) actions.clearError();
|
|
743
|
+
})
|
|
744
|
+
.catch(() => undefined);
|
|
745
|
+
return () => {
|
|
746
|
+
cancelled = true;
|
|
747
|
+
};
|
|
748
|
+
}, [
|
|
749
|
+
chatAgent,
|
|
750
|
+
connection.credentials,
|
|
751
|
+
facetState?.turn?.activeMessageId,
|
|
752
|
+
isServerStreaming,
|
|
753
|
+
sdkIsRecovering,
|
|
754
|
+
sdkStatus,
|
|
755
|
+
]);
|
|
382
756
|
const turnTimingRef = useRef<ChatTurnTiming | null>(null);
|
|
383
757
|
|
|
384
758
|
useEffect(() => {
|
|
@@ -386,7 +760,8 @@ export function useUniversalAgentChat(
|
|
|
386
760
|
if (!timing) return;
|
|
387
761
|
const assistant = messages.find((message) =>
|
|
388
762
|
message.role === "assistant" &&
|
|
389
|
-
!timing.baselineAssistantIds.has(message.id)
|
|
763
|
+
!timing.baselineAssistantIds.has(message.id) &&
|
|
764
|
+
(!timing.submissionId || message.metadata?.turnId === timing.submissionId)
|
|
390
765
|
);
|
|
391
766
|
if (assistant) markChatTurnPhase(timing, "streamStart");
|
|
392
767
|
if (
|
|
@@ -398,9 +773,18 @@ export function useUniversalAgentChat(
|
|
|
398
773
|
}
|
|
399
774
|
if (status === "ready" && timing.streamStart !== undefined) {
|
|
400
775
|
if (timing.firstText !== undefined) markChatTurnPhase(timing, "ready");
|
|
776
|
+
else timing.ready ??= now();
|
|
401
777
|
turnTimingRef.current = null;
|
|
778
|
+
const metrics = clientPerformanceMetrics(timing);
|
|
779
|
+
if (connection.performanceRpc && timing.submissionId && metrics) {
|
|
780
|
+
void agentRef.current.call(connection.performanceRpc, [{
|
|
781
|
+
submissionId: timing.submissionId,
|
|
782
|
+
messageId: timing.messageId,
|
|
783
|
+
...metrics,
|
|
784
|
+
}]).catch(() => {});
|
|
785
|
+
}
|
|
402
786
|
}
|
|
403
|
-
}, [messages, status]);
|
|
787
|
+
}, [connection.performanceRpc, messages, status]);
|
|
404
788
|
|
|
405
789
|
useEffect(() => {
|
|
406
790
|
if (optimisticMessages.length === 0 || messages.length === 0) return;
|
|
@@ -466,7 +850,8 @@ export function useUniversalAgentChat(
|
|
|
466
850
|
isToolContinuation;
|
|
467
851
|
const pendingLocalTurn = optimisticMessages.length > 0 &&
|
|
468
852
|
(status === "submitted" || status === "streaming");
|
|
469
|
-
const turnActive =
|
|
853
|
+
const turnActive = hydratingTurnActive ||
|
|
854
|
+
Boolean(turn?.activeSubmissionId) ||
|
|
470
855
|
pendingLocalTurn ||
|
|
471
856
|
(authoritativeTurn === undefined && sdkTurnActive);
|
|
472
857
|
const canSteer = turnActive &&
|
|
@@ -485,17 +870,56 @@ export function useUniversalAgentChat(
|
|
|
485
870
|
const dispatchMessage = useCallback(async (
|
|
486
871
|
message: ChatMessage,
|
|
487
872
|
delivery: MessageDelivery,
|
|
488
|
-
): Promise<MessageDispatchReceipt> =>
|
|
489
|
-
agentRef.current.call<MessageDispatchReceipt>(
|
|
873
|
+
): Promise<MessageDispatchReceipt> => {
|
|
874
|
+
const dispatch = () => agentRef.current.call<MessageDispatchReceipt>(
|
|
490
875
|
"dispatchMessage",
|
|
491
876
|
[message, delivery],
|
|
492
|
-
)
|
|
877
|
+
);
|
|
878
|
+
try {
|
|
879
|
+
return await dispatch();
|
|
880
|
+
} catch (error) {
|
|
881
|
+
// The server deduplicates dispatches by message.id, so replay only the
|
|
882
|
+
// transport failures that mean admission may already have succeeded.
|
|
883
|
+
const retryable = error instanceof Error && (
|
|
884
|
+
/^RPC call to dispatchMessage timed out after \d+ms$/u.test(error.message) ||
|
|
885
|
+
(error.message === "Connection closed" && agentRef.current.shouldReconnect)
|
|
886
|
+
);
|
|
887
|
+
if (!retryable) throw error;
|
|
888
|
+
await agentRef.current.ready;
|
|
889
|
+
return dispatch();
|
|
890
|
+
}
|
|
891
|
+
}, []);
|
|
493
892
|
|
|
494
893
|
const submitMessage = useCallback(async (
|
|
495
894
|
message: ChatMessage,
|
|
496
895
|
delivery: MessageDelivery,
|
|
497
896
|
projection: "message" | "queue",
|
|
498
897
|
): Promise<boolean> => {
|
|
898
|
+
if (projection === "message" && delivery === "steer") {
|
|
899
|
+
const activeSubmissionId = authoritativeTurn?.activeSubmissionId;
|
|
900
|
+
const activeAssistants = messagesRef.current.filter((candidate) =>
|
|
901
|
+
candidate.role === "assistant" &&
|
|
902
|
+
candidate.metadata?.turnId === activeSubmissionId
|
|
903
|
+
);
|
|
904
|
+
const assistantMessageId = activeAssistants[0]?.id ??
|
|
905
|
+
authoritativeTurn?.activeMessageId;
|
|
906
|
+
if (assistantMessageId) {
|
|
907
|
+
message = {
|
|
908
|
+
...message,
|
|
909
|
+
metadata: {
|
|
910
|
+
...message.metadata,
|
|
911
|
+
optimisticSteer: {
|
|
912
|
+
assistantMessageId,
|
|
913
|
+
afterStep: activeAssistants.reduce((total, assistant) =>
|
|
914
|
+
total + assistant.parts.filter(
|
|
915
|
+
({ type }) => type === "step-start",
|
|
916
|
+
).length, 0),
|
|
917
|
+
},
|
|
918
|
+
},
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
const stopRevision = stopRevisionRef.current;
|
|
499
923
|
setDispatchError(undefined);
|
|
500
924
|
clearError();
|
|
501
925
|
failedDispatchRef.current = undefined;
|
|
@@ -513,7 +937,6 @@ export function useUniversalAgentChat(
|
|
|
513
937
|
};
|
|
514
938
|
}
|
|
515
939
|
if (projection === "message") {
|
|
516
|
-
activeSubmissionIdRef.current = undefined;
|
|
517
940
|
setOptimisticMessages((current) =>
|
|
518
941
|
current.some(({ id }) => id === message.id)
|
|
519
942
|
? current
|
|
@@ -548,7 +971,7 @@ export function useUniversalAgentChat(
|
|
|
548
971
|
markChatTurnPhase(turnTimingRef.current, "rpcReceipt");
|
|
549
972
|
}
|
|
550
973
|
if (receipt.kind === "rejected") {
|
|
551
|
-
|
|
974
|
+
rollback();
|
|
552
975
|
if (turnTimingRef.current?.messageId === message.id) {
|
|
553
976
|
turnTimingRef.current = null;
|
|
554
977
|
}
|
|
@@ -558,9 +981,49 @@ export function useUniversalAgentChat(
|
|
|
558
981
|
return false;
|
|
559
982
|
}
|
|
560
983
|
if (projection === "message") {
|
|
561
|
-
|
|
984
|
+
const submissionId = receipt.kind === "queued"
|
|
562
985
|
? receipt.submission.submissionId
|
|
563
986
|
: receipt.submissionId;
|
|
987
|
+
if (stopRevision !== stopRevisionRef.current) {
|
|
988
|
+
await agentRef.current.call<{ ok: boolean }>(
|
|
989
|
+
"cancelSubmissionById",
|
|
990
|
+
[submissionId, USER_STOP_REASON],
|
|
991
|
+
);
|
|
992
|
+
rollback();
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
if (
|
|
996
|
+
delivery === "steer" && receipt.kind === "accepted" &&
|
|
997
|
+
!message.metadata?.optimisticSteer
|
|
998
|
+
) {
|
|
999
|
+
const activeAssistant = messagesRef.current.findLast((candidate) =>
|
|
1000
|
+
candidate.role === "assistant" &&
|
|
1001
|
+
candidate.metadata?.turnId === submissionId
|
|
1002
|
+
);
|
|
1003
|
+
const assistantMessageId = activeAssistant?.id ??
|
|
1004
|
+
(authoritativeTurn?.activeSubmissionId === submissionId
|
|
1005
|
+
? authoritativeTurn.activeMessageId
|
|
1006
|
+
: undefined);
|
|
1007
|
+
if (assistantMessageId) {
|
|
1008
|
+
const afterStep = activeAssistant?.parts.filter(
|
|
1009
|
+
({ type }) => type === "step-start",
|
|
1010
|
+
).length ?? 0;
|
|
1011
|
+
setOptimisticMessages((current) => current.map((optimistic) =>
|
|
1012
|
+
optimistic.id === message.id
|
|
1013
|
+
? {
|
|
1014
|
+
...optimistic,
|
|
1015
|
+
metadata: {
|
|
1016
|
+
...optimistic.metadata,
|
|
1017
|
+
optimisticSteer: { assistantMessageId, afterStep },
|
|
1018
|
+
},
|
|
1019
|
+
}
|
|
1020
|
+
: optimistic
|
|
1021
|
+
));
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (turnTimingRef.current?.messageId === message.id) {
|
|
1025
|
+
turnTimingRef.current.submissionId = submissionId;
|
|
1026
|
+
}
|
|
564
1027
|
}
|
|
565
1028
|
if (projection === "queue") {
|
|
566
1029
|
if (receipt.kind !== "queued" || receipt.position < 1) {
|
|
@@ -579,7 +1042,7 @@ export function useUniversalAgentChat(
|
|
|
579
1042
|
}
|
|
580
1043
|
return true;
|
|
581
1044
|
} catch (cause) {
|
|
582
|
-
|
|
1045
|
+
rollback();
|
|
583
1046
|
if (turnTimingRef.current?.messageId === message.id) {
|
|
584
1047
|
turnTimingRef.current = null;
|
|
585
1048
|
}
|
|
@@ -590,14 +1053,21 @@ export function useUniversalAgentChat(
|
|
|
590
1053
|
);
|
|
591
1054
|
return false;
|
|
592
1055
|
}
|
|
593
|
-
}, [
|
|
1056
|
+
}, [
|
|
1057
|
+
authoritativeTurn?.activeMessageId,
|
|
1058
|
+
authoritativeTurn?.activeSubmissionId,
|
|
1059
|
+
chatId,
|
|
1060
|
+
clearError,
|
|
1061
|
+
dispatchMessage,
|
|
1062
|
+
]);
|
|
594
1063
|
|
|
595
1064
|
const sendText = useCallback(async (
|
|
596
1065
|
text: string,
|
|
597
1066
|
files?: readonly FileUIPart[],
|
|
598
1067
|
capabilities?: readonly RequestedCapability[],
|
|
1068
|
+
messageId?: string,
|
|
599
1069
|
): Promise<boolean> => {
|
|
600
|
-
const message = createChatMessage(text, files, capabilities);
|
|
1070
|
+
const message = createChatMessage(text, files, capabilities, messageId);
|
|
601
1071
|
return message
|
|
602
1072
|
? submitMessage(message, "enqueue", "message")
|
|
603
1073
|
: false;
|
|
@@ -678,29 +1148,49 @@ export function useUniversalAgentChat(
|
|
|
678
1148
|
}, []);
|
|
679
1149
|
|
|
680
1150
|
const stop = useCallback(async () => {
|
|
1151
|
+
stopRevisionRef.current += 1;
|
|
681
1152
|
const requestId = authoritativeTurn?.activeRequestId;
|
|
682
|
-
const submissionId = authoritativeTurn?.activeSubmissionId
|
|
683
|
-
|
|
1153
|
+
const submissionId = authoritativeTurn?.activeSubmissionId;
|
|
1154
|
+
const beforeStop = messagesRef.current;
|
|
1155
|
+
const userIndex = beforeStop.findLastIndex(({ role }) => role === "user");
|
|
1156
|
+
const assistantIndex = beforeStop.findLastIndex(
|
|
1157
|
+
({ role }, index) => index > userIndex && role === "assistant",
|
|
1158
|
+
);
|
|
1159
|
+
const marker = {
|
|
1160
|
+
completedAt: Date.now(),
|
|
1161
|
+
interruptedByUser: true,
|
|
1162
|
+
turnStatus: "aborted",
|
|
1163
|
+
} as const;
|
|
1164
|
+
setMessages(assistantIndex < 0
|
|
1165
|
+
? [...beforeStop, {
|
|
1166
|
+
id: `optimistic-stop:${submissionId ?? requestId ?? crypto.randomUUID()}`,
|
|
1167
|
+
role: "assistant",
|
|
1168
|
+
metadata: marker,
|
|
1169
|
+
parts: [],
|
|
1170
|
+
}]
|
|
1171
|
+
: beforeStop.map((message, index) =>
|
|
1172
|
+
index === assistantIndex
|
|
1173
|
+
? { ...message, metadata: { ...message.metadata, ...marker } }
|
|
1174
|
+
: message
|
|
1175
|
+
));
|
|
1176
|
+
const rollback = () => setMessages(beforeStop);
|
|
684
1177
|
setDispatchError(undefined);
|
|
685
1178
|
try {
|
|
1179
|
+
await agentRef.current.call<{ ok: boolean }>(
|
|
1180
|
+
"stopAllSubmissions",
|
|
1181
|
+
[USER_STOP_REASON],
|
|
1182
|
+
);
|
|
1183
|
+
setOptimisticMessages([]);
|
|
1184
|
+
setOptimisticQueued([]);
|
|
686
1185
|
if (requestId) {
|
|
687
1186
|
agentRef.current.send(JSON.stringify({
|
|
688
1187
|
type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL,
|
|
689
1188
|
id: requestId,
|
|
690
1189
|
}));
|
|
691
|
-
|
|
1190
|
+
await stopChat();
|
|
692
1191
|
}
|
|
693
|
-
const result = submissionId
|
|
694
|
-
? await agentRef.current.call<{ ok: boolean }>(
|
|
695
|
-
"cancelSubmissionById",
|
|
696
|
-
[submissionId, USER_STOP_REASON],
|
|
697
|
-
)
|
|
698
|
-
: await agentRef.current.call<{ ok: boolean }>(
|
|
699
|
-
"stopTurn",
|
|
700
|
-
[undefined, USER_STOP_REASON],
|
|
701
|
-
);
|
|
702
|
-
if (!result.ok) setDispatchError("The active Turn could not be stopped");
|
|
703
1192
|
} catch (cause) {
|
|
1193
|
+
rollback();
|
|
704
1194
|
setDispatchError(
|
|
705
1195
|
cause instanceof Error ? cause.message : String(cause),
|
|
706
1196
|
);
|
|
@@ -708,6 +1198,7 @@ export function useUniversalAgentChat(
|
|
|
708
1198
|
}, [
|
|
709
1199
|
authoritativeTurn?.activeRequestId,
|
|
710
1200
|
authoritativeTurn?.activeSubmissionId,
|
|
1201
|
+
stopChat,
|
|
711
1202
|
]);
|
|
712
1203
|
|
|
713
1204
|
const retry = useCallback(() => {
|
|
@@ -736,6 +1227,14 @@ export function useUniversalAgentChat(
|
|
|
736
1227
|
return false;
|
|
737
1228
|
}
|
|
738
1229
|
}, []);
|
|
1230
|
+
const decideApproval = useCallback(
|
|
1231
|
+
(executionId: string, decision: ApprovalDecision) =>
|
|
1232
|
+
agentRef.current.call<{ ok: boolean }>(
|
|
1233
|
+
"decideApproval",
|
|
1234
|
+
[executionId, decision],
|
|
1235
|
+
),
|
|
1236
|
+
[],
|
|
1237
|
+
);
|
|
739
1238
|
|
|
740
1239
|
// 只读控制面:Runtime 尚未装配时由 Agent 侧 `ensureRuntimeReady` 负责等待,
|
|
741
1240
|
// 这里不缓存结果 —— 换模型、加技能或 reload 之后调用方要拿到的是新装配。
|
|
@@ -743,6 +1242,24 @@ export function useUniversalAgentChat(
|
|
|
743
1242
|
() => agentRef.current.call<RuntimeAssemblyView>("getRuntimeAssembly", []),
|
|
744
1243
|
[],
|
|
745
1244
|
);
|
|
1245
|
+
const getRuntimeBinding = useCallback(
|
|
1246
|
+
<Binding,>() => agentRef.current.call<Binding>("getRuntimeBinding", []),
|
|
1247
|
+
[],
|
|
1248
|
+
);
|
|
1249
|
+
const readChatMetadata = useCallback(
|
|
1250
|
+
<Metadata,>() => agentRef.current.call<Metadata>("readChatMetadata", []),
|
|
1251
|
+
[],
|
|
1252
|
+
);
|
|
1253
|
+
const renameChat = useCallback(
|
|
1254
|
+
<Metadata,>(title: string) =>
|
|
1255
|
+
agentRef.current.call<Metadata>("renameChat", [title]),
|
|
1256
|
+
[],
|
|
1257
|
+
);
|
|
1258
|
+
const rebindRuntime = useCallback(
|
|
1259
|
+
<Input, Result,>(input: Input) =>
|
|
1260
|
+
agentRef.current.call<Result>("rebindRuntime", [input]),
|
|
1261
|
+
[],
|
|
1262
|
+
);
|
|
746
1263
|
const updateConfig = useCallback(
|
|
747
1264
|
<Change,>(command: unknown) =>
|
|
748
1265
|
agentRef.current.call<RuntimeConfigUpdateResult<Change>>(
|
|
@@ -761,15 +1278,21 @@ export function useUniversalAgentChat(
|
|
|
761
1278
|
|
|
762
1279
|
return {
|
|
763
1280
|
respondToolInteraction,
|
|
1281
|
+
decideApproval,
|
|
764
1282
|
getRuntimeAssembly,
|
|
1283
|
+
getRuntimeBinding,
|
|
1284
|
+
readChatMetadata,
|
|
1285
|
+
renameChat,
|
|
1286
|
+
rebindRuntime,
|
|
765
1287
|
updateConfig,
|
|
766
1288
|
reloadRuntime,
|
|
767
1289
|
messages: visibleMessages,
|
|
768
1290
|
status,
|
|
1291
|
+
connectionStatus,
|
|
1292
|
+
connectionError,
|
|
769
1293
|
runtimeLoad: facetState?.runtimeLoad,
|
|
770
1294
|
isStreaming,
|
|
771
|
-
error:
|
|
772
|
-
connectionError ?? undefined,
|
|
1295
|
+
error: presentationError,
|
|
773
1296
|
sendText,
|
|
774
1297
|
steerText,
|
|
775
1298
|
enqueueText,
|