@springbrand/chat-client 0.1.3-alpha.4 → 0.1.3-alpha.41
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 +304 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/chat-client",
|
|
3
|
-
"version": "0.1.3-alpha.
|
|
3
|
+
"version": "0.1.3-alpha.41",
|
|
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.2.0-alpha.53"
|
|
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,
|
|
@@ -18,10 +31,19 @@ import { createSafeUIMessageAgentConnection } from "./ui-message-stream-guard";
|
|
|
18
31
|
import { CURRENT_INBOX_AGENT_PATH } from "./chat-sessions";
|
|
19
32
|
|
|
20
33
|
type ChatMessageMetadata = Record<string, unknown> & {
|
|
34
|
+
completedAt?: number;
|
|
35
|
+
error?: string;
|
|
36
|
+
interruptedByUser?: boolean;
|
|
21
37
|
requestedCapabilities?: readonly RequestedCapability[];
|
|
38
|
+
turnId?: string;
|
|
22
39
|
turnStatus?: string;
|
|
23
40
|
};
|
|
24
41
|
type ChatMessage = UIMessage<ChatMessageMetadata>;
|
|
42
|
+
export type ChatConnectionStatus =
|
|
43
|
+
| "connecting"
|
|
44
|
+
| "connected"
|
|
45
|
+
| "rejected"
|
|
46
|
+
| "error";
|
|
25
47
|
type FailedDispatch = {
|
|
26
48
|
message: ChatMessage;
|
|
27
49
|
delivery: MessageDelivery;
|
|
@@ -73,6 +95,7 @@ type ChatQueuedSubmission = {
|
|
|
73
95
|
type ChatTurn = {
|
|
74
96
|
activeSubmissionId?: string;
|
|
75
97
|
activeRequestId?: string;
|
|
98
|
+
activeMessageId?: string;
|
|
76
99
|
recoveryAttempt?: number;
|
|
77
100
|
recoveryMax?: number;
|
|
78
101
|
recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
|
|
@@ -84,6 +107,8 @@ type ChatTurn = {
|
|
|
84
107
|
export interface ChatRuntime {
|
|
85
108
|
messages: ChatMessage[];
|
|
86
109
|
status: ChatStatus;
|
|
110
|
+
connectionStatus: ChatConnectionStatus;
|
|
111
|
+
connectionError: AgentConnectionError | undefined;
|
|
87
112
|
runtimeLoad: ChatRuntimeLoadState | undefined;
|
|
88
113
|
isStreaming: boolean;
|
|
89
114
|
error: string | Error | undefined;
|
|
@@ -115,6 +140,10 @@ export interface ChatRuntime {
|
|
|
115
140
|
toolCallId: string,
|
|
116
141
|
response: unknown,
|
|
117
142
|
) => Promise<boolean>;
|
|
143
|
+
decideApproval: (
|
|
144
|
+
executionId: string,
|
|
145
|
+
decision: ApprovalDecision,
|
|
146
|
+
) => Promise<{ ok: boolean }>;
|
|
118
147
|
/**
|
|
119
148
|
* 读取该 Session facet 当前真正装配出来的 Runtime。
|
|
120
149
|
*
|
|
@@ -124,6 +153,10 @@ export interface ChatRuntime {
|
|
|
124
153
|
* 装配未就绪还是连接断了,收敛成 `undefined` 会让调试面板无从显示原因。
|
|
125
154
|
*/
|
|
126
155
|
getRuntimeAssembly: () => Promise<RuntimeAssemblyView>;
|
|
156
|
+
getRuntimeBinding: <Binding = unknown>() => Promise<Binding>;
|
|
157
|
+
readChatMetadata: <Metadata = unknown>() => Promise<Metadata>;
|
|
158
|
+
renameChat: <Metadata = unknown>(title: string) => Promise<Metadata>;
|
|
159
|
+
rebindRuntime: <Input = unknown, Result = unknown>(input: Input) => Promise<Result>;
|
|
127
160
|
updateConfig: <Change = unknown>(
|
|
128
161
|
command: unknown,
|
|
129
162
|
) => Promise<RuntimeConfigUpdateResult<Change>>;
|
|
@@ -145,8 +178,15 @@ type SharedChatConnectionOptions = {
|
|
|
145
178
|
host?: string;
|
|
146
179
|
credentials?: RequestCredentials;
|
|
147
180
|
protocols?: string | string[];
|
|
181
|
+
onMessage?: (message: MessageEvent) => void;
|
|
148
182
|
inboxAgent?: string;
|
|
149
183
|
sessionAgent?: string;
|
|
184
|
+
/** Skip history loading when the caller has just created this Chat. */
|
|
185
|
+
skipInitialMessages?: boolean;
|
|
186
|
+
/** Host projection used only until the selected Session sends its first state. */
|
|
187
|
+
initialTurnActive?: boolean;
|
|
188
|
+
/** Optional Agent RPC method injected by the Host to receive Turn timings. */
|
|
189
|
+
performanceRpc?: string;
|
|
150
190
|
};
|
|
151
191
|
|
|
152
192
|
export type ChatConnectionOptions = SharedChatConnectionOptions & (
|
|
@@ -154,9 +194,61 @@ export type ChatConnectionOptions = SharedChatConnectionOptions & (
|
|
|
154
194
|
| { inboxName?: never; basePath?: string }
|
|
155
195
|
);
|
|
156
196
|
|
|
197
|
+
type SessionAgentConnection = ReturnType<typeof useAgent<RuntimeState>>;
|
|
198
|
+
type UniversalAgentChatContextValue = {
|
|
199
|
+
chatId: string;
|
|
200
|
+
connection: ChatConnectionOptions;
|
|
201
|
+
agent: SessionAgentConnection;
|
|
202
|
+
chatAgent: SessionAgentConnection;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const UniversalAgentChatContext = createContext<UniversalAgentChatContextValue | null>(null);
|
|
206
|
+
|
|
207
|
+
export function UniversalAgentChatProvider({
|
|
208
|
+
chatId,
|
|
209
|
+
connection = {},
|
|
210
|
+
fallback = null,
|
|
211
|
+
children,
|
|
212
|
+
}: {
|
|
213
|
+
chatId: string;
|
|
214
|
+
connection?: ChatConnectionOptions;
|
|
215
|
+
fallback?: ReactNode;
|
|
216
|
+
children: ReactNode;
|
|
217
|
+
}) {
|
|
218
|
+
const inboxAgent = connection.inboxAgent ?? "Inbox";
|
|
219
|
+
const agent = useAgent<RuntimeState>({
|
|
220
|
+
agent: inboxAgent,
|
|
221
|
+
...(connection.inboxName === undefined
|
|
222
|
+
? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
|
|
223
|
+
: { name: connection.inboxName }),
|
|
224
|
+
...(connection.host === undefined ? {} : { host: connection.host }),
|
|
225
|
+
...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
|
|
226
|
+
...(connection.onMessage === undefined ? {} : { onMessage: connection.onMessage }),
|
|
227
|
+
connectionTimeout: 6_000,
|
|
228
|
+
sub: [{
|
|
229
|
+
agent: connection.sessionAgent ?? "UniversalAgent",
|
|
230
|
+
name: chatId,
|
|
231
|
+
}],
|
|
232
|
+
});
|
|
233
|
+
const chatAgent = useMemo(
|
|
234
|
+
() => createSafeUIMessageAgentConnection(agent),
|
|
235
|
+
[agent],
|
|
236
|
+
);
|
|
237
|
+
const value = useMemo(
|
|
238
|
+
() => ({ chatId, connection, agent, chatAgent }),
|
|
239
|
+
[agent, chatAgent, chatId, connection],
|
|
240
|
+
);
|
|
241
|
+
return createElement(
|
|
242
|
+
UniversalAgentChatContext.Provider,
|
|
243
|
+
{ value },
|
|
244
|
+
createElement(Suspense, { fallback }, children),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
157
248
|
type ChatTurnTiming = {
|
|
158
249
|
chatId: string;
|
|
159
250
|
messageId: string;
|
|
251
|
+
submissionId?: string;
|
|
160
252
|
baselineAssistantIds: Set<string>;
|
|
161
253
|
click: number;
|
|
162
254
|
rpcReceipt?: number;
|
|
@@ -165,6 +257,37 @@ type ChatTurnTiming = {
|
|
|
165
257
|
ready?: number;
|
|
166
258
|
};
|
|
167
259
|
|
|
260
|
+
type ClientPerformanceMetrics = {
|
|
261
|
+
clickToRpcMs: number;
|
|
262
|
+
rpcToStreamMs: number;
|
|
263
|
+
streamToFirstTextMs?: number;
|
|
264
|
+
firstTextToReadyMs?: number;
|
|
265
|
+
ttftMs?: number;
|
|
266
|
+
totalMs: number;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export function clientPerformanceMetrics(
|
|
270
|
+
timing: ChatTurnTiming,
|
|
271
|
+
): ClientPerformanceMetrics | null {
|
|
272
|
+
if (
|
|
273
|
+
timing.rpcReceipt === undefined ||
|
|
274
|
+
timing.streamStart === undefined ||
|
|
275
|
+
timing.ready === undefined
|
|
276
|
+
) return null;
|
|
277
|
+
return {
|
|
278
|
+
clickToRpcMs: timing.rpcReceipt - timing.click,
|
|
279
|
+
rpcToStreamMs: timing.streamStart - timing.rpcReceipt,
|
|
280
|
+
...(timing.firstText === undefined
|
|
281
|
+
? {}
|
|
282
|
+
: {
|
|
283
|
+
streamToFirstTextMs: timing.firstText - timing.streamStart,
|
|
284
|
+
firstTextToReadyMs: timing.ready - timing.firstText,
|
|
285
|
+
ttftMs: timing.firstText - timing.click,
|
|
286
|
+
}),
|
|
287
|
+
totalMs: timing.ready - timing.click,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
168
291
|
const CHAT_TURN_SEGMENTS = {
|
|
169
292
|
rpcReceipt: ["click", "universal-agent.chat.click_to_rpc_receipt"],
|
|
170
293
|
streamStart: [
|
|
@@ -260,10 +383,12 @@ function queuedPreview(message: ChatMessage): string {
|
|
|
260
383
|
function loadInitialMessages(
|
|
261
384
|
url: string | undefined,
|
|
262
385
|
credentials: RequestCredentials | undefined,
|
|
386
|
+
view: "hydration" | "full" = "hydration",
|
|
263
387
|
): Promise<ChatMessage[]> {
|
|
264
388
|
if (!url) return Promise.resolve([]);
|
|
265
389
|
const messagesUrl = new URL(url);
|
|
266
390
|
messagesUrl.pathname = `${messagesUrl.pathname.replace(/\/$/, "")}/get-messages`;
|
|
391
|
+
if (view === "hydration") messagesUrl.searchParams.set("view", "hydration");
|
|
267
392
|
return getAgentMessages({
|
|
268
393
|
url: messagesUrl.toString(),
|
|
269
394
|
...(credentials === undefined ? {} : { credentials }),
|
|
@@ -281,10 +406,12 @@ function loadInitialMessages(
|
|
|
281
406
|
* sub 数组由客户端 kebab 化;服务端按 ctx.exports 反解回 CamelCase className,
|
|
282
407
|
* 与 Inbox.onBeforeSubAgent 的严格门卫(hasSubAgent)对齐。
|
|
283
408
|
*/
|
|
284
|
-
export function useUniversalAgentChat(
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
409
|
+
export function useUniversalAgentChat(): ChatRuntime {
|
|
410
|
+
const context = useContext(UniversalAgentChatContext);
|
|
411
|
+
if (!context) {
|
|
412
|
+
throw new Error("useUniversalAgentChat must be used within UniversalAgentChatProvider");
|
|
413
|
+
}
|
|
414
|
+
const { chatId, connection, agent, chatAgent } = context;
|
|
288
415
|
const [optimisticMessages, setOptimisticMessages] =
|
|
289
416
|
useState<ChatMessage[]>([]);
|
|
290
417
|
const [optimisticQueued, setOptimisticQueued] =
|
|
@@ -292,25 +419,7 @@ export function useUniversalAgentChat(
|
|
|
292
419
|
const [dispatchError, setDispatchError] = useState<string>();
|
|
293
420
|
const [canRetry, setCanRetry] = useState(false);
|
|
294
421
|
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
|
-
|
|
422
|
+
const stopRevisionRef = useRef(0);
|
|
314
423
|
// 轮询→推送:这条 chat 连接连的是 UniversalAgent facet,`agent.state` 即 facet 的
|
|
315
424
|
// AgentState 广播(useAgent 内部 useState,收到 cf_agent_state 即 re-render)。待批项
|
|
316
425
|
// 随它推来 —— 审批读侧复用这条已开的连接,不另开第二条 WebSocket。三态(原则 VII):
|
|
@@ -326,22 +435,27 @@ export function useUniversalAgentChat(
|
|
|
326
435
|
isStreaming,
|
|
327
436
|
error,
|
|
328
437
|
clearError,
|
|
438
|
+
setMessages,
|
|
439
|
+
stop: stopChat,
|
|
329
440
|
// autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
|
|
330
441
|
// 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
|
|
331
442
|
isServerStreaming,
|
|
332
|
-
isRecovering,
|
|
443
|
+
isRecovering: sdkIsRecovering,
|
|
333
444
|
isToolContinuation,
|
|
334
|
-
connectionError,
|
|
445
|
+
connectionError: sdkConnectionError,
|
|
335
446
|
} = useAgentChat<RuntimeState, ChatMessage>({
|
|
336
447
|
agent: chatAgent,
|
|
337
|
-
getInitialMessages:
|
|
338
|
-
|
|
448
|
+
getInitialMessages: connection.skipInitialMessages
|
|
449
|
+
? null
|
|
450
|
+
: ({ url }) => loadInitialMessages(url, connection.credentials),
|
|
339
451
|
...(connection.credentials === undefined
|
|
340
452
|
? {}
|
|
341
453
|
: { credentials: connection.credentials }),
|
|
342
454
|
syncMessagesToServer: false,
|
|
343
|
-
throttle: 100,
|
|
344
455
|
});
|
|
456
|
+
// The SDK keeps its advisory recovery flag until terminal settlement; a
|
|
457
|
+
// live server stream proves the recovered Turn is executing again.
|
|
458
|
+
const isRecovering = sdkIsRecovering && !isServerStreaming;
|
|
345
459
|
const visibleMessages = useMemo(
|
|
346
460
|
() => mergeOptimisticMessages(messages, optimisticMessages),
|
|
347
461
|
[messages, optimisticMessages],
|
|
@@ -361,10 +475,23 @@ export function useUniversalAgentChat(
|
|
|
361
475
|
? "ready"
|
|
362
476
|
: sdkStatus;
|
|
363
477
|
const sdkError = stoppedByUser || hasTerminalMessage ? undefined : error;
|
|
478
|
+
const connectionError = sdkConnectionError ?? undefined;
|
|
479
|
+
const connectionStatus: ChatConnectionStatus = connectionError
|
|
480
|
+
? connectionError.code === 1008 ? "rejected" : "error"
|
|
481
|
+
: agent.identified ? "connected" : "connecting";
|
|
482
|
+
const runtimeError = dispatchError ?? sdkError ?? connectionError;
|
|
483
|
+
const runtimeErrorMessage = runtimeError instanceof Error
|
|
484
|
+
? runtimeError.message
|
|
485
|
+
: runtimeError;
|
|
486
|
+
const presentationError = isServerStreaming || isRecovering ||
|
|
487
|
+
(latestTurnStatus === "error" && latestMessage?.metadata?.error === runtimeErrorMessage)
|
|
488
|
+
? undefined
|
|
489
|
+
: runtimeError;
|
|
364
490
|
// RPC admissions bypass useChat's request lifecycle. Project the durable
|
|
365
491
|
// Turn here so presentation stays correct without a second send path.
|
|
366
|
-
const authoritativeStatus =
|
|
367
|
-
|
|
492
|
+
const authoritativeStatus = isServerStreaming
|
|
493
|
+
? "streaming"
|
|
494
|
+
: normalizedSdkStatus === "ready" &&
|
|
368
495
|
!isRecovering &&
|
|
369
496
|
approvals?.length === 0 &&
|
|
370
497
|
authoritativeTurn?.activeSubmissionId
|
|
@@ -372,13 +499,45 @@ export function useUniversalAgentChat(
|
|
|
372
499
|
? "streaming"
|
|
373
500
|
: "submitted"
|
|
374
501
|
: normalizedSdkStatus;
|
|
502
|
+
const hydratingTurnActive = facetState === undefined &&
|
|
503
|
+
connection.initialTurnActive === true;
|
|
375
504
|
const status = dispatchError
|
|
376
505
|
? "error"
|
|
377
|
-
: optimisticMessages.length > 0
|
|
506
|
+
: (optimisticMessages.length > 0 || hydratingTurnActive) &&
|
|
507
|
+
authoritativeStatus === "ready"
|
|
378
508
|
? "submitted"
|
|
379
509
|
: authoritativeStatus;
|
|
380
510
|
const messagesRef = useRef(messages);
|
|
381
511
|
messagesRef.current = messages;
|
|
512
|
+
const replayFallbackMessageIdRef = useRef<string | undefined>(undefined);
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
const activeMessageId = facetState?.turn?.activeMessageId;
|
|
515
|
+
if (
|
|
516
|
+
!activeMessageId ||
|
|
517
|
+
sdkStatus !== "error" ||
|
|
518
|
+
sdkIsRecovering ||
|
|
519
|
+
isServerStreaming ||
|
|
520
|
+
replayFallbackMessageIdRef.current === activeMessageId
|
|
521
|
+
) return;
|
|
522
|
+
const url = chatAgent.getHttpUrl();
|
|
523
|
+
if (!url) return;
|
|
524
|
+
replayFallbackMessageIdRef.current = activeMessageId;
|
|
525
|
+
void loadInitialMessages(url, connection.credentials, "full")
|
|
526
|
+
.then((snapshot) => {
|
|
527
|
+
setMessages(snapshot);
|
|
528
|
+
clearError();
|
|
529
|
+
})
|
|
530
|
+
.catch(() => undefined);
|
|
531
|
+
}, [
|
|
532
|
+
chatAgent,
|
|
533
|
+
clearError,
|
|
534
|
+
connection.credentials,
|
|
535
|
+
facetState?.turn?.activeMessageId,
|
|
536
|
+
isServerStreaming,
|
|
537
|
+
sdkIsRecovering,
|
|
538
|
+
sdkStatus,
|
|
539
|
+
setMessages,
|
|
540
|
+
]);
|
|
382
541
|
const turnTimingRef = useRef<ChatTurnTiming | null>(null);
|
|
383
542
|
|
|
384
543
|
useEffect(() => {
|
|
@@ -386,7 +545,8 @@ export function useUniversalAgentChat(
|
|
|
386
545
|
if (!timing) return;
|
|
387
546
|
const assistant = messages.find((message) =>
|
|
388
547
|
message.role === "assistant" &&
|
|
389
|
-
!timing.baselineAssistantIds.has(message.id)
|
|
548
|
+
!timing.baselineAssistantIds.has(message.id) &&
|
|
549
|
+
(!timing.submissionId || message.metadata?.turnId === timing.submissionId)
|
|
390
550
|
);
|
|
391
551
|
if (assistant) markChatTurnPhase(timing, "streamStart");
|
|
392
552
|
if (
|
|
@@ -398,9 +558,18 @@ export function useUniversalAgentChat(
|
|
|
398
558
|
}
|
|
399
559
|
if (status === "ready" && timing.streamStart !== undefined) {
|
|
400
560
|
if (timing.firstText !== undefined) markChatTurnPhase(timing, "ready");
|
|
561
|
+
else timing.ready ??= now();
|
|
401
562
|
turnTimingRef.current = null;
|
|
563
|
+
const metrics = clientPerformanceMetrics(timing);
|
|
564
|
+
if (connection.performanceRpc && timing.submissionId && metrics) {
|
|
565
|
+
void agentRef.current.call(connection.performanceRpc, [{
|
|
566
|
+
submissionId: timing.submissionId,
|
|
567
|
+
messageId: timing.messageId,
|
|
568
|
+
...metrics,
|
|
569
|
+
}]).catch(() => {});
|
|
570
|
+
}
|
|
402
571
|
}
|
|
403
|
-
}, [messages, status]);
|
|
572
|
+
}, [connection.performanceRpc, messages, status]);
|
|
404
573
|
|
|
405
574
|
useEffect(() => {
|
|
406
575
|
if (optimisticMessages.length === 0 || messages.length === 0) return;
|
|
@@ -466,7 +635,8 @@ export function useUniversalAgentChat(
|
|
|
466
635
|
isToolContinuation;
|
|
467
636
|
const pendingLocalTurn = optimisticMessages.length > 0 &&
|
|
468
637
|
(status === "submitted" || status === "streaming");
|
|
469
|
-
const turnActive =
|
|
638
|
+
const turnActive = hydratingTurnActive ||
|
|
639
|
+
Boolean(turn?.activeSubmissionId) ||
|
|
470
640
|
pendingLocalTurn ||
|
|
471
641
|
(authoritativeTurn === undefined && sdkTurnActive);
|
|
472
642
|
const canSteer = turnActive &&
|
|
@@ -485,17 +655,32 @@ export function useUniversalAgentChat(
|
|
|
485
655
|
const dispatchMessage = useCallback(async (
|
|
486
656
|
message: ChatMessage,
|
|
487
657
|
delivery: MessageDelivery,
|
|
488
|
-
): Promise<MessageDispatchReceipt> =>
|
|
489
|
-
agentRef.current.call<MessageDispatchReceipt>(
|
|
658
|
+
): Promise<MessageDispatchReceipt> => {
|
|
659
|
+
const dispatch = () => agentRef.current.call<MessageDispatchReceipt>(
|
|
490
660
|
"dispatchMessage",
|
|
491
661
|
[message, delivery],
|
|
492
|
-
)
|
|
662
|
+
);
|
|
663
|
+
try {
|
|
664
|
+
return await dispatch();
|
|
665
|
+
} catch (error) {
|
|
666
|
+
// The server deduplicates dispatches by message.id, so replay only the
|
|
667
|
+
// transport failures that mean admission may already have succeeded.
|
|
668
|
+
const retryable = error instanceof Error && (
|
|
669
|
+
/^RPC call to dispatchMessage timed out after \d+ms$/u.test(error.message) ||
|
|
670
|
+
(error.message === "Connection closed" && agentRef.current.shouldReconnect)
|
|
671
|
+
);
|
|
672
|
+
if (!retryable) throw error;
|
|
673
|
+
await agentRef.current.ready;
|
|
674
|
+
return dispatch();
|
|
675
|
+
}
|
|
676
|
+
}, []);
|
|
493
677
|
|
|
494
678
|
const submitMessage = useCallback(async (
|
|
495
679
|
message: ChatMessage,
|
|
496
680
|
delivery: MessageDelivery,
|
|
497
681
|
projection: "message" | "queue",
|
|
498
682
|
): Promise<boolean> => {
|
|
683
|
+
const stopRevision = stopRevisionRef.current;
|
|
499
684
|
setDispatchError(undefined);
|
|
500
685
|
clearError();
|
|
501
686
|
failedDispatchRef.current = undefined;
|
|
@@ -513,7 +698,6 @@ export function useUniversalAgentChat(
|
|
|
513
698
|
};
|
|
514
699
|
}
|
|
515
700
|
if (projection === "message") {
|
|
516
|
-
activeSubmissionIdRef.current = undefined;
|
|
517
701
|
setOptimisticMessages((current) =>
|
|
518
702
|
current.some(({ id }) => id === message.id)
|
|
519
703
|
? current
|
|
@@ -558,9 +742,20 @@ export function useUniversalAgentChat(
|
|
|
558
742
|
return false;
|
|
559
743
|
}
|
|
560
744
|
if (projection === "message") {
|
|
561
|
-
|
|
745
|
+
const submissionId = receipt.kind === "queued"
|
|
562
746
|
? receipt.submission.submissionId
|
|
563
747
|
: receipt.submissionId;
|
|
748
|
+
if (stopRevision !== stopRevisionRef.current) {
|
|
749
|
+
await agentRef.current.call<{ ok: boolean }>(
|
|
750
|
+
"cancelSubmissionById",
|
|
751
|
+
[submissionId, USER_STOP_REASON],
|
|
752
|
+
);
|
|
753
|
+
rollback();
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
if (turnTimingRef.current?.messageId === message.id) {
|
|
757
|
+
turnTimingRef.current.submissionId = submissionId;
|
|
758
|
+
}
|
|
564
759
|
}
|
|
565
760
|
if (projection === "queue") {
|
|
566
761
|
if (receipt.kind !== "queued" || receipt.position < 1) {
|
|
@@ -678,29 +873,49 @@ export function useUniversalAgentChat(
|
|
|
678
873
|
}, []);
|
|
679
874
|
|
|
680
875
|
const stop = useCallback(async () => {
|
|
876
|
+
stopRevisionRef.current += 1;
|
|
681
877
|
const requestId = authoritativeTurn?.activeRequestId;
|
|
682
|
-
const submissionId = authoritativeTurn?.activeSubmissionId
|
|
683
|
-
|
|
878
|
+
const submissionId = authoritativeTurn?.activeSubmissionId;
|
|
879
|
+
const beforeStop = messagesRef.current;
|
|
880
|
+
const userIndex = beforeStop.findLastIndex(({ role }) => role === "user");
|
|
881
|
+
const assistantIndex = beforeStop.findLastIndex(
|
|
882
|
+
({ role }, index) => index > userIndex && role === "assistant",
|
|
883
|
+
);
|
|
884
|
+
const marker = {
|
|
885
|
+
completedAt: Date.now(),
|
|
886
|
+
interruptedByUser: true,
|
|
887
|
+
turnStatus: "aborted",
|
|
888
|
+
} as const;
|
|
889
|
+
setMessages(assistantIndex < 0
|
|
890
|
+
? [...beforeStop, {
|
|
891
|
+
id: `optimistic-stop:${submissionId ?? requestId ?? crypto.randomUUID()}`,
|
|
892
|
+
role: "assistant",
|
|
893
|
+
metadata: marker,
|
|
894
|
+
parts: [],
|
|
895
|
+
}]
|
|
896
|
+
: beforeStop.map((message, index) =>
|
|
897
|
+
index === assistantIndex
|
|
898
|
+
? { ...message, metadata: { ...message.metadata, ...marker } }
|
|
899
|
+
: message
|
|
900
|
+
));
|
|
901
|
+
const rollback = () => setMessages(beforeStop);
|
|
684
902
|
setDispatchError(undefined);
|
|
685
903
|
try {
|
|
904
|
+
await agentRef.current.call<{ ok: boolean }>(
|
|
905
|
+
"stopAllSubmissions",
|
|
906
|
+
[USER_STOP_REASON],
|
|
907
|
+
);
|
|
908
|
+
setOptimisticMessages([]);
|
|
909
|
+
setOptimisticQueued([]);
|
|
686
910
|
if (requestId) {
|
|
687
911
|
agentRef.current.send(JSON.stringify({
|
|
688
912
|
type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL,
|
|
689
913
|
id: requestId,
|
|
690
914
|
}));
|
|
691
|
-
|
|
915
|
+
await stopChat();
|
|
692
916
|
}
|
|
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
917
|
} catch (cause) {
|
|
918
|
+
rollback();
|
|
704
919
|
setDispatchError(
|
|
705
920
|
cause instanceof Error ? cause.message : String(cause),
|
|
706
921
|
);
|
|
@@ -708,6 +923,7 @@ export function useUniversalAgentChat(
|
|
|
708
923
|
}, [
|
|
709
924
|
authoritativeTurn?.activeRequestId,
|
|
710
925
|
authoritativeTurn?.activeSubmissionId,
|
|
926
|
+
stopChat,
|
|
711
927
|
]);
|
|
712
928
|
|
|
713
929
|
const retry = useCallback(() => {
|
|
@@ -736,6 +952,14 @@ export function useUniversalAgentChat(
|
|
|
736
952
|
return false;
|
|
737
953
|
}
|
|
738
954
|
}, []);
|
|
955
|
+
const decideApproval = useCallback(
|
|
956
|
+
(executionId: string, decision: ApprovalDecision) =>
|
|
957
|
+
agentRef.current.call<{ ok: boolean }>(
|
|
958
|
+
"decideApproval",
|
|
959
|
+
[executionId, decision],
|
|
960
|
+
),
|
|
961
|
+
[],
|
|
962
|
+
);
|
|
739
963
|
|
|
740
964
|
// 只读控制面:Runtime 尚未装配时由 Agent 侧 `ensureRuntimeReady` 负责等待,
|
|
741
965
|
// 这里不缓存结果 —— 换模型、加技能或 reload 之后调用方要拿到的是新装配。
|
|
@@ -743,6 +967,24 @@ export function useUniversalAgentChat(
|
|
|
743
967
|
() => agentRef.current.call<RuntimeAssemblyView>("getRuntimeAssembly", []),
|
|
744
968
|
[],
|
|
745
969
|
);
|
|
970
|
+
const getRuntimeBinding = useCallback(
|
|
971
|
+
<Binding,>() => agentRef.current.call<Binding>("getRuntimeBinding", []),
|
|
972
|
+
[],
|
|
973
|
+
);
|
|
974
|
+
const readChatMetadata = useCallback(
|
|
975
|
+
<Metadata,>() => agentRef.current.call<Metadata>("readChatMetadata", []),
|
|
976
|
+
[],
|
|
977
|
+
);
|
|
978
|
+
const renameChat = useCallback(
|
|
979
|
+
<Metadata,>(title: string) =>
|
|
980
|
+
agentRef.current.call<Metadata>("renameChat", [title]),
|
|
981
|
+
[],
|
|
982
|
+
);
|
|
983
|
+
const rebindRuntime = useCallback(
|
|
984
|
+
<Input, Result,>(input: Input) =>
|
|
985
|
+
agentRef.current.call<Result>("rebindRuntime", [input]),
|
|
986
|
+
[],
|
|
987
|
+
);
|
|
746
988
|
const updateConfig = useCallback(
|
|
747
989
|
<Change,>(command: unknown) =>
|
|
748
990
|
agentRef.current.call<RuntimeConfigUpdateResult<Change>>(
|
|
@@ -761,15 +1003,21 @@ export function useUniversalAgentChat(
|
|
|
761
1003
|
|
|
762
1004
|
return {
|
|
763
1005
|
respondToolInteraction,
|
|
1006
|
+
decideApproval,
|
|
764
1007
|
getRuntimeAssembly,
|
|
1008
|
+
getRuntimeBinding,
|
|
1009
|
+
readChatMetadata,
|
|
1010
|
+
renameChat,
|
|
1011
|
+
rebindRuntime,
|
|
765
1012
|
updateConfig,
|
|
766
1013
|
reloadRuntime,
|
|
767
1014
|
messages: visibleMessages,
|
|
768
1015
|
status,
|
|
1016
|
+
connectionStatus,
|
|
1017
|
+
connectionError,
|
|
769
1018
|
runtimeLoad: facetState?.runtimeLoad,
|
|
770
1019
|
isStreaming,
|
|
771
|
-
error:
|
|
772
|
-
connectionError ?? undefined,
|
|
1020
|
+
error: presentationError,
|
|
773
1021
|
sendText,
|
|
774
1022
|
steerText,
|
|
775
1023
|
enqueueText,
|