@springbrand/chat-client 0.1.1 → 0.1.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.
@@ -1,542 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
- import { nanoid } from "nanoid";
3
- import type { FileUIPart, UIMessage } from "ai";
4
- import { useAgentChat } from "agents/chat/react";
5
- import { useAgent, useAgentToolEvents } from "agents/react";
6
- import {
7
- USER_STOP_REASON,
8
- type MessageDelivery,
9
- type MessageDispatchReceipt,
10
- type RuntimeQueuedSubmission,
11
- type RuntimeState,
12
- } from "@springbrand/agent-runtime/contracts";
13
- import { createSafeUIMessageAgentConnection } from "./ui-message-stream-guard";
14
- import { CURRENT_INBOX_AGENT_PATH } from "./chat-sessions";
15
-
16
- type ChatMessageMetadata = Record<string, unknown> & {
17
- turnStatus?: string;
18
- };
19
- type ChatMessage = UIMessage<ChatMessageMetadata>;
20
- type FailedDispatch = {
21
- message: ChatMessage;
22
- delivery: MessageDelivery;
23
- projection: "message" | "queue";
24
- };
25
-
26
- type ChatTurnTiming = {
27
- chatId: string;
28
- messageId: string;
29
- baselineAssistantIds: Set<string>;
30
- click: number;
31
- rpcReceipt?: number;
32
- streamStart?: number;
33
- firstText?: number;
34
- ready?: number;
35
- };
36
-
37
- const CHAT_TURN_SEGMENTS = {
38
- rpcReceipt: ["click", "universal-agent.chat.click_to_rpc_receipt"],
39
- streamStart: [
40
- "rpcReceipt",
41
- "universal-agent.chat.rpc_receipt_to_stream_start",
42
- ],
43
- firstText: [
44
- "streamStart",
45
- "universal-agent.chat.stream_start_to_first_text",
46
- ],
47
- ready: ["firstText", "universal-agent.chat.first_text_to_ready"],
48
- } as const;
49
-
50
- function now(): number {
51
- return typeof performance === "undefined" ? Date.now() : performance.now();
52
- }
53
-
54
- function markChatTurnPhase(
55
- timing: ChatTurnTiming,
56
- phase: keyof typeof CHAT_TURN_SEGMENTS,
57
- ): void {
58
- if (timing[phase] !== undefined) return;
59
- const timestamp = now();
60
- timing[phase] = timestamp;
61
- if (typeof performance === "undefined") return;
62
- const [previous, name] = CHAT_TURN_SEGMENTS[phase];
63
- const start = timing[previous];
64
- if (start === undefined) return;
65
- try {
66
- performance.measure(name, {
67
- start,
68
- end: timestamp,
69
- detail: { chatId: timing.chatId, messageId: timing.messageId },
70
- });
71
- } catch {
72
- // Metrics must never interrupt message delivery.
73
- }
74
- }
75
-
76
- function createChatMessage(
77
- text: string,
78
- files?: readonly FileUIPart[],
79
- ): ChatMessage | null {
80
- const normalizedText = text.trim();
81
- if (!normalizedText && !files?.length) return null;
82
- const createdAt = Date.now();
83
- return {
84
- id: nanoid(),
85
- role: "user",
86
- parts: [
87
- ...(files ?? []),
88
- ...(normalizedText
89
- ? [{ type: "text" as const, text: normalizedText }]
90
- : []),
91
- ],
92
- metadata: {
93
- createdAt,
94
- authorDisplayName: "You",
95
- messageSource: "Web",
96
- },
97
- };
98
- }
99
-
100
- export function mergeOptimisticMessages<T extends UIMessage>(
101
- messages: T[],
102
- optimistic: T[],
103
- ): T[] {
104
- if (optimistic.length === 0) return messages;
105
- const ids = new Set(messages.map(({ id }) => id));
106
- const pending = optimistic.filter(({ id }) => !ids.has(id));
107
- return pending.length === 0 ? messages : [...messages, ...pending];
108
- }
109
-
110
- function queuedPreview(message: ChatMessage): string {
111
- return message.parts.flatMap((part) => {
112
- if (part.type === "text") return [part.text];
113
- if (part.type === "file" && part.filename) return [part.filename];
114
- return [];
115
- }).join(" ").trim().slice(0, 160) || "Queued message";
116
- }
117
-
118
- /**
119
- * 薄适配层:把 useAgent + UIMessage chat + useAgentToolEvents
120
- * 收敛成一个归一化 runtime,对外只暴露 messages / 发送 / 状态 / 子 agent 运行,
121
- * 隐藏 SDK 细节。
122
- *
123
- * 多会话接线(官方 facet 模式):经父级 Inbox 连 UniversalAgent facet,
124
- * URL = /api/agent-connections/inbox/sub/universal-agent/{chatId};可信用户
125
- * 由 Worker 从 Session 解析后再路由到真正的 Inbox。
126
- * sub 数组由客户端 kebab 化;服务端按 ctx.exports 反解回 CamelCase className,
127
- * 与 Inbox.onBeforeSubAgent 的严格门卫(hasSubAgent)对齐。
128
- */
129
- export function useUniversalAgentChat(chatId: string) {
130
- const [optimisticMessages, setOptimisticMessages] =
131
- useState<ChatMessage[]>([]);
132
- const [optimisticQueued, setOptimisticQueued] =
133
- useState<RuntimeQueuedSubmission[]>([]);
134
- const [dispatchError, setDispatchError] = useState<string>();
135
- const [canRetry, setCanRetry] = useState(false);
136
- const failedDispatchRef = useRef<FailedDispatch | undefined>(undefined);
137
- const activeSubmissionIdRef = useRef<string | undefined>(undefined);
138
- const agent = useAgent<RuntimeState>({
139
- agent: "Inbox",
140
- basePath: CURRENT_INBOX_AGENT_PATH,
141
- sub: [{ agent: "UniversalAgent", name: chatId }],
142
- });
143
- const chatAgent = useMemo(
144
- () => createSafeUIMessageAgentConnection(agent),
145
- [agent],
146
- );
147
-
148
- // 轮询→推送:这条 chat 连接连的是 UniversalAgent facet,`agent.state` 即 facet 的
149
- // AgentState 广播(useAgent 内部 useState,收到 cf_agent_state 即 re-render)。待批项
150
- // 随它推来 —— 审批读侧复用这条已开的连接,不另开第二条 WebSocket。三态(原则 VII):
151
- // approvals === undefined = 首帧未到(未加载),不可当「空」。
152
- const facetState = agent.state;
153
- const agentRef = useRef(agent);
154
- agentRef.current = agent;
155
- const approvals = facetState?.approvals;
156
- const approvalsLoaded = facetState?.approvals !== undefined;
157
- const {
158
- messages,
159
- status: sdkStatus,
160
- isStreaming,
161
- error,
162
- // autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
163
- // 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
164
- isServerStreaming,
165
- isRecovering,
166
- isToolContinuation,
167
- connectionError,
168
- } = useAgentChat<RuntimeState, ChatMessage>({
169
- agent: chatAgent,
170
- syncMessagesToServer: false,
171
- throttle: 100,
172
- });
173
- const visibleMessages = useMemo(
174
- () => mergeOptimisticMessages(messages, optimisticMessages),
175
- [messages, optimisticMessages],
176
- );
177
- const authoritativeTurn = facetState?.turn;
178
- const stoppedByUser = error?.message === USER_STOP_REASON;
179
- const normalizedSdkStatus = stoppedByUser && sdkStatus === "error"
180
- ? "ready"
181
- : sdkStatus;
182
- // RPC admissions bypass useChat's request lifecycle. Project the durable
183
- // Turn here so presentation stays correct without a second send path.
184
- const authoritativeStatus =
185
- normalizedSdkStatus === "ready" &&
186
- !isRecovering &&
187
- approvals?.length === 0 &&
188
- authoritativeTurn?.activeSubmissionId
189
- ? isServerStreaming
190
- ? "streaming"
191
- : "submitted"
192
- : normalizedSdkStatus;
193
- const status = dispatchError
194
- ? "error"
195
- : optimisticMessages.length > 0 && authoritativeStatus === "ready"
196
- ? "submitted"
197
- : authoritativeStatus;
198
- const messagesRef = useRef(messages);
199
- messagesRef.current = messages;
200
- const turnTimingRef = useRef<ChatTurnTiming | null>(null);
201
-
202
- useEffect(() => {
203
- const timing = turnTimingRef.current;
204
- if (!timing) return;
205
- const assistant = messages.find((message) =>
206
- message.role === "assistant" &&
207
- !timing.baselineAssistantIds.has(message.id)
208
- );
209
- if (assistant) markChatTurnPhase(timing, "streamStart");
210
- if (
211
- assistant?.parts.some((part) =>
212
- part.type === "text" && part.text.trim().length > 0
213
- )
214
- ) {
215
- markChatTurnPhase(timing, "firstText");
216
- }
217
- if (status === "ready" && timing.streamStart !== undefined) {
218
- if (timing.firstText !== undefined) markChatTurnPhase(timing, "ready");
219
- turnTimingRef.current = null;
220
- }
221
- }, [messages, status]);
222
-
223
- useEffect(() => {
224
- if (optimisticMessages.length === 0 || messages.length === 0) return;
225
- const acknowledged = new Set(messages.map(({ id }) => id));
226
- setOptimisticMessages((current) => {
227
- const next = current.filter(({ id }) => !acknowledged.has(id));
228
- return next.length === current.length ? current : next;
229
- });
230
- }, [messages, optimisticMessages.length]);
231
-
232
- useEffect(() => {
233
- if (optimisticQueued.length === 0) return;
234
- const acknowledged = new Set([
235
- ...messages.map(({ id }) => id),
236
- ...(authoritativeTurn?.queued.map(({ messageId }) => messageId) ?? []),
237
- ]);
238
- if (acknowledged.size === 0) return;
239
- setOptimisticQueued((current) => {
240
- const next = current.filter(({ messageId }) =>
241
- !acknowledged.has(messageId)
242
- );
243
- return next.length === current.length ? current : next;
244
- });
245
- }, [authoritativeTurn?.queued, messages, optimisticQueued.length]);
246
-
247
- const turn = useMemo(() => {
248
- const visibleMessageIds = new Set(visibleMessages.map(({ id }) => id));
249
- const authoritativeQueued = authoritativeTurn?.queued.filter(
250
- ({ messageId }) => !visibleMessageIds.has(messageId),
251
- ) ?? [];
252
- const acknowledged = new Set([
253
- ...visibleMessageIds,
254
- ...(authoritativeTurn?.queued.map(({ messageId }) => messageId) ?? []),
255
- ]);
256
- const pending = optimisticQueued.filter(({ messageId }) =>
257
- !acknowledged.has(messageId)
258
- );
259
- if (
260
- pending.length === 0 &&
261
- authoritativeQueued.length === (authoritativeTurn?.queued.length ?? 0)
262
- ) {
263
- return authoritativeTurn;
264
- }
265
- const queued = [
266
- ...authoritativeQueued,
267
- ...pending,
268
- ].map((submission, index) => ({
269
- ...submission,
270
- position: index + 1,
271
- }));
272
- return {
273
- ...authoritativeTurn,
274
- steerable: authoritativeTurn?.steerable ?? false,
275
- hasPendingSteer: authoritativeTurn?.hasPendingSteer ?? false,
276
- queued,
277
- };
278
- }, [authoritativeTurn, visibleMessages, optimisticQueued]);
279
- const turnActive = Boolean(turn?.activeSubmissionId) ||
280
- status === "submitted" ||
281
- status === "streaming" ||
282
- isServerStreaming ||
283
- isRecovering ||
284
- isToolContinuation;
285
- const canSteer = turnActive &&
286
- (!turn?.activeSubmissionId || turn.steerable);
287
-
288
- // 子 agent 运行(agentTool 前台 + runAgentTool detached 后台)的实时事件投影:
289
- // 状态机 + progress snapshot + durable milestones(knowledge/upstream-adoption.csv F6)
290
- const { runsById } = useAgentToolEvents({ agent });
291
- const agentToolRuns = useMemo(
292
- () => Object.values(runsById).sort((a, b) => a.order - b.order),
293
- [runsById],
294
- );
295
-
296
- const dispatchMessage = useCallback(async (
297
- message: ChatMessage,
298
- delivery: MessageDelivery,
299
- ): Promise<MessageDispatchReceipt> =>
300
- agentRef.current.call<MessageDispatchReceipt>(
301
- "dispatchMessage",
302
- [message, delivery],
303
- ), []);
304
-
305
- const submitMessage = useCallback(async (
306
- message: ChatMessage,
307
- delivery: MessageDelivery,
308
- projection: "message" | "queue",
309
- ): Promise<boolean> => {
310
- setDispatchError(undefined);
311
- failedDispatchRef.current = undefined;
312
- setCanRetry(false);
313
- if (projection === "message" && delivery === "enqueue") {
314
- turnTimingRef.current = {
315
- chatId,
316
- messageId: message.id,
317
- baselineAssistantIds: new Set(
318
- messagesRef.current
319
- .filter(({ role }) => role === "assistant")
320
- .map(({ id }) => id),
321
- ),
322
- click: now(),
323
- };
324
- }
325
- if (projection === "message") {
326
- setOptimisticMessages((current) =>
327
- current.some(({ id }) => id === message.id)
328
- ? current
329
- : [...current, message]
330
- );
331
- } else {
332
- setOptimisticQueued((current) => [
333
- ...current,
334
- {
335
- submissionId: `optimistic:${message.id}`,
336
- messageId: message.id,
337
- preview: queuedPreview(message),
338
- position: current.length + 1,
339
- createdAt: Number(message.metadata?.createdAt ?? Date.now()),
340
- },
341
- ]);
342
- }
343
- const rollback = () => {
344
- if (projection === "message") {
345
- setOptimisticMessages((current) =>
346
- current.filter(({ id }) => id !== message.id)
347
- );
348
- } else {
349
- setOptimisticQueued((current) =>
350
- current.filter(({ messageId }) => messageId !== message.id)
351
- );
352
- }
353
- };
354
- try {
355
- const receipt = await dispatchMessage(message, delivery);
356
- if (turnTimingRef.current?.messageId === message.id) {
357
- markChatTurnPhase(turnTimingRef.current, "rpcReceipt");
358
- }
359
- if (receipt.kind === "rejected") {
360
- if (projection === "queue") rollback();
361
- if (turnTimingRef.current?.messageId === message.id) {
362
- turnTimingRef.current = null;
363
- }
364
- failedDispatchRef.current = { message, delivery, projection };
365
- setCanRetry(true);
366
- setDispatchError(receipt.message);
367
- return false;
368
- }
369
- if (projection === "message") {
370
- activeSubmissionIdRef.current = receipt.kind === "queued"
371
- ? receipt.submission.submissionId
372
- : receipt.submissionId;
373
- }
374
- if (projection === "queue") {
375
- if (receipt.kind !== "queued" || receipt.position < 1) {
376
- rollback();
377
- } else {
378
- setOptimisticQueued((current) => current.map((queued) =>
379
- queued.messageId === message.id
380
- ? {
381
- ...queued,
382
- submissionId: receipt.submission.submissionId,
383
- position: receipt.position,
384
- }
385
- : queued
386
- ));
387
- }
388
- }
389
- return true;
390
- } catch (cause) {
391
- if (projection === "queue") rollback();
392
- if (turnTimingRef.current?.messageId === message.id) {
393
- turnTimingRef.current = null;
394
- }
395
- failedDispatchRef.current = { message, delivery, projection };
396
- setCanRetry(true);
397
- setDispatchError(
398
- cause instanceof Error ? cause.message : String(cause),
399
- );
400
- return false;
401
- }
402
- }, [chatId, dispatchMessage]);
403
-
404
- const sendText = useCallback(async (
405
- text: string,
406
- files?: readonly FileUIPart[],
407
- ): Promise<boolean> => {
408
- const message = createChatMessage(text, files);
409
- return message
410
- ? submitMessage(message, "enqueue", "message")
411
- : false;
412
- }, [submitMessage]);
413
-
414
- const dispatchText = useCallback(async (
415
- text: string,
416
- files: readonly FileUIPart[] | undefined,
417
- delivery: MessageDelivery,
418
- projection: "message" | "queue",
419
- ): Promise<boolean> => {
420
- const message = createChatMessage(text, files);
421
- return message ? submitMessage(message, delivery, projection) : false;
422
- }, [submitMessage]);
423
-
424
- const steerText = useCallback(
425
- (text: string, files?: readonly FileUIPart[]) =>
426
- dispatchText(text, files, "steer", "message"),
427
- [dispatchText],
428
- );
429
- const enqueueText = useCallback(
430
- (text: string, files?: readonly FileUIPart[]) =>
431
- dispatchText(text, files, "enqueue", "queue"),
432
- [dispatchText],
433
- );
434
- const steerQueued = useCallback(async (submissionId: string) => {
435
- setDispatchError(undefined);
436
- try {
437
- const receipt = await agentRef.current.call<MessageDispatchReceipt>(
438
- "steerQueuedSubmission",
439
- [submissionId],
440
- );
441
- if (receipt.kind !== "steered") {
442
- setDispatchError(
443
- receipt.kind === "rejected"
444
- ? receipt.message
445
- : "The queued message could not be steered",
446
- );
447
- return false;
448
- }
449
- if (receipt.message) {
450
- const message = receipt.message as ChatMessage;
451
- setOptimisticMessages((current) =>
452
- current.some(({ id }) => id === message.id)
453
- ? current
454
- : [...current, message]
455
- );
456
- }
457
- setOptimisticQueued((current) =>
458
- current.filter((queued) => queued.submissionId !== submissionId)
459
- );
460
- return true;
461
- } catch (cause) {
462
- setDispatchError(
463
- cause instanceof Error ? cause.message : String(cause),
464
- );
465
- return false;
466
- }
467
- }, []);
468
- const cancelQueued = useCallback(async (submissionId: string) => {
469
- const result = await agentRef.current.call<{ ok: boolean }>(
470
- "cancelSubmissionById",
471
- [submissionId, "Cancelled from queue"],
472
- );
473
- if (result.ok) {
474
- setOptimisticQueued((current) =>
475
- current.filter((queued) => queued.submissionId !== submissionId)
476
- );
477
- }
478
- return result.ok;
479
- }, []);
480
-
481
- const stop = useCallback(async () => {
482
- const submissionId = authoritativeTurn?.activeSubmissionId ??
483
- activeSubmissionIdRef.current;
484
- if (!submissionId) return;
485
- setDispatchError(undefined);
486
- try {
487
- const result = await agentRef.current.call<{ ok: boolean }>(
488
- "cancelSubmissionById",
489
- [submissionId, USER_STOP_REASON],
490
- );
491
- if (!result.ok) setDispatchError("The active Turn could not be stopped");
492
- } catch (cause) {
493
- setDispatchError(
494
- cause instanceof Error ? cause.message : String(cause),
495
- );
496
- }
497
- }, [authoritativeTurn?.activeSubmissionId]);
498
-
499
- const retry = useCallback(() => {
500
- const failed = failedDispatchRef.current;
501
- if (!failed) return;
502
- void submitMessage(
503
- failed.message,
504
- failed.delivery,
505
- failed.projection,
506
- );
507
- }, [submitMessage]);
508
-
509
- return {
510
- messages: visibleMessages,
511
- status,
512
- runtimeLoad: facetState?.runtimeLoad,
513
- isStreaming,
514
- error: dispatchError ?? (stoppedByUser ? undefined : error) ??
515
- connectionError ?? undefined,
516
- sendText,
517
- steerText,
518
- enqueueText,
519
- steerQueued,
520
- cancelQueued,
521
- stop,
522
- regenerate: retry,
523
- canRetry,
524
- agentToolRuns,
525
- // 流态标志:
526
- // - isServerStreaming:服务端主动推流(子 agent 回投/续跑),非用户发起
527
- // - isRecovering:durable turn 恢复中(被 deploy/eviction 或 stall 看门狗中断后重连)
528
- // - isToolContinuation:工具续跑轮次(区分「用户刚发消息等首 token」)
529
- isServerStreaming,
530
- isRecovering,
531
- isToolContinuation,
532
- // facet 推送来的审批快照(会话级)。ApprovalsProvider 复用它,零轮询。
533
- approvals,
534
- approvalsLoaded,
535
- turn,
536
- turnActive,
537
- canSteer,
538
- };
539
- }
540
-
541
- export type ChatRuntime = ReturnType<typeof useUniversalAgentChat>;
542
- export type AgentToolRun = ChatRuntime["agentToolRuns"][number];