@springbrand/chat-client 0.1.0

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 ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@springbrand/chat-client",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "src"
7
+ ],
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.ts"
13
+ },
14
+ "peerDependencies": {
15
+ "agents": "^0.19.0",
16
+ "ai": "^7.0.0",
17
+ "react": "^19.0.0"
18
+ },
19
+ "dependencies": {
20
+ "nanoid": "^5.1.16",
21
+ "@springbrand/agent-runtime": "0.1.0"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^19.2.17",
25
+ "@types/react-dom": "^19.2.3",
26
+ "react-dom": "^19.2.7",
27
+ "typescript": "^7.0.2"
28
+ },
29
+ "scripts": {
30
+ "typecheck": "tsc --noEmit"
31
+ }
32
+ }
@@ -0,0 +1,238 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { nanoid } from "nanoid";
3
+ import type { FileUIPart } from "ai";
4
+
5
+ const VISION_MAX_BYTES = 500 * 1024;
6
+ const VISION_TYPES = /^image\/(png|jpe?g|webp|gif)$/i;
7
+
8
+ export interface UploadReceipt {
9
+ path: string;
10
+ size: number;
11
+ mime: string;
12
+ markdownPath?: string | null;
13
+ }
14
+
15
+ export function isVisionFile(meta: { mediaType?: string; size?: number }): boolean {
16
+ return (
17
+ VISION_TYPES.test(meta.mediaType ?? "") && (meta.size ?? 0) <= VISION_MAX_BYTES
18
+ );
19
+ }
20
+
21
+ export async function uploadFileToWorkspace(
22
+ file: File,
23
+ chatId: string,
24
+ ): Promise<UploadReceipt> {
25
+ const form = new FormData();
26
+ form.append("file", file);
27
+ const query = new URLSearchParams({ chat: chatId });
28
+ const response = await fetch(`/api/files?${query.toString()}`, {
29
+ method: "POST",
30
+ body: form,
31
+ });
32
+ if (!response.ok) throw new Error(`upload failed: ${response.status}`);
33
+ return (await response.json()) as UploadReceipt;
34
+ }
35
+
36
+ interface VisionUploadPart {
37
+ filename?: string;
38
+ mediaType?: string;
39
+ url: string;
40
+ }
41
+
42
+ async function toDataUrl<T extends VisionUploadPart>(part: T): Promise<T> {
43
+ if (!part.url.startsWith("blob:")) return part;
44
+ try {
45
+ const blob = await (await fetch(part.url)).blob();
46
+ const dataUrl = await new Promise<string | null>((resolve) => {
47
+ const reader = new FileReader();
48
+ reader.onloadend = () => resolve(reader.result as string);
49
+ reader.onerror = () => resolve(null);
50
+ reader.readAsDataURL(blob);
51
+ });
52
+ return { ...part, url: dataUrl ?? part.url };
53
+ } catch {
54
+ return part;
55
+ }
56
+ }
57
+
58
+ export type AttachmentStatus = "uploading" | "ready" | "error";
59
+
60
+ export type Attachment = {
61
+ id: string;
62
+ type: "file";
63
+ filename?: string;
64
+ mediaType?: string;
65
+ url: string;
66
+ size?: number;
67
+ isVision: boolean;
68
+ status: AttachmentStatus;
69
+ receipt?: UploadReceipt;
70
+ };
71
+
72
+ export async function prepareAttachmentParts(
73
+ files: readonly Attachment[],
74
+ ): Promise<FileUIPart[]> {
75
+ return Promise.all(files.map(async (file): Promise<FileUIPart> => {
76
+ if (file.isVision) {
77
+ const prepared = await toDataUrl({
78
+ filename: file.filename,
79
+ mediaType: file.mediaType,
80
+ url: file.url,
81
+ });
82
+ if (prepared.url.startsWith("blob:")) {
83
+ throw new Error(`${file.filename ?? "Image"} could not be prepared`);
84
+ }
85
+ return {
86
+ type: "file",
87
+ ...(prepared.filename ? { filename: prepared.filename } : {}),
88
+ mediaType: prepared.mediaType || "application/octet-stream",
89
+ url: prepared.url,
90
+ };
91
+ }
92
+ if (!file.receipt) {
93
+ throw new Error(`${file.filename ?? "Attachment"} is not uploaded`);
94
+ }
95
+ return {
96
+ type: "file",
97
+ ...(file.filename ? { filename: file.filename } : {}),
98
+ mediaType: file.receipt.mime,
99
+ url: file.receipt.path,
100
+ };
101
+ }));
102
+ }
103
+
104
+ /** 浏览器附件 module:公共 Composer 负责选择与展示,这里维护 Workspace 上传和提交状态。 */
105
+ export function useWorkspaceAttachments(chatId: string | null) {
106
+ const [files, setFiles] = useState<Attachment[]>([]);
107
+ const rawRef = useRef(new Map<string, File>());
108
+
109
+ const patch = useCallback((id: string, value: Partial<Attachment>) => {
110
+ setFiles((current) =>
111
+ current.map((file) => (file.id === id ? { ...file, ...value } : file)),
112
+ );
113
+ }, []);
114
+
115
+ const startUpload = useCallback(
116
+ (id: string, file: File) => {
117
+ patch(id, { status: "uploading" });
118
+ if (!chatId) {
119
+ patch(id, { status: "error" });
120
+ return;
121
+ }
122
+ uploadFileToWorkspace(file, chatId)
123
+ .then((receipt) => patch(id, { status: "ready", receipt }))
124
+ .catch(() => patch(id, { status: "error" }));
125
+ },
126
+ [chatId, patch],
127
+ );
128
+
129
+ const add = useCallback(
130
+ (incoming: readonly File[]) => {
131
+ if (incoming.length === 0) return;
132
+ const entries = incoming.map((file) => {
133
+ const id = nanoid();
134
+ rawRef.current.set(id, file);
135
+ const isVision = isVisionFile({
136
+ mediaType: file.type,
137
+ size: file.size,
138
+ });
139
+ return {
140
+ id,
141
+ type: "file" as const,
142
+ filename: file.name,
143
+ mediaType: file.type,
144
+ size: file.size,
145
+ url: URL.createObjectURL(file),
146
+ isVision,
147
+ status: (isVision ? "ready" : "uploading") as AttachmentStatus,
148
+ };
149
+ });
150
+ setFiles((current) => [...current, ...entries]);
151
+ for (const entry of entries) {
152
+ if (!entry.isVision) {
153
+ const raw = rawRef.current.get(entry.id);
154
+ if (raw) startUpload(entry.id, raw);
155
+ }
156
+ }
157
+ },
158
+ [startUpload],
159
+ );
160
+
161
+ const retry = useCallback(
162
+ (id: string) => {
163
+ const raw = rawRef.current.get(id);
164
+ if (raw) startUpload(id, raw);
165
+ },
166
+ [startUpload],
167
+ );
168
+
169
+ const remove = useCallback((id: string) => {
170
+ rawRef.current.delete(id);
171
+ setFiles((current) => {
172
+ const found = current.find((file) => file.id === id);
173
+ if (found?.url.startsWith("blob:")) URL.revokeObjectURL(found.url);
174
+ return current.filter((file) => file.id !== id);
175
+ });
176
+ }, []);
177
+
178
+ const reorder = useCallback((fromIndex: number, toIndex: number) => {
179
+ setFiles((current) => {
180
+ if (
181
+ fromIndex < 0 ||
182
+ toIndex < 0 ||
183
+ fromIndex >= current.length ||
184
+ toIndex >= current.length
185
+ ) {
186
+ return current;
187
+ }
188
+ const next = [...current];
189
+ const [moved] = next.splice(fromIndex, 1);
190
+ next.splice(toIndex, 0, moved);
191
+ return next;
192
+ });
193
+ }, []);
194
+
195
+ const clear = useCallback(() => {
196
+ rawRef.current.clear();
197
+ setFiles((current) => {
198
+ for (const file of current) {
199
+ if (file.url.startsWith("blob:")) URL.revokeObjectURL(file.url);
200
+ }
201
+ return [];
202
+ });
203
+ }, []);
204
+
205
+ const allReady = useMemo(
206
+ () => files.every((file) => file.status === "ready"),
207
+ [files],
208
+ );
209
+
210
+ const toSubmit = useCallback(
211
+ () => prepareAttachmentParts(files),
212
+ [files],
213
+ );
214
+
215
+ const filesRef = useRef(files);
216
+ useEffect(() => {
217
+ filesRef.current = files;
218
+ }, [files]);
219
+ useEffect(
220
+ () => () => {
221
+ for (const file of filesRef.current) {
222
+ if (file.url.startsWith("blob:")) URL.revokeObjectURL(file.url);
223
+ }
224
+ },
225
+ [],
226
+ );
227
+
228
+ return {
229
+ files,
230
+ add,
231
+ remove,
232
+ retry,
233
+ reorder,
234
+ clear,
235
+ allReady,
236
+ toSubmit,
237
+ };
238
+ }
@@ -0,0 +1,99 @@
1
+ import { useCallback, useRef } from "react";
2
+ import { useAgent } from "agents/react";
3
+ import type { RuntimeActivity } from "@springbrand/agent-runtime/contracts";
4
+
5
+ export interface ChatSessionSummary {
6
+ id: string;
7
+ userAgentId: string;
8
+ title: string;
9
+ titleSource: "default" | "generated" | "manual";
10
+ createdAt: number;
11
+ updatedAt: number;
12
+ pinned: boolean;
13
+ archived: boolean;
14
+ activity: RuntimeActivity;
15
+ usage?: {
16
+ totalTokens: number;
17
+ totalCost: number;
18
+ };
19
+ }
20
+
21
+ interface InboxState {
22
+ chats: ChatSessionSummary[];
23
+ workspaceRev?: number;
24
+ }
25
+
26
+ export const CURRENT_INBOX_AGENT_PATH = "api/agent-connections/inbox";
27
+
28
+ /** Browser client for the user Inbox. Routing and presentation stay with the host UI. */
29
+ export function useChatSessions() {
30
+ const inbox = useAgent<InboxState>({
31
+ agent: "Inbox",
32
+ basePath: CURRENT_INBOX_AGENT_PATH,
33
+ });
34
+ const creatingRef = useRef(false);
35
+
36
+ const createChat = useCallback(async (userAgentId?: string) => {
37
+ if (creatingRef.current) return undefined;
38
+ creatingRef.current = true;
39
+ try {
40
+ return await inbox.call<ChatSessionSummary>(
41
+ "createChat",
42
+ userAgentId ? [{ userAgentId }] : undefined,
43
+ );
44
+ } finally {
45
+ creatingRef.current = false;
46
+ }
47
+ }, [inbox]);
48
+
49
+ const forkChat = useCallback(
50
+ (sourceChatId: string, messageId: string) =>
51
+ inbox.call<ChatSessionSummary>("forkChat", [sourceChatId, messageId]),
52
+ [inbox],
53
+ );
54
+
55
+ const renameChat = useCallback(async (id: string, title: string) => {
56
+ const normalizedTitle = title.trim();
57
+ if (!normalizedTitle) return;
58
+ await inbox.call("renameChat", [id, normalizedTitle]);
59
+ }, [inbox]);
60
+
61
+ const deleteChat = useCallback(async (id: string) => {
62
+ await inbox.call("deleteChat", [id]);
63
+ }, [inbox]);
64
+
65
+ const pinChat = useCallback(async (id: string, pinned: boolean) => {
66
+ await inbox.call("pinChat", [id, pinned]);
67
+ }, [inbox]);
68
+
69
+ const archiveChat = useCallback(async (id: string, archived: boolean) => {
70
+ await inbox.call("archiveChat", [id, archived]);
71
+ }, [inbox]);
72
+
73
+ const reloadChatRuntime = useCallback(
74
+ async (chatId: string, userAgentId: string) => {
75
+ const result = await inbox.call<{ userAgentId: string } | null>(
76
+ "chatReloadRuntime",
77
+ [chatId, userAgentId],
78
+ );
79
+ if (!result) throw new Error("Session is unavailable");
80
+ return result;
81
+ },
82
+ [inbox],
83
+ );
84
+
85
+ return {
86
+ chats: inbox.state?.chats ?? [],
87
+ synced: inbox.state !== undefined,
88
+ workspaceRev: inbox.state?.workspaceRev ?? 0,
89
+ createChat,
90
+ forkChat,
91
+ renameChat,
92
+ deleteChat,
93
+ pinChat,
94
+ archiveChat,
95
+ reloadChatRuntime,
96
+ };
97
+ }
98
+
99
+ export type ChatSessions = ReturnType<typeof useChatSessions>;
@@ -0,0 +1,119 @@
1
+ import { isToolUIPart, type UIMessage } from "ai";
2
+
3
+ export interface ChatSummarySource {
4
+ kind: "file" | "link";
5
+ label: string;
6
+ href?: string;
7
+ }
8
+
9
+ export interface ChatMessageSummary {
10
+ activityCount: number;
11
+ currentGoal: string;
12
+ sources: ChatSummarySource[];
13
+ }
14
+
15
+ export function extractUrls(output: unknown): string[] {
16
+ if (output == null) return [];
17
+ const text = typeof output === "string" ? output : JSON.stringify(output);
18
+ const matches = text.match(/https?:\/\/[^\s"'<>()[\]\\]+/g) ?? [];
19
+ return [...new Set(matches.map((url) => url.replace(/[.,;:!?]+$/, "")))];
20
+ }
21
+
22
+ function urlLabel(url: string): string {
23
+ try {
24
+ const parsed = new URL(url);
25
+ return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`;
26
+ } catch {
27
+ return url;
28
+ }
29
+ }
30
+
31
+ function stringField(
32
+ record: Record<string, unknown>,
33
+ field: string,
34
+ ): string | undefined {
35
+ return typeof record[field] === "string" ? record[field] : undefined;
36
+ }
37
+
38
+ export function summarizeChatMessages(
39
+ messages: readonly UIMessage[],
40
+ ): ChatMessageSummary {
41
+ const sources: ChatSummarySource[] = [];
42
+ const seen = new Set<string>();
43
+ let activityCount = 0;
44
+ let currentGoal = "";
45
+
46
+ const add = (key: string, source: ChatSummarySource) => {
47
+ if (seen.has(key)) return;
48
+ seen.add(key);
49
+ sources.push(source);
50
+ };
51
+
52
+ const addUrls = (value: unknown) => {
53
+ for (const url of extractUrls(value)) {
54
+ add(`url:${url}`, {
55
+ kind: "link",
56
+ label: urlLabel(url),
57
+ href: url,
58
+ });
59
+ }
60
+ };
61
+
62
+ for (const message of messages) {
63
+ if (message.role === "user") {
64
+ const text = message.parts
65
+ .flatMap((part) =>
66
+ part.type === "text" && typeof part.text === "string"
67
+ ? [part.text]
68
+ : []
69
+ )
70
+ .join(" ")
71
+ .trim();
72
+ if (text) currentGoal = text;
73
+ }
74
+
75
+ for (const part of message.parts) {
76
+ const record = part as Record<string, unknown>;
77
+ if (part.type === "file") {
78
+ const url = part.url;
79
+ const label = part.filename ?? part.mediaType ?? "附件";
80
+ add(`file:${url || label}`, {
81
+ kind: "file",
82
+ label,
83
+ ...(url.startsWith("http") ? { href: url } : {}),
84
+ });
85
+ } else if (part.type === "source-document") {
86
+ const sourceId = stringField(record, "sourceId") ?? "";
87
+ add(`document:${sourceId}`, {
88
+ kind: "file",
89
+ label:
90
+ stringField(record, "filename") ??
91
+ stringField(record, "title") ??
92
+ "文档",
93
+ });
94
+ } else if (part.type === "source-url") {
95
+ const url = stringField(record, "url");
96
+ if (!url) continue;
97
+ add(`url:${url}`, {
98
+ kind: "link",
99
+ label: stringField(record, "title") || urlLabel(url),
100
+ href: url,
101
+ });
102
+ }
103
+ }
104
+
105
+ for (const part of message.parts) {
106
+ if (isToolUIPart(part)) {
107
+ activityCount += 1;
108
+ addUrls((part as Record<string, unknown>).output);
109
+ }
110
+ }
111
+
112
+ for (const part of message.parts) {
113
+ if (part.type === "text" || part.type === "reasoning") addUrls(part.text);
114
+ }
115
+ addUrls(message.metadata);
116
+ }
117
+
118
+ return { activityCount, currentGoal, sources: sources.slice(0, 5) };
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export {
2
+ useUniversalAgentChat,
3
+ type AgentToolRun,
4
+ type ChatRuntime,
5
+ } from "./use-universal-agent-chat";
6
+ export {
7
+ prepareAttachmentParts,
8
+ useWorkspaceAttachments,
9
+ type Attachment,
10
+ type AttachmentStatus,
11
+ type UploadReceipt,
12
+ } from "./chat-attachments";
13
+ export {
14
+ extractUrls,
15
+ summarizeChatMessages,
16
+ type ChatMessageSummary,
17
+ type ChatSummarySource,
18
+ } from "./chat-summary";
19
+ export {
20
+ useChatSessions,
21
+ type ChatSessionSummary,
22
+ type ChatSessions,
23
+ } from "./chat-sessions";
@@ -0,0 +1,139 @@
1
+ import type { UIMessageChunk } from "ai";
2
+
3
+ type AgentListener = (event: MessageEvent) => void;
4
+
5
+ interface AgentConnection {
6
+ addEventListener(
7
+ type: string,
8
+ listener: AgentListener,
9
+ options?: AddEventListenerOptions,
10
+ ): void;
11
+ removeEventListener(type: string, listener: AgentListener): void;
12
+ }
13
+
14
+ interface StreamState {
15
+ readonly text: Set<string>;
16
+ readonly reasoning: Set<string>;
17
+ readonly toolInput: Set<string>;
18
+ }
19
+
20
+ function createStreamState(): StreamState {
21
+ return {
22
+ text: new Set(),
23
+ reasoning: new Set(),
24
+ toolInput: new Set(),
25
+ };
26
+ }
27
+
28
+ function acceptsChunk(state: StreamState, chunk: UIMessageChunk): boolean {
29
+ switch (chunk.type) {
30
+ case "text-start":
31
+ state.text.add(chunk.id);
32
+ return true;
33
+ case "text-delta":
34
+ return state.text.has(chunk.id);
35
+ case "text-end":
36
+ return state.text.delete(chunk.id);
37
+ case "reasoning-start":
38
+ state.reasoning.add(chunk.id);
39
+ return true;
40
+ case "reasoning-delta":
41
+ return state.reasoning.has(chunk.id);
42
+ case "reasoning-end":
43
+ return state.reasoning.delete(chunk.id);
44
+ case "tool-input-start":
45
+ state.toolInput.add(chunk.toolCallId);
46
+ return true;
47
+ case "tool-input-delta":
48
+ return state.toolInput.has(chunk.toolCallId);
49
+ case "finish-step":
50
+ state.text.clear();
51
+ state.reasoning.clear();
52
+ return true;
53
+ default:
54
+ return true;
55
+ }
56
+ }
57
+
58
+ /** Drops orphan AI SDK streaming chunks so one malformed part cannot stop chat. */
59
+ export function createSafeUIMessageAgentConnection<T extends AgentConnection>(
60
+ agent: T,
61
+ ): T {
62
+ const states = new Map<string, StreamState>();
63
+ const decisions = new WeakMap<MessageEvent, boolean>();
64
+ const listenerMaps = new Map<string, Map<AgentListener, AgentListener>>();
65
+ const bound = new Map<PropertyKey, unknown>();
66
+
67
+ const shouldForward = (event: MessageEvent): boolean => {
68
+ const decided = decisions.get(event);
69
+ if (decided !== undefined) return decided;
70
+
71
+ let forward = true;
72
+ try {
73
+ const frame = JSON.parse(event.data) as {
74
+ type?: unknown;
75
+ id?: unknown;
76
+ body?: unknown;
77
+ done?: unknown;
78
+ };
79
+ if (
80
+ frame.type === "cf_agent_use_chat_response" &&
81
+ typeof frame.id === "string"
82
+ ) {
83
+ if (typeof frame.body === "string" && frame.body.trim()) {
84
+ const chunk = JSON.parse(frame.body) as UIMessageChunk;
85
+ const state = chunk.type === "start"
86
+ ? createStreamState()
87
+ : states.get(frame.id) ?? createStreamState();
88
+ states.set(frame.id, state);
89
+ forward = acceptsChunk(state, chunk);
90
+ }
91
+ if (frame.done === true) states.delete(frame.id);
92
+ }
93
+ } catch {
94
+ // Leave unrelated or malformed frames to the owning SDK listener.
95
+ }
96
+ decisions.set(event, forward);
97
+ return forward;
98
+ };
99
+
100
+ const addEventListener = (
101
+ type: string,
102
+ listener: AgentListener,
103
+ options?: AddEventListenerOptions,
104
+ ) => {
105
+ if (type !== "message") {
106
+ agent.addEventListener(type, listener, options);
107
+ return;
108
+ }
109
+ const wrapped: AgentListener = (event) => {
110
+ if (typeof event.data !== "string" || shouldForward(event)) listener(event);
111
+ };
112
+ const listeners = listenerMaps.get(type) ?? new Map();
113
+ listeners.set(listener, wrapped);
114
+ listenerMaps.set(type, listeners);
115
+ options?.signal?.addEventListener(
116
+ "abort",
117
+ () => listeners.delete(listener),
118
+ { once: true },
119
+ );
120
+ agent.addEventListener(type, wrapped, options);
121
+ };
122
+
123
+ const removeEventListener = (type: string, listener: AgentListener) => {
124
+ const wrapped = listenerMaps.get(type)?.get(listener) ?? listener;
125
+ listenerMaps.get(type)?.delete(listener);
126
+ agent.removeEventListener(type, wrapped);
127
+ };
128
+
129
+ return new Proxy(agent, {
130
+ get(target, property) {
131
+ if (property === "addEventListener") return addEventListener;
132
+ if (property === "removeEventListener") return removeEventListener;
133
+ const value = Reflect.get(target, property, target) as unknown;
134
+ if (typeof value !== "function") return value;
135
+ if (!bound.has(property)) bound.set(property, value.bind(target));
136
+ return bound.get(property);
137
+ },
138
+ });
139
+ }
@@ -0,0 +1,542 @@
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];