@springbrand/chat-client 0.1.2 → 0.1.3-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,18 +1,16 @@
1
1
  {
2
2
  "name": "@springbrand/chat-client",
3
- "version": "0.1.2",
3
+ "version": "0.1.3-alpha.1",
4
4
  "type": "module",
5
5
  "files": [
6
- "dist"
6
+ "src",
7
+ "!src/**/*.test.ts"
7
8
  ],
8
9
  "publishConfig": {
9
10
  "access": "public"
10
11
  },
11
12
  "exports": {
12
- ".": {
13
- "types": "./dist/index.d.ts",
14
- "import": "./dist/index.js"
15
- }
13
+ ".": "./src/index.ts"
16
14
  },
17
15
  "peerDependencies": {
18
16
  "agents": "^0.19.0",
@@ -27,10 +25,9 @@
27
25
  "@types/react-dom": "^19.2.3",
28
26
  "react-dom": "^19.2.7",
29
27
  "typescript": "^7.0.2",
30
- "@springbrand/agent-runtime": "0.1.3-alpha.0"
28
+ "@springbrand/agent-runtime": "0.1.3-alpha.2"
31
29
  },
32
30
  "scripts": {
33
- "build": "tsc -p tsconfig.build.json",
34
31
  "typecheck": "tsc --noEmit"
35
32
  }
36
33
  }
@@ -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,120 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useAgent } from "agents/react";
3
+
4
+ interface InboxState<Session> {
5
+ chats: Session[];
6
+ workspaceRev?: number;
7
+ }
8
+
9
+ export const CURRENT_INBOX_AGENT_PATH = "api/agent-connections/inbox";
10
+
11
+ type Request = typeof fetch;
12
+ export type ChatSessionsOptions = {
13
+ protocols?: string | string[];
14
+ };
15
+
16
+ export async function loadChatSessions<Session>(
17
+ signal?: AbortSignal,
18
+ request: Request = fetch,
19
+ ): Promise<Session[]> {
20
+ const response = await request("/api/chats", {
21
+ cache: "no-store",
22
+ headers: { Accept: "application/json" },
23
+ signal,
24
+ });
25
+ if (!response.ok) throw new Error("Unable to load chat sessions");
26
+ const body = await response.json().catch(() => null) as
27
+ | { chats?: unknown }
28
+ | null;
29
+ if (!Array.isArray(body?.chats)) {
30
+ throw new Error("Invalid chat sessions response");
31
+ }
32
+ return body.chats as Session[];
33
+ }
34
+
35
+ /** Browser client for the user Inbox. Routing and presentation stay with the host UI. */
36
+ export function useChatSessions<Session>(options: ChatSessionsOptions = {}) {
37
+ const inbox = useAgent<InboxState<Session>>({
38
+ agent: "Inbox",
39
+ basePath: CURRENT_INBOX_AGENT_PATH,
40
+ ...(options.protocols === undefined ? {} : { protocols: options.protocols }),
41
+ });
42
+ const [snapshot, setSnapshot] = useState<Session[]>();
43
+ const creatingRef = useRef(false);
44
+
45
+ useEffect(() => {
46
+ const controller = new AbortController();
47
+ void loadChatSessions<Session>(controller.signal)
48
+ .then(setSnapshot)
49
+ .catch(() => undefined); // WebSocket state remains the fallback.
50
+ return () => controller.abort();
51
+ }, []);
52
+
53
+ const createChat = useCallback(async (userAgentId?: string) => {
54
+ if (creatingRef.current) return undefined;
55
+ creatingRef.current = true;
56
+ try {
57
+ await inbox.ready;
58
+ return await inbox.call<Session>(
59
+ "createChat",
60
+ userAgentId ? [{ userAgentId }] : undefined,
61
+ );
62
+ } finally {
63
+ creatingRef.current = false;
64
+ }
65
+ }, [inbox]);
66
+
67
+ const forkChat = useCallback(
68
+ (sourceChatId: string, messageId: string) =>
69
+ inbox.call<Session>("forkChat", [sourceChatId, messageId]),
70
+ [inbox],
71
+ );
72
+
73
+ const renameChat = useCallback(async (id: string, title: string) => {
74
+ const normalizedTitle = title.trim();
75
+ if (!normalizedTitle) return;
76
+ await inbox.call("renameChat", [id, normalizedTitle]);
77
+ }, [inbox]);
78
+
79
+ const deleteChat = useCallback(async (id: string) => {
80
+ await inbox.call("deleteChat", [id]);
81
+ }, [inbox]);
82
+
83
+ const pinChat = useCallback(async (id: string, pinned: boolean) => {
84
+ await inbox.call("pinChat", [id, pinned]);
85
+ }, [inbox]);
86
+
87
+ const archiveChat = useCallback(async (id: string, archived: boolean) => {
88
+ await inbox.call("archiveChat", [id, archived]);
89
+ }, [inbox]);
90
+
91
+ const reloadChatRuntime = useCallback(
92
+ async (chatId: string, userAgentId: string) => {
93
+ const result = await inbox.call<{ userAgentId: string } | null>(
94
+ "chatReloadRuntime",
95
+ [chatId, userAgentId],
96
+ );
97
+ if (!result) throw new Error("Session is unavailable");
98
+ return result;
99
+ },
100
+ [inbox],
101
+ );
102
+
103
+ return {
104
+ chats: inbox.state?.chats ?? snapshot ?? [],
105
+ synced: inbox.state !== undefined || snapshot !== undefined,
106
+ connected: inbox.identified,
107
+ workspaceRev: inbox.state?.workspaceRev ?? 0,
108
+ createChat,
109
+ forkChat,
110
+ renameChat,
111
+ deleteChat,
112
+ pinChat,
113
+ archiveChat,
114
+ reloadChatRuntime,
115
+ };
116
+ }
117
+
118
+ export type ChatSessions<Session> = ReturnType<
119
+ typeof useChatSessions<Session>
120
+ >;
@@ -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,24 @@
1
+ export {
2
+ useUniversalAgentChat,
3
+ type AgentToolRun,
4
+ type ChatConnectionOptions,
5
+ type ChatRuntime,
6
+ } from "./use-universal-agent-chat";
7
+ export {
8
+ prepareAttachmentParts,
9
+ useWorkspaceAttachments,
10
+ type Attachment,
11
+ type AttachmentStatus,
12
+ type UploadReceipt,
13
+ } from "./chat-attachments";
14
+ export {
15
+ extractUrls,
16
+ summarizeChatMessages,
17
+ type ChatMessageSummary,
18
+ type ChatSummarySource,
19
+ } from "./chat-summary";
20
+ export {
21
+ useChatSessions,
22
+ type ChatSessionsOptions,
23
+ type ChatSessions,
24
+ } 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
+ }