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