@springbrand/chat-client 0.1.3-alpha.8 → 0.3.0-alpha.1
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 +601 -84
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.1",
|
|
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.11.0",
|
|
17
|
+
"agents": "^0.22.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.11.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.1"
|
|
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,8 +168,15 @@ 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 & (
|
|
@@ -154,9 +184,61 @@ export type ChatConnectionOptions = SharedChatConnectionOptions & (
|
|
|
154
184
|
| { inboxName?: never; basePath?: string }
|
|
155
185
|
);
|
|
156
186
|
|
|
187
|
+
type SessionAgentConnection = ReturnType<typeof useAgent<RuntimeState>>;
|
|
188
|
+
type UniversalAgentChatContextValue = {
|
|
189
|
+
chatId: string;
|
|
190
|
+
connection: ChatConnectionOptions;
|
|
191
|
+
agent: SessionAgentConnection;
|
|
192
|
+
chatAgent: SessionAgentConnection;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const UniversalAgentChatContext = createContext<UniversalAgentChatContextValue | null>(null);
|
|
196
|
+
|
|
197
|
+
export function UniversalAgentChatProvider({
|
|
198
|
+
chatId,
|
|
199
|
+
connection = {},
|
|
200
|
+
fallback = null,
|
|
201
|
+
children,
|
|
202
|
+
}: {
|
|
203
|
+
chatId: string;
|
|
204
|
+
connection?: ChatConnectionOptions;
|
|
205
|
+
fallback?: ReactNode;
|
|
206
|
+
children: ReactNode;
|
|
207
|
+
}) {
|
|
208
|
+
const inboxAgent = connection.inboxAgent ?? "Inbox";
|
|
209
|
+
const agent = useAgent<RuntimeState>({
|
|
210
|
+
agent: inboxAgent,
|
|
211
|
+
...(connection.inboxName === undefined
|
|
212
|
+
? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
|
|
213
|
+
: { name: connection.inboxName }),
|
|
214
|
+
...(connection.host === undefined ? {} : { host: connection.host }),
|
|
215
|
+
...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
|
|
216
|
+
...(connection.onMessage === undefined ? {} : { onMessage: connection.onMessage }),
|
|
217
|
+
connectionTimeout: 6_000,
|
|
218
|
+
sub: [{
|
|
219
|
+
agent: connection.sessionAgent ?? "UniversalAgent",
|
|
220
|
+
name: chatId,
|
|
221
|
+
}],
|
|
222
|
+
});
|
|
223
|
+
const chatAgent = useMemo(
|
|
224
|
+
() => createSafeUIMessageAgentConnection(agent),
|
|
225
|
+
[agent],
|
|
226
|
+
);
|
|
227
|
+
const value = useMemo(
|
|
228
|
+
() => ({ chatId, connection, agent, chatAgent }),
|
|
229
|
+
[agent, chatAgent, chatId, connection],
|
|
230
|
+
);
|
|
231
|
+
return createElement(
|
|
232
|
+
UniversalAgentChatContext.Provider,
|
|
233
|
+
{ value },
|
|
234
|
+
createElement(Suspense, { fallback }, children),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
157
238
|
type ChatTurnTiming = {
|
|
158
239
|
chatId: string;
|
|
159
240
|
messageId: string;
|
|
241
|
+
submissionId?: string;
|
|
160
242
|
baselineAssistantIds: Set<string>;
|
|
161
243
|
click: number;
|
|
162
244
|
rpcReceipt?: number;
|
|
@@ -165,6 +247,37 @@ type ChatTurnTiming = {
|
|
|
165
247
|
ready?: number;
|
|
166
248
|
};
|
|
167
249
|
|
|
250
|
+
type ClientPerformanceMetrics = {
|
|
251
|
+
clickToRpcMs: number;
|
|
252
|
+
rpcToStreamMs: number;
|
|
253
|
+
streamToFirstTextMs?: number;
|
|
254
|
+
firstTextToReadyMs?: number;
|
|
255
|
+
ttftMs?: number;
|
|
256
|
+
totalMs: number;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export function clientPerformanceMetrics(
|
|
260
|
+
timing: ChatTurnTiming,
|
|
261
|
+
): ClientPerformanceMetrics | null {
|
|
262
|
+
if (
|
|
263
|
+
timing.rpcReceipt === undefined ||
|
|
264
|
+
timing.streamStart === undefined ||
|
|
265
|
+
timing.ready === undefined
|
|
266
|
+
) return null;
|
|
267
|
+
return {
|
|
268
|
+
clickToRpcMs: timing.rpcReceipt - timing.click,
|
|
269
|
+
rpcToStreamMs: timing.streamStart - timing.rpcReceipt,
|
|
270
|
+
...(timing.firstText === undefined
|
|
271
|
+
? {}
|
|
272
|
+
: {
|
|
273
|
+
streamToFirstTextMs: timing.firstText - timing.streamStart,
|
|
274
|
+
firstTextToReadyMs: timing.ready - timing.firstText,
|
|
275
|
+
ttftMs: timing.firstText - timing.click,
|
|
276
|
+
}),
|
|
277
|
+
totalMs: timing.ready - timing.click,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
168
281
|
const CHAT_TURN_SEGMENTS = {
|
|
169
282
|
rpcReceipt: ["click", "universal-agent.chat.click_to_rpc_receipt"],
|
|
170
283
|
streamStart: [
|
|
@@ -210,6 +323,7 @@ function createChatMessage(
|
|
|
210
323
|
text: string,
|
|
211
324
|
files?: readonly FileUIPart[],
|
|
212
325
|
capabilities: readonly RequestedCapability[] = [],
|
|
326
|
+
messageId?: string,
|
|
213
327
|
): ChatMessage | null {
|
|
214
328
|
const normalizedText = text.trim();
|
|
215
329
|
if (!normalizedText && !files?.length) return null;
|
|
@@ -222,7 +336,7 @@ function createChatMessage(
|
|
|
222
336
|
return true;
|
|
223
337
|
});
|
|
224
338
|
return {
|
|
225
|
-
id: nanoid(),
|
|
339
|
+
id: messageId ?? nanoid(),
|
|
226
340
|
role: "user",
|
|
227
341
|
parts: [
|
|
228
342
|
...(files ?? []),
|
|
@@ -243,10 +357,204 @@ export function mergeOptimisticMessages<T extends UIMessage>(
|
|
|
243
357
|
messages: T[],
|
|
244
358
|
optimistic: T[],
|
|
245
359
|
): T[] {
|
|
246
|
-
if (optimistic.length === 0) return messages;
|
|
247
360
|
const ids = new Set(messages.map(({ id }) => id));
|
|
248
361
|
const pending = optimistic.filter(({ id }) => !ids.has(id));
|
|
249
|
-
|
|
362
|
+
const assistantsByTurn = new Map<string, T[]>();
|
|
363
|
+
for (const message of messages) {
|
|
364
|
+
const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
|
|
365
|
+
if (message.role !== "assistant" || typeof turnId !== "string") continue;
|
|
366
|
+
const assistants = assistantsByTurn.get(turnId) ?? [];
|
|
367
|
+
assistants.push(message);
|
|
368
|
+
assistantsByTurn.set(turnId, assistants);
|
|
369
|
+
}
|
|
370
|
+
const assistantPosition = new Map<string, {
|
|
371
|
+
assistantMessageId: string;
|
|
372
|
+
stepOffset: number;
|
|
373
|
+
}>();
|
|
374
|
+
for (const assistants of assistantsByTurn.values()) {
|
|
375
|
+
let stepOffset = 0;
|
|
376
|
+
for (const assistant of assistants) {
|
|
377
|
+
assistantPosition.set(assistant.id, {
|
|
378
|
+
assistantMessageId: assistants[0]!.id,
|
|
379
|
+
stepOffset,
|
|
380
|
+
});
|
|
381
|
+
stepOffset += assistant.parts.filter(
|
|
382
|
+
({ type }) => type === "step-start",
|
|
383
|
+
).length;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const normalizeAnchor = (anchor: NonNullable<
|
|
387
|
+
ChatMessageMetadata["optimisticSteer"]
|
|
388
|
+
>) => {
|
|
389
|
+
const position = assistantPosition.get(anchor.assistantMessageId);
|
|
390
|
+
return position
|
|
391
|
+
? {
|
|
392
|
+
assistantMessageId: position.assistantMessageId,
|
|
393
|
+
afterStep: position.stepOffset + anchor.afterStep,
|
|
394
|
+
}
|
|
395
|
+
: anchor;
|
|
396
|
+
};
|
|
397
|
+
const logicalMessages: T[] = [];
|
|
398
|
+
const logicalAssistantIndex = new Map<string, number>();
|
|
399
|
+
for (const message of messages) {
|
|
400
|
+
const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
|
|
401
|
+
if (message.role !== "assistant" || typeof turnId !== "string") {
|
|
402
|
+
logicalMessages.push(message);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const existingIndex = logicalAssistantIndex.get(turnId);
|
|
406
|
+
if (existingIndex === undefined) {
|
|
407
|
+
logicalAssistantIndex.set(turnId, logicalMessages.length);
|
|
408
|
+
logicalMessages.push(message);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const existing = logicalMessages[existingIndex]!;
|
|
412
|
+
logicalMessages[existingIndex] = {
|
|
413
|
+
...existing,
|
|
414
|
+
parts: [...existing.parts, ...message.parts],
|
|
415
|
+
metadata: {
|
|
416
|
+
...(existing.metadata as Record<string, unknown> | undefined),
|
|
417
|
+
...(message.metadata as Record<string, unknown> | undefined),
|
|
418
|
+
},
|
|
419
|
+
} as T;
|
|
420
|
+
}
|
|
421
|
+
const authoritativeAnchors = messages.flatMap((message, messageIndex) => {
|
|
422
|
+
const anchor = (message.metadata as ChatMessageMetadata | undefined)
|
|
423
|
+
?.optimisticSteer;
|
|
424
|
+
if (anchor) return [{ message, anchor: normalizeAnchor(anchor) }];
|
|
425
|
+
if (message.role !== "user") return [];
|
|
426
|
+
const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
|
|
427
|
+
const assistants = typeof turnId === "string"
|
|
428
|
+
? assistantsByTurn.get(turnId)
|
|
429
|
+
: undefined;
|
|
430
|
+
if (!assistants || assistants.length === 0) return [];
|
|
431
|
+
const openingMessageId = (assistants[0]!.metadata as
|
|
432
|
+
| ChatMessageMetadata
|
|
433
|
+
| undefined)?.userMessageId;
|
|
434
|
+
if (message.id === openingMessageId) return [];
|
|
435
|
+
const preceding = messages.slice(0, messageIndex).filter((candidate) =>
|
|
436
|
+
candidate.role === "assistant" &&
|
|
437
|
+
(candidate.metadata as ChatMessageMetadata | undefined)?.turnId === turnId
|
|
438
|
+
);
|
|
439
|
+
return [{
|
|
440
|
+
message,
|
|
441
|
+
anchor: {
|
|
442
|
+
assistantMessageId: assistants[0]!.id,
|
|
443
|
+
afterStep: preceding.reduce((total, assistant) =>
|
|
444
|
+
total + assistant.parts.filter(
|
|
445
|
+
({ type }) => type === "step-start",
|
|
446
|
+
).length, 0),
|
|
447
|
+
},
|
|
448
|
+
}];
|
|
449
|
+
});
|
|
450
|
+
const pendingAnchors = pending.flatMap((message) => {
|
|
451
|
+
const anchor = (message.metadata as ChatMessageMetadata | undefined)
|
|
452
|
+
?.optimisticSteer;
|
|
453
|
+
return anchor ? [{ message, anchor: normalizeAnchor(anchor) }] : [];
|
|
454
|
+
});
|
|
455
|
+
const anchoredSteers = [...authoritativeAnchors, ...pendingAnchors];
|
|
456
|
+
if (pending.length === 0 && anchoredSteers.length === 0) return messages;
|
|
457
|
+
const anchoredIds = new Set(
|
|
458
|
+
anchoredSteers.map(({ message }) => message.id),
|
|
459
|
+
);
|
|
460
|
+
const pendingById = new Map(
|
|
461
|
+
pending
|
|
462
|
+
.filter(({ id }) => !anchoredIds.has(id))
|
|
463
|
+
.map((message) => [message.id, message]),
|
|
464
|
+
);
|
|
465
|
+
const withPrompts = logicalMessages
|
|
466
|
+
.filter(({ id }) => !anchoredIds.has(id))
|
|
467
|
+
.flatMap((message) => {
|
|
468
|
+
const userMessageId = message.role === "assistant"
|
|
469
|
+
? (message.metadata as ChatMessageMetadata | undefined)?.userMessageId
|
|
470
|
+
: undefined;
|
|
471
|
+
if (typeof userMessageId !== "string") return [message];
|
|
472
|
+
const prompt = pendingById.get(userMessageId);
|
|
473
|
+
if (!prompt) return [message];
|
|
474
|
+
pendingById.delete(userMessageId);
|
|
475
|
+
return [prompt, message];
|
|
476
|
+
});
|
|
477
|
+
const unmatchedPrompts = pending.filter(({ id }) => pendingById.has(id));
|
|
478
|
+
if (anchoredSteers.length === 0) {
|
|
479
|
+
return [
|
|
480
|
+
...withPrompts,
|
|
481
|
+
...unmatchedPrompts,
|
|
482
|
+
];
|
|
483
|
+
}
|
|
484
|
+
const targetAssistantIds = new Set(
|
|
485
|
+
anchoredSteers.map(({ anchor }) => anchor.assistantMessageId),
|
|
486
|
+
);
|
|
487
|
+
const firstTarget = withPrompts.findIndex(({ id, role }) =>
|
|
488
|
+
role === "assistant" && targetAssistantIds.has(id)
|
|
489
|
+
);
|
|
490
|
+
const base = firstTarget < 0
|
|
491
|
+
? [...withPrompts, ...unmatchedPrompts]
|
|
492
|
+
: [
|
|
493
|
+
...withPrompts.slice(0, firstTarget),
|
|
494
|
+
...unmatchedPrompts,
|
|
495
|
+
...withPrompts.slice(firstTarget),
|
|
496
|
+
];
|
|
497
|
+
const byAssistant = new Map<string, typeof anchoredSteers>();
|
|
498
|
+
for (const anchored of anchoredSteers) {
|
|
499
|
+
const group = byAssistant.get(anchored.anchor.assistantMessageId) ?? [];
|
|
500
|
+
group.push(anchored);
|
|
501
|
+
byAssistant.set(anchored.anchor.assistantMessageId, group);
|
|
502
|
+
}
|
|
503
|
+
const placed = new Set<string>();
|
|
504
|
+
const merged = base.flatMap((message) => {
|
|
505
|
+
if (message.role !== "assistant") return [message];
|
|
506
|
+
const steers = byAssistant.get(message.id);
|
|
507
|
+
if (!steers) return [message];
|
|
508
|
+
|
|
509
|
+
const projected: T[] = [];
|
|
510
|
+
const stepStarts = message.parts.flatMap((part, index) =>
|
|
511
|
+
part.type === "step-start" ? [index] : []
|
|
512
|
+
);
|
|
513
|
+
let cursor = 0;
|
|
514
|
+
let minimumBoundary = 1;
|
|
515
|
+
for (let index = 0; index < steers.length;) {
|
|
516
|
+
const boundary = Math.max(
|
|
517
|
+
minimumBoundary,
|
|
518
|
+
steers[index]!.anchor.afterStep,
|
|
519
|
+
);
|
|
520
|
+
minimumBoundary = boundary;
|
|
521
|
+
let end = index + 1;
|
|
522
|
+
while (
|
|
523
|
+
end < steers.length &&
|
|
524
|
+
steers[end]!.anchor.afterStep <= boundary
|
|
525
|
+
) {
|
|
526
|
+
end += 1;
|
|
527
|
+
}
|
|
528
|
+
const segmentEnd = stepStarts[boundary] ?? message.parts.length;
|
|
529
|
+
if (segmentEnd > cursor) {
|
|
530
|
+
projected.push({
|
|
531
|
+
...message,
|
|
532
|
+
id: `${message.id}:segment:${projected.length}`,
|
|
533
|
+
parts: message.parts.slice(cursor, segmentEnd),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
for (const anchored of steers.slice(index, end)) {
|
|
537
|
+
projected.push(anchored.message);
|
|
538
|
+
placed.add(anchored.message.id);
|
|
539
|
+
}
|
|
540
|
+
cursor = segmentEnd;
|
|
541
|
+
index = end;
|
|
542
|
+
}
|
|
543
|
+
if (cursor < message.parts.length) {
|
|
544
|
+
projected.push({
|
|
545
|
+
...message,
|
|
546
|
+
id: `${message.id}:segment:${projected.length}`,
|
|
547
|
+
parts: message.parts.slice(cursor),
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return projected;
|
|
551
|
+
});
|
|
552
|
+
return [
|
|
553
|
+
...merged,
|
|
554
|
+
...anchoredSteers
|
|
555
|
+
.filter(({ message }) => !placed.has(message.id))
|
|
556
|
+
.map(({ message }) => message),
|
|
557
|
+
];
|
|
250
558
|
}
|
|
251
559
|
|
|
252
560
|
function queuedPreview(message: ChatMessage): string {
|
|
@@ -260,10 +568,12 @@ function queuedPreview(message: ChatMessage): string {
|
|
|
260
568
|
function loadInitialMessages(
|
|
261
569
|
url: string | undefined,
|
|
262
570
|
credentials: RequestCredentials | undefined,
|
|
571
|
+
view: "hydration" | "full" = "hydration",
|
|
263
572
|
): Promise<ChatMessage[]> {
|
|
264
573
|
if (!url) return Promise.resolve([]);
|
|
265
574
|
const messagesUrl = new URL(url);
|
|
266
575
|
messagesUrl.pathname = `${messagesUrl.pathname.replace(/\/$/, "")}/get-messages`;
|
|
576
|
+
if (view === "hydration") messagesUrl.searchParams.set("view", "hydration");
|
|
267
577
|
return getAgentMessages({
|
|
268
578
|
url: messagesUrl.toString(),
|
|
269
579
|
...(credentials === undefined ? {} : { credentials }),
|
|
@@ -281,10 +591,12 @@ function loadInitialMessages(
|
|
|
281
591
|
* sub 数组由客户端 kebab 化;服务端按 ctx.exports 反解回 CamelCase className,
|
|
282
592
|
* 与 Inbox.onBeforeSubAgent 的严格门卫(hasSubAgent)对齐。
|
|
283
593
|
*/
|
|
284
|
-
export function useUniversalAgentChat(
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
594
|
+
export function useUniversalAgentChat(): ChatRuntime {
|
|
595
|
+
const context = useContext(UniversalAgentChatContext);
|
|
596
|
+
if (!context) {
|
|
597
|
+
throw new Error("useUniversalAgentChat must be used within UniversalAgentChatProvider");
|
|
598
|
+
}
|
|
599
|
+
const { chatId, connection, agent, chatAgent } = context;
|
|
288
600
|
const [optimisticMessages, setOptimisticMessages] =
|
|
289
601
|
useState<ChatMessage[]>([]);
|
|
290
602
|
const [optimisticQueued, setOptimisticQueued] =
|
|
@@ -292,25 +604,7 @@ export function useUniversalAgentChat(
|
|
|
292
604
|
const [dispatchError, setDispatchError] = useState<string>();
|
|
293
605
|
const [canRetry, setCanRetry] = useState(false);
|
|
294
606
|
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
|
-
|
|
607
|
+
const stopRevisionRef = useRef(0);
|
|
314
608
|
// 轮询→推送:这条 chat 连接连的是 UniversalAgent facet,`agent.state` 即 facet 的
|
|
315
609
|
// AgentState 广播(useAgent 内部 useState,收到 cf_agent_state 即 re-render)。待批项
|
|
316
610
|
// 随它推来 —— 审批读侧复用这条已开的连接,不另开第二条 WebSocket。三态(原则 VII):
|
|
@@ -326,27 +620,32 @@ export function useUniversalAgentChat(
|
|
|
326
620
|
isStreaming,
|
|
327
621
|
error,
|
|
328
622
|
clearError,
|
|
623
|
+
setMessages,
|
|
624
|
+
stop: stopChat,
|
|
329
625
|
// autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
|
|
330
626
|
// 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
|
|
331
627
|
isServerStreaming,
|
|
332
|
-
isRecovering,
|
|
628
|
+
isRecovering: sdkIsRecovering,
|
|
333
629
|
isToolContinuation,
|
|
334
|
-
connectionError,
|
|
630
|
+
connectionError: sdkConnectionError,
|
|
335
631
|
} = useAgentChat<RuntimeState, ChatMessage>({
|
|
336
632
|
agent: chatAgent,
|
|
337
|
-
getInitialMessages:
|
|
338
|
-
|
|
633
|
+
getInitialMessages: connection.skipInitialMessages
|
|
634
|
+
? null
|
|
635
|
+
: ({ url }) => loadInitialMessages(url, connection.credentials),
|
|
339
636
|
...(connection.credentials === undefined
|
|
340
637
|
? {}
|
|
341
638
|
: { credentials: connection.credentials }),
|
|
342
639
|
syncMessagesToServer: false,
|
|
343
|
-
throttle: 100,
|
|
344
640
|
});
|
|
641
|
+
// The SDK keeps its advisory recovery flag until terminal settlement; a
|
|
642
|
+
// live server stream proves the recovered Turn is executing again.
|
|
643
|
+
const isRecovering = sdkIsRecovering && !isServerStreaming;
|
|
345
644
|
const visibleMessages = useMemo(
|
|
346
645
|
() => mergeOptimisticMessages(messages, optimisticMessages),
|
|
347
646
|
[messages, optimisticMessages],
|
|
348
647
|
);
|
|
349
|
-
const
|
|
648
|
+
const reportedTurn = facetState?.turn;
|
|
350
649
|
const stoppedByUser = error?.message === USER_STOP_REASON;
|
|
351
650
|
const latestMessage = visibleMessages.at(-1);
|
|
352
651
|
const latestTurnStatus = latestMessage?.role === "assistant"
|
|
@@ -356,15 +655,40 @@ export function useUniversalAgentChat(
|
|
|
356
655
|
latestTurnStatus === "error" ||
|
|
357
656
|
latestTurnStatus === "aborted" ||
|
|
358
657
|
latestTurnStatus === "skipped";
|
|
658
|
+
// Messages and Agent state arrive in separate frames. A terminal message for
|
|
659
|
+
// the same Submission is newer than a still-active state projection; a
|
|
660
|
+
// different active ID belongs to the next Turn and must remain live.
|
|
661
|
+
const authoritativeTurn = reportedTurn &&
|
|
662
|
+
hasTerminalMessage &&
|
|
663
|
+
latestMessage?.metadata?.turnId === reportedTurn.activeSubmissionId
|
|
664
|
+
? {
|
|
665
|
+
steerable: false,
|
|
666
|
+
hasPendingSteer: false,
|
|
667
|
+
queued: reportedTurn.queued,
|
|
668
|
+
}
|
|
669
|
+
: reportedTurn;
|
|
359
670
|
const normalizedSdkStatus = sdkStatus === "error" &&
|
|
360
671
|
(stoppedByUser || hasTerminalMessage)
|
|
361
672
|
? "ready"
|
|
362
673
|
: sdkStatus;
|
|
363
674
|
const sdkError = stoppedByUser || hasTerminalMessage ? undefined : error;
|
|
675
|
+
const connectionError = sdkConnectionError ?? undefined;
|
|
676
|
+
const connectionStatus: ChatConnectionStatus = connectionError
|
|
677
|
+
? connectionError.code === 1008 ? "rejected" : "error"
|
|
678
|
+
: agent.identified ? "connected" : "connecting";
|
|
679
|
+
const runtimeError = dispatchError ?? sdkError ?? connectionError;
|
|
680
|
+
const runtimeErrorMessage = runtimeError instanceof Error
|
|
681
|
+
? runtimeError.message
|
|
682
|
+
: runtimeError;
|
|
683
|
+
const presentationError = isServerStreaming || isRecovering ||
|
|
684
|
+
(latestTurnStatus === "error" && latestMessage?.metadata?.error === runtimeErrorMessage)
|
|
685
|
+
? undefined
|
|
686
|
+
: runtimeError;
|
|
364
687
|
// RPC admissions bypass useChat's request lifecycle. Project the durable
|
|
365
688
|
// Turn here so presentation stays correct without a second send path.
|
|
366
|
-
const authoritativeStatus =
|
|
367
|
-
|
|
689
|
+
const authoritativeStatus = isServerStreaming
|
|
690
|
+
? "streaming"
|
|
691
|
+
: normalizedSdkStatus === "ready" &&
|
|
368
692
|
!isRecovering &&
|
|
369
693
|
approvals?.length === 0 &&
|
|
370
694
|
authoritativeTurn?.activeSubmissionId
|
|
@@ -372,13 +696,57 @@ export function useUniversalAgentChat(
|
|
|
372
696
|
? "streaming"
|
|
373
697
|
: "submitted"
|
|
374
698
|
: normalizedSdkStatus;
|
|
699
|
+
const hydratingTurnActive = facetState === undefined &&
|
|
700
|
+
connection.initialTurnActive === true;
|
|
375
701
|
const status = dispatchError
|
|
376
702
|
? "error"
|
|
377
|
-
: optimisticMessages.length > 0
|
|
703
|
+
: (optimisticMessages.length > 0 || hydratingTurnActive) &&
|
|
704
|
+
authoritativeStatus === "ready"
|
|
378
705
|
? "submitted"
|
|
379
706
|
: authoritativeStatus;
|
|
380
707
|
const messagesRef = useRef(messages);
|
|
381
708
|
messagesRef.current = messages;
|
|
709
|
+
// SDK callback identities can change without a new recovery request.
|
|
710
|
+
const replayActionsRef = useRef({ setMessages, clearError });
|
|
711
|
+
replayActionsRef.current = { setMessages, clearError };
|
|
712
|
+
useEffect(() => {
|
|
713
|
+
const activeMessageId = facetState?.turn?.activeMessageId;
|
|
714
|
+
if (
|
|
715
|
+
!activeMessageId ||
|
|
716
|
+
sdkStatus !== "error" ||
|
|
717
|
+
sdkIsRecovering ||
|
|
718
|
+
isServerStreaming
|
|
719
|
+
) return;
|
|
720
|
+
const url = chatAgent.getHttpUrl();
|
|
721
|
+
if (!url) return;
|
|
722
|
+
const baseline = messagesRef.current;
|
|
723
|
+
let cancelled = false;
|
|
724
|
+
void loadInitialMessages(url, connection.credentials, "full")
|
|
725
|
+
.then((snapshot) => {
|
|
726
|
+
if (cancelled) return;
|
|
727
|
+
// The SDK protects its own snapshots while streaming. This HTTP
|
|
728
|
+
// fallback must also reject a response older than the current store.
|
|
729
|
+
const actions = replayActionsRef.current;
|
|
730
|
+
let applied = false;
|
|
731
|
+
actions.setMessages((current) => {
|
|
732
|
+
if (current !== baseline) return current;
|
|
733
|
+
applied = true;
|
|
734
|
+
return snapshot;
|
|
735
|
+
});
|
|
736
|
+
if (applied) actions.clearError();
|
|
737
|
+
})
|
|
738
|
+
.catch(() => undefined);
|
|
739
|
+
return () => {
|
|
740
|
+
cancelled = true;
|
|
741
|
+
};
|
|
742
|
+
}, [
|
|
743
|
+
chatAgent,
|
|
744
|
+
connection.credentials,
|
|
745
|
+
facetState?.turn?.activeMessageId,
|
|
746
|
+
isServerStreaming,
|
|
747
|
+
sdkIsRecovering,
|
|
748
|
+
sdkStatus,
|
|
749
|
+
]);
|
|
382
750
|
const turnTimingRef = useRef<ChatTurnTiming | null>(null);
|
|
383
751
|
|
|
384
752
|
useEffect(() => {
|
|
@@ -386,7 +754,8 @@ export function useUniversalAgentChat(
|
|
|
386
754
|
if (!timing) return;
|
|
387
755
|
const assistant = messages.find((message) =>
|
|
388
756
|
message.role === "assistant" &&
|
|
389
|
-
!timing.baselineAssistantIds.has(message.id)
|
|
757
|
+
!timing.baselineAssistantIds.has(message.id) &&
|
|
758
|
+
(!timing.submissionId || message.metadata?.turnId === timing.submissionId)
|
|
390
759
|
);
|
|
391
760
|
if (assistant) markChatTurnPhase(timing, "streamStart");
|
|
392
761
|
if (
|
|
@@ -398,9 +767,18 @@ export function useUniversalAgentChat(
|
|
|
398
767
|
}
|
|
399
768
|
if (status === "ready" && timing.streamStart !== undefined) {
|
|
400
769
|
if (timing.firstText !== undefined) markChatTurnPhase(timing, "ready");
|
|
770
|
+
else timing.ready ??= now();
|
|
401
771
|
turnTimingRef.current = null;
|
|
772
|
+
const metrics = clientPerformanceMetrics(timing);
|
|
773
|
+
if (connection.performanceRpc && timing.submissionId && metrics) {
|
|
774
|
+
void agentRef.current.call(connection.performanceRpc, [{
|
|
775
|
+
submissionId: timing.submissionId,
|
|
776
|
+
messageId: timing.messageId,
|
|
777
|
+
...metrics,
|
|
778
|
+
}]).catch(() => {});
|
|
779
|
+
}
|
|
402
780
|
}
|
|
403
|
-
}, [messages, status]);
|
|
781
|
+
}, [connection.performanceRpc, messages, status]);
|
|
404
782
|
|
|
405
783
|
useEffect(() => {
|
|
406
784
|
if (optimisticMessages.length === 0 || messages.length === 0) return;
|
|
@@ -466,7 +844,8 @@ export function useUniversalAgentChat(
|
|
|
466
844
|
isToolContinuation;
|
|
467
845
|
const pendingLocalTurn = optimisticMessages.length > 0 &&
|
|
468
846
|
(status === "submitted" || status === "streaming");
|
|
469
|
-
const turnActive =
|
|
847
|
+
const turnActive = hydratingTurnActive ||
|
|
848
|
+
Boolean(turn?.activeSubmissionId) ||
|
|
470
849
|
pendingLocalTurn ||
|
|
471
850
|
(authoritativeTurn === undefined && sdkTurnActive);
|
|
472
851
|
const canSteer = turnActive &&
|
|
@@ -485,17 +864,56 @@ export function useUniversalAgentChat(
|
|
|
485
864
|
const dispatchMessage = useCallback(async (
|
|
486
865
|
message: ChatMessage,
|
|
487
866
|
delivery: MessageDelivery,
|
|
488
|
-
): Promise<MessageDispatchReceipt> =>
|
|
489
|
-
agentRef.current.call<MessageDispatchReceipt>(
|
|
867
|
+
): Promise<MessageDispatchReceipt> => {
|
|
868
|
+
const dispatch = () => agentRef.current.call<MessageDispatchReceipt>(
|
|
490
869
|
"dispatchMessage",
|
|
491
870
|
[message, delivery],
|
|
492
|
-
)
|
|
871
|
+
);
|
|
872
|
+
try {
|
|
873
|
+
return await dispatch();
|
|
874
|
+
} catch (error) {
|
|
875
|
+
// The server deduplicates dispatches by message.id, so replay only the
|
|
876
|
+
// transport failures that mean admission may already have succeeded.
|
|
877
|
+
const retryable = error instanceof Error && (
|
|
878
|
+
/^RPC call to dispatchMessage timed out after \d+ms$/u.test(error.message) ||
|
|
879
|
+
(error.message === "Connection closed" && agentRef.current.shouldReconnect)
|
|
880
|
+
);
|
|
881
|
+
if (!retryable) throw error;
|
|
882
|
+
await agentRef.current.ready;
|
|
883
|
+
return dispatch();
|
|
884
|
+
}
|
|
885
|
+
}, []);
|
|
493
886
|
|
|
494
887
|
const submitMessage = useCallback(async (
|
|
495
888
|
message: ChatMessage,
|
|
496
889
|
delivery: MessageDelivery,
|
|
497
890
|
projection: "message" | "queue",
|
|
498
891
|
): Promise<boolean> => {
|
|
892
|
+
if (projection === "message" && delivery === "steer") {
|
|
893
|
+
const activeSubmissionId = authoritativeTurn?.activeSubmissionId;
|
|
894
|
+
const activeAssistants = messagesRef.current.filter((candidate) =>
|
|
895
|
+
candidate.role === "assistant" &&
|
|
896
|
+
candidate.metadata?.turnId === activeSubmissionId
|
|
897
|
+
);
|
|
898
|
+
const assistantMessageId = activeAssistants[0]?.id ??
|
|
899
|
+
authoritativeTurn?.activeMessageId;
|
|
900
|
+
if (assistantMessageId) {
|
|
901
|
+
message = {
|
|
902
|
+
...message,
|
|
903
|
+
metadata: {
|
|
904
|
+
...message.metadata,
|
|
905
|
+
optimisticSteer: {
|
|
906
|
+
assistantMessageId,
|
|
907
|
+
afterStep: activeAssistants.reduce((total, assistant) =>
|
|
908
|
+
total + assistant.parts.filter(
|
|
909
|
+
({ type }) => type === "step-start",
|
|
910
|
+
).length, 0),
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const stopRevision = stopRevisionRef.current;
|
|
499
917
|
setDispatchError(undefined);
|
|
500
918
|
clearError();
|
|
501
919
|
failedDispatchRef.current = undefined;
|
|
@@ -513,7 +931,6 @@ export function useUniversalAgentChat(
|
|
|
513
931
|
};
|
|
514
932
|
}
|
|
515
933
|
if (projection === "message") {
|
|
516
|
-
activeSubmissionIdRef.current = undefined;
|
|
517
934
|
setOptimisticMessages((current) =>
|
|
518
935
|
current.some(({ id }) => id === message.id)
|
|
519
936
|
? current
|
|
@@ -548,7 +965,7 @@ export function useUniversalAgentChat(
|
|
|
548
965
|
markChatTurnPhase(turnTimingRef.current, "rpcReceipt");
|
|
549
966
|
}
|
|
550
967
|
if (receipt.kind === "rejected") {
|
|
551
|
-
|
|
968
|
+
rollback();
|
|
552
969
|
if (turnTimingRef.current?.messageId === message.id) {
|
|
553
970
|
turnTimingRef.current = null;
|
|
554
971
|
}
|
|
@@ -558,9 +975,49 @@ export function useUniversalAgentChat(
|
|
|
558
975
|
return false;
|
|
559
976
|
}
|
|
560
977
|
if (projection === "message") {
|
|
561
|
-
|
|
978
|
+
const submissionId = receipt.kind === "queued"
|
|
562
979
|
? receipt.submission.submissionId
|
|
563
980
|
: receipt.submissionId;
|
|
981
|
+
if (stopRevision !== stopRevisionRef.current) {
|
|
982
|
+
await agentRef.current.call<{ ok: boolean }>(
|
|
983
|
+
"cancelSubmissionById",
|
|
984
|
+
[submissionId, USER_STOP_REASON],
|
|
985
|
+
);
|
|
986
|
+
rollback();
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
if (
|
|
990
|
+
delivery === "steer" && receipt.kind === "accepted" &&
|
|
991
|
+
!message.metadata?.optimisticSteer
|
|
992
|
+
) {
|
|
993
|
+
const activeAssistant = messagesRef.current.findLast((candidate) =>
|
|
994
|
+
candidate.role === "assistant" &&
|
|
995
|
+
candidate.metadata?.turnId === submissionId
|
|
996
|
+
);
|
|
997
|
+
const assistantMessageId = activeAssistant?.id ??
|
|
998
|
+
(authoritativeTurn?.activeSubmissionId === submissionId
|
|
999
|
+
? authoritativeTurn.activeMessageId
|
|
1000
|
+
: undefined);
|
|
1001
|
+
if (assistantMessageId) {
|
|
1002
|
+
const afterStep = activeAssistant?.parts.filter(
|
|
1003
|
+
({ type }) => type === "step-start",
|
|
1004
|
+
).length ?? 0;
|
|
1005
|
+
setOptimisticMessages((current) => current.map((optimistic) =>
|
|
1006
|
+
optimistic.id === message.id
|
|
1007
|
+
? {
|
|
1008
|
+
...optimistic,
|
|
1009
|
+
metadata: {
|
|
1010
|
+
...optimistic.metadata,
|
|
1011
|
+
optimisticSteer: { assistantMessageId, afterStep },
|
|
1012
|
+
},
|
|
1013
|
+
}
|
|
1014
|
+
: optimistic
|
|
1015
|
+
));
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
if (turnTimingRef.current?.messageId === message.id) {
|
|
1019
|
+
turnTimingRef.current.submissionId = submissionId;
|
|
1020
|
+
}
|
|
564
1021
|
}
|
|
565
1022
|
if (projection === "queue") {
|
|
566
1023
|
if (receipt.kind !== "queued" || receipt.position < 1) {
|
|
@@ -579,7 +1036,7 @@ export function useUniversalAgentChat(
|
|
|
579
1036
|
}
|
|
580
1037
|
return true;
|
|
581
1038
|
} catch (cause) {
|
|
582
|
-
|
|
1039
|
+
rollback();
|
|
583
1040
|
if (turnTimingRef.current?.messageId === message.id) {
|
|
584
1041
|
turnTimingRef.current = null;
|
|
585
1042
|
}
|
|
@@ -590,14 +1047,21 @@ export function useUniversalAgentChat(
|
|
|
590
1047
|
);
|
|
591
1048
|
return false;
|
|
592
1049
|
}
|
|
593
|
-
}, [
|
|
1050
|
+
}, [
|
|
1051
|
+
authoritativeTurn?.activeMessageId,
|
|
1052
|
+
authoritativeTurn?.activeSubmissionId,
|
|
1053
|
+
chatId,
|
|
1054
|
+
clearError,
|
|
1055
|
+
dispatchMessage,
|
|
1056
|
+
]);
|
|
594
1057
|
|
|
595
1058
|
const sendText = useCallback(async (
|
|
596
1059
|
text: string,
|
|
597
1060
|
files?: readonly FileUIPart[],
|
|
598
1061
|
capabilities?: readonly RequestedCapability[],
|
|
1062
|
+
messageId?: string,
|
|
599
1063
|
): Promise<boolean> => {
|
|
600
|
-
const message = createChatMessage(text, files, capabilities);
|
|
1064
|
+
const message = createChatMessage(text, files, capabilities, messageId);
|
|
601
1065
|
return message
|
|
602
1066
|
? submitMessage(message, "enqueue", "message")
|
|
603
1067
|
: false;
|
|
@@ -678,29 +1142,49 @@ export function useUniversalAgentChat(
|
|
|
678
1142
|
}, []);
|
|
679
1143
|
|
|
680
1144
|
const stop = useCallback(async () => {
|
|
1145
|
+
stopRevisionRef.current += 1;
|
|
681
1146
|
const requestId = authoritativeTurn?.activeRequestId;
|
|
682
|
-
const submissionId = authoritativeTurn?.activeSubmissionId
|
|
683
|
-
|
|
1147
|
+
const submissionId = authoritativeTurn?.activeSubmissionId;
|
|
1148
|
+
const beforeStop = messagesRef.current;
|
|
1149
|
+
const userIndex = beforeStop.findLastIndex(({ role }) => role === "user");
|
|
1150
|
+
const assistantIndex = beforeStop.findLastIndex(
|
|
1151
|
+
({ role }, index) => index > userIndex && role === "assistant",
|
|
1152
|
+
);
|
|
1153
|
+
const marker = {
|
|
1154
|
+
completedAt: Date.now(),
|
|
1155
|
+
interruptedByUser: true,
|
|
1156
|
+
turnStatus: "aborted",
|
|
1157
|
+
} as const;
|
|
1158
|
+
setMessages(assistantIndex < 0
|
|
1159
|
+
? [...beforeStop, {
|
|
1160
|
+
id: `optimistic-stop:${submissionId ?? requestId ?? crypto.randomUUID()}`,
|
|
1161
|
+
role: "assistant",
|
|
1162
|
+
metadata: marker,
|
|
1163
|
+
parts: [],
|
|
1164
|
+
}]
|
|
1165
|
+
: beforeStop.map((message, index) =>
|
|
1166
|
+
index === assistantIndex
|
|
1167
|
+
? { ...message, metadata: { ...message.metadata, ...marker } }
|
|
1168
|
+
: message
|
|
1169
|
+
));
|
|
1170
|
+
const rollback = () => setMessages(beforeStop);
|
|
684
1171
|
setDispatchError(undefined);
|
|
685
1172
|
try {
|
|
1173
|
+
await agentRef.current.call<{ ok: boolean }>(
|
|
1174
|
+
"stopAllSubmissions",
|
|
1175
|
+
[USER_STOP_REASON],
|
|
1176
|
+
);
|
|
1177
|
+
setOptimisticMessages([]);
|
|
1178
|
+
setOptimisticQueued([]);
|
|
686
1179
|
if (requestId) {
|
|
687
1180
|
agentRef.current.send(JSON.stringify({
|
|
688
1181
|
type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL,
|
|
689
1182
|
id: requestId,
|
|
690
1183
|
}));
|
|
691
|
-
|
|
1184
|
+
await stopChat();
|
|
692
1185
|
}
|
|
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
1186
|
} catch (cause) {
|
|
1187
|
+
rollback();
|
|
704
1188
|
setDispatchError(
|
|
705
1189
|
cause instanceof Error ? cause.message : String(cause),
|
|
706
1190
|
);
|
|
@@ -708,6 +1192,7 @@ export function useUniversalAgentChat(
|
|
|
708
1192
|
}, [
|
|
709
1193
|
authoritativeTurn?.activeRequestId,
|
|
710
1194
|
authoritativeTurn?.activeSubmissionId,
|
|
1195
|
+
stopChat,
|
|
711
1196
|
]);
|
|
712
1197
|
|
|
713
1198
|
const retry = useCallback(() => {
|
|
@@ -736,6 +1221,14 @@ export function useUniversalAgentChat(
|
|
|
736
1221
|
return false;
|
|
737
1222
|
}
|
|
738
1223
|
}, []);
|
|
1224
|
+
const decideApproval = useCallback(
|
|
1225
|
+
(executionId: string, decision: ApprovalDecision) =>
|
|
1226
|
+
agentRef.current.call<{ ok: boolean }>(
|
|
1227
|
+
"decideApproval",
|
|
1228
|
+
[executionId, decision],
|
|
1229
|
+
),
|
|
1230
|
+
[],
|
|
1231
|
+
);
|
|
739
1232
|
|
|
740
1233
|
// 只读控制面:Runtime 尚未装配时由 Agent 侧 `ensureRuntimeReady` 负责等待,
|
|
741
1234
|
// 这里不缓存结果 —— 换模型、加技能或 reload 之后调用方要拿到的是新装配。
|
|
@@ -743,6 +1236,24 @@ export function useUniversalAgentChat(
|
|
|
743
1236
|
() => agentRef.current.call<RuntimeAssemblyView>("getRuntimeAssembly", []),
|
|
744
1237
|
[],
|
|
745
1238
|
);
|
|
1239
|
+
const getRuntimeBinding = useCallback(
|
|
1240
|
+
<Binding,>() => agentRef.current.call<Binding>("getRuntimeBinding", []),
|
|
1241
|
+
[],
|
|
1242
|
+
);
|
|
1243
|
+
const readChatMetadata = useCallback(
|
|
1244
|
+
<Metadata,>() => agentRef.current.call<Metadata>("readChatMetadata", []),
|
|
1245
|
+
[],
|
|
1246
|
+
);
|
|
1247
|
+
const renameChat = useCallback(
|
|
1248
|
+
<Metadata,>(title: string) =>
|
|
1249
|
+
agentRef.current.call<Metadata>("renameChat", [title]),
|
|
1250
|
+
[],
|
|
1251
|
+
);
|
|
1252
|
+
const rebindRuntime = useCallback(
|
|
1253
|
+
<Input, Result,>(input: Input) =>
|
|
1254
|
+
agentRef.current.call<Result>("rebindRuntime", [input]),
|
|
1255
|
+
[],
|
|
1256
|
+
);
|
|
746
1257
|
const updateConfig = useCallback(
|
|
747
1258
|
<Change,>(command: unknown) =>
|
|
748
1259
|
agentRef.current.call<RuntimeConfigUpdateResult<Change>>(
|
|
@@ -761,15 +1272,21 @@ export function useUniversalAgentChat(
|
|
|
761
1272
|
|
|
762
1273
|
return {
|
|
763
1274
|
respondToolInteraction,
|
|
1275
|
+
decideApproval,
|
|
764
1276
|
getRuntimeAssembly,
|
|
1277
|
+
getRuntimeBinding,
|
|
1278
|
+
readChatMetadata,
|
|
1279
|
+
renameChat,
|
|
1280
|
+
rebindRuntime,
|
|
765
1281
|
updateConfig,
|
|
766
1282
|
reloadRuntime,
|
|
767
1283
|
messages: visibleMessages,
|
|
768
1284
|
status,
|
|
1285
|
+
connectionStatus,
|
|
1286
|
+
connectionError,
|
|
769
1287
|
runtimeLoad: facetState?.runtimeLoad,
|
|
770
1288
|
isStreaming,
|
|
771
|
-
error:
|
|
772
|
-
connectionError ?? undefined,
|
|
1289
|
+
error: presentationError,
|
|
773
1290
|
sendText,
|
|
774
1291
|
steerText,
|
|
775
1292
|
enqueueText,
|