@tanstack/ai-client 0.18.5 → 0.18.6

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.
Files changed (48) hide show
  1. package/dist/esm/chat-client.d.ts +264 -0
  2. package/dist/esm/chat-client.js +1097 -0
  3. package/dist/esm/chat-client.js.map +1 -0
  4. package/dist/esm/client-persistor.d.ts +86 -0
  5. package/dist/esm/client-persistor.js +243 -0
  6. package/dist/esm/client-persistor.js.map +1 -0
  7. package/dist/esm/connection-adapters.d.ts +203 -0
  8. package/dist/esm/connection-adapters.js +611 -0
  9. package/dist/esm/connection-adapters.js.map +1 -0
  10. package/dist/esm/devtools-noop.d.ts +63 -0
  11. package/dist/esm/devtools-noop.js +141 -0
  12. package/dist/esm/devtools-noop.js.map +1 -0
  13. package/dist/esm/devtools.d.ts +276 -0
  14. package/dist/esm/devtools.js +1178 -0
  15. package/dist/esm/devtools.js.map +1 -0
  16. package/dist/esm/events.d.ts +129 -0
  17. package/dist/esm/events.js +227 -0
  18. package/dist/esm/events.js.map +1 -0
  19. package/dist/esm/generation-client.d.ts +99 -0
  20. package/dist/esm/generation-client.js +325 -0
  21. package/dist/esm/generation-client.js.map +1 -0
  22. package/dist/esm/generation-types.d.ts +242 -0
  23. package/dist/esm/generation-types.js +14 -0
  24. package/dist/esm/generation-types.js.map +1 -0
  25. package/dist/esm/index.d.ts +16 -0
  26. package/dist/esm/index.js +44 -0
  27. package/dist/esm/index.js.map +1 -0
  28. package/dist/esm/realtime-client.d.ts +98 -0
  29. package/dist/esm/realtime-client.js +403 -0
  30. package/dist/esm/realtime-client.js.map +1 -0
  31. package/dist/esm/realtime-types.d.ts +78 -0
  32. package/dist/esm/response-stream.d.ts +7 -0
  33. package/dist/esm/response-stream.js +32 -0
  34. package/dist/esm/response-stream.js.map +1 -0
  35. package/dist/esm/sse-parser.d.ts +8 -0
  36. package/dist/esm/sse-parser.js +52 -0
  37. package/dist/esm/sse-parser.js.map +1 -0
  38. package/dist/esm/sse-utils.d.ts +1 -0
  39. package/dist/esm/sse-utils.js +11 -0
  40. package/dist/esm/sse-utils.js.map +1 -0
  41. package/dist/esm/tool-types.d.ts +20 -0
  42. package/dist/esm/types.d.ts +419 -0
  43. package/dist/esm/types.js +11 -0
  44. package/dist/esm/types.js.map +1 -0
  45. package/dist/esm/video-generation-client.d.ts +110 -0
  46. package/dist/esm/video-generation-client.js +382 -0
  47. package/dist/esm/video-generation-client.js.map +1 -0
  48. package/package.json +3 -3
@@ -0,0 +1,1097 @@
1
+ import { StreamProcessor, normalizeToUIMessage, generateMessageId, convertSchemaToJsonSchema, isStandardSchema, parseWithStandardSchema } from "@tanstack/ai/client";
2
+ import { createNoOpChatDevtoolsBridge } from "./devtools-noop.js";
3
+ import { normalizeConnectionAdapter, getChunkRunId, fetcherToConnectionAdapter } from "./connection-adapters.js";
4
+ import { ChatPersistor } from "./client-persistor.js";
5
+ function resolveTransport(transport) {
6
+ const { connection, fetcher } = transport;
7
+ if (connection && fetcher) {
8
+ throw new Error(
9
+ "ChatClient: pass either `connection` or `fetcher`, not both."
10
+ );
11
+ }
12
+ if (connection) return connection;
13
+ if (fetcher) return fetcherToConnectionAdapter(fetcher);
14
+ throw new Error("ChatClient: either `connection` or `fetcher` is required.");
15
+ }
16
+ class ChatClient {
17
+ processor;
18
+ connection;
19
+ uniqueId;
20
+ threadId;
21
+ // All persistence concerns (hydrate / save / clear, plus suppression of late
22
+ // chunks after a mid-stream clear) live in ChatPersistor so this class stays
23
+ // focused on streaming. Undefined when no `persistence` adapter is configured.
24
+ persistor;
25
+ currentRunId = null;
26
+ // Track the legacy `body` option and the canonical `forwardedProps`
27
+ // option as separate slots so that `updateOptions({ forwardedProps })`
28
+ // doesn't wipe a previously-set `body` (and vice versa). They are
29
+ // merged on every send, with `forwardedProps` winning on key collision.
30
+ bodyOption = {};
31
+ forwardedPropsOption = {};
32
+ context = void 0;
33
+ pendingMessageBody = void 0;
34
+ isLoading = false;
35
+ isSubscribed = false;
36
+ error = void 0;
37
+ status = "ready";
38
+ connectionStatus = "disconnected";
39
+ abortController = null;
40
+ clientToolsRef;
41
+ devtoolsBridge;
42
+ /**
43
+ * Alias for `this.events`. The bridge installs an
44
+ * emitter that auto-attaches run/thread context and auto-emits a
45
+ * snapshot after every event, so chat-client only ever calls
46
+ * `this.events.X(...)` exactly like it did before devtools landed.
47
+ */
48
+ events;
49
+ currentStreamId = null;
50
+ currentMessageId = null;
51
+ postStreamActions = [];
52
+ // Track pending client tool executions to await them before stream finalization
53
+ pendingToolExecutions = /* @__PURE__ */ new Map();
54
+ activeClientTools = null;
55
+ activeContext = void 0;
56
+ // Flag to deduplicate continuation checks during action draining
57
+ continuationPending = false;
58
+ subscriptionAbortController = null;
59
+ processingResolve = null;
60
+ errorReportedGeneration = null;
61
+ streamGeneration = 0;
62
+ // Tracks whether a queued checkForContinuation was skipped because
63
+ // continuationPending was true (chained approval scenario)
64
+ continuationSkipped = false;
65
+ draining = false;
66
+ sessionGenerating = false;
67
+ activeRunIds = /* @__PURE__ */ new Set();
68
+ devtoolsMounted = false;
69
+ callbacksRef;
70
+ constructor(options) {
71
+ this.uniqueId = options.id || this.generateUniqueId("chat");
72
+ this.threadId = options.threadId || this.generateUniqueId("thread");
73
+ if (options.persistence) {
74
+ this.persistor = new ChatPersistor(
75
+ options.persistence,
76
+ this.uniqueId,
77
+ (messages) => this.processor.setMessages(messages)
78
+ );
79
+ }
80
+ this.bodyOption = options.body || {};
81
+ this.forwardedPropsOption = options.forwardedProps || {};
82
+ this.context = options.context;
83
+ this.connection = normalizeConnectionAdapter(resolveTransport(options));
84
+ this.clientToolsRef = { current: /* @__PURE__ */ new Map() };
85
+ if (options.tools) {
86
+ for (const tool of options.tools) {
87
+ this.clientToolsRef.current.set(tool.name, tool);
88
+ }
89
+ }
90
+ this.devtoolsBridge = (options.devtoolsBridgeFactory ?? createNoOpChatDevtoolsBridge)(this.buildDevtoolsBridgeOptions(options.devtools));
91
+ this.events = this.devtoolsBridge.events;
92
+ this.callbacksRef = {
93
+ current: {
94
+ onResponse: options.onResponse || (() => {
95
+ }),
96
+ onChunk: options.onChunk || (() => {
97
+ }),
98
+ onFinish: options.onFinish || (() => {
99
+ }),
100
+ onError: options.onError || (() => {
101
+ }),
102
+ onMessagesChange: options.onMessagesChange || (() => {
103
+ }),
104
+ onLoadingChange: options.onLoadingChange || (() => {
105
+ }),
106
+ onErrorChange: options.onErrorChange || (() => {
107
+ }),
108
+ onStatusChange: options.onStatusChange || (() => {
109
+ }),
110
+ onSubscriptionChange: options.onSubscriptionChange || (() => {
111
+ }),
112
+ onConnectionStatusChange: options.onConnectionStatusChange || (() => {
113
+ }),
114
+ onSessionGeneratingChange: options.onSessionGeneratingChange || (() => {
115
+ }),
116
+ onCustomEvent: options.onCustomEvent || (() => {
117
+ })
118
+ }
119
+ };
120
+ const persistedMessages = this.persistor?.readInitial();
121
+ const initialMessages = Array.isArray(persistedMessages) ? persistedMessages : options.initialMessages;
122
+ this.processor = new StreamProcessor({
123
+ ...options.streamProcessor?.chunkStrategy ? { chunkStrategy: options.streamProcessor.chunkStrategy } : {},
124
+ ...initialMessages ? { initialMessages } : {},
125
+ events: {
126
+ onMessagesChange: (messages) => {
127
+ this.persistor?.notifyMessagesChanged(messages);
128
+ this.callbacksRef.current.onMessagesChange(messages);
129
+ },
130
+ onStreamStart: () => {
131
+ this.setStatus("streaming");
132
+ const assistantMessageId = this.processor.getCurrentAssistantMessageId();
133
+ if (!assistantMessageId) {
134
+ return;
135
+ }
136
+ const messages = this.processor.getMessages();
137
+ const assistantMessage = messages.find(
138
+ (m) => m.id === assistantMessageId
139
+ );
140
+ if (assistantMessage) {
141
+ this.currentMessageId = assistantMessage.id;
142
+ this.events.messageAppended(
143
+ assistantMessage,
144
+ this.currentStreamId || void 0
145
+ );
146
+ }
147
+ },
148
+ onStreamEnd: (message) => {
149
+ this.callbacksRef.current.onFinish(message);
150
+ this.setStatus("ready");
151
+ this.resolveProcessing();
152
+ },
153
+ onError: (error) => {
154
+ this.reportStreamError(error);
155
+ },
156
+ onTextUpdate: (messageId, content) => {
157
+ if (this.currentStreamId) {
158
+ this.events.textUpdated(this.currentStreamId, messageId, content);
159
+ }
160
+ },
161
+ onThinkingUpdate: (messageId, content) => {
162
+ if (this.currentStreamId) {
163
+ this.events.thinkingUpdated(
164
+ this.currentStreamId,
165
+ messageId,
166
+ content,
167
+ void 0
168
+ );
169
+ }
170
+ },
171
+ onStructuredOutputChange: (args) => {
172
+ const streamId = this.devtoolsBridge.resolveStreamId();
173
+ const eventName = args.phase === "start" ? "structured-output:started" : args.phase === "complete" ? "structured-output:completed" : args.phase === "error" ? "structured-output:errored" : "structured-output:updated";
174
+ this.currentMessageId = args.messageId;
175
+ this.events.structuredOutputChanged(
176
+ eventName,
177
+ streamId,
178
+ args.messageId,
179
+ {
180
+ status: args.status,
181
+ raw: args.raw,
182
+ ...args.partial !== void 0 ? { partial: args.partial } : {},
183
+ ...args.data !== void 0 ? { data: args.data } : {},
184
+ ...args.reasoning !== void 0 ? { reasoning: args.reasoning } : {},
185
+ ...args.errorMessage !== void 0 ? { errorMessage: args.errorMessage } : {},
186
+ ...args.delta !== void 0 ? { delta: args.delta } : {}
187
+ }
188
+ );
189
+ },
190
+ onToolCallStateChange: (messageId, toolCallId, state, args) => {
191
+ const messages = this.processor.getMessages();
192
+ const message = messages.find((m) => m.id === messageId);
193
+ const toolCallPart = message?.parts.find(
194
+ (p) => p.type === "tool-call" && p.id === toolCallId
195
+ );
196
+ const toolName = toolCallPart?.name || "unknown";
197
+ if (this.currentStreamId) {
198
+ this.events.toolCallStateChanged(
199
+ this.currentStreamId,
200
+ messageId,
201
+ toolCallId,
202
+ toolName,
203
+ state,
204
+ args
205
+ );
206
+ }
207
+ },
208
+ onToolCall: (args) => {
209
+ const clientTools = this.activeClientTools ?? this.clientToolsRef.current;
210
+ const clientTool = clientTools.get(args.toolName);
211
+ const executeFunc = clientTool?.execute;
212
+ if (executeFunc) {
213
+ const runEventContext = this.devtoolsBridge.getCurrentRunEventContext();
214
+ const executionPromise = (async () => {
215
+ try {
216
+ const context = this.activeClientTools === null ? this.context : this.activeContext;
217
+ const output = await executeFunc(args.input, {
218
+ toolCallId: args.toolCallId,
219
+ context,
220
+ emitCustomEvent: () => {
221
+ }
222
+ });
223
+ await this.addToolResultForClientTool(
224
+ {
225
+ toolCallId: args.toolCallId,
226
+ tool: args.toolName,
227
+ output,
228
+ state: "output-available"
229
+ },
230
+ clientTool,
231
+ runEventContext
232
+ );
233
+ } catch (error) {
234
+ await this.addToolResultForClientTool(
235
+ {
236
+ toolCallId: args.toolCallId,
237
+ tool: args.toolName,
238
+ output: null,
239
+ state: "output-error",
240
+ errorText: error.message
241
+ },
242
+ clientTool,
243
+ runEventContext
244
+ );
245
+ } finally {
246
+ this.pendingToolExecutions.delete(args.toolCallId);
247
+ }
248
+ })();
249
+ this.pendingToolExecutions.set(args.toolCallId, executionPromise);
250
+ }
251
+ },
252
+ onApprovalRequest: (args) => {
253
+ const streamId = this.devtoolsBridge.resolveStreamId();
254
+ const messageIdForApproval = this.findMessageIdForToolCall(args.toolCallId) ?? this.currentMessageId ?? "";
255
+ this.events.approvalRequested(
256
+ streamId,
257
+ messageIdForApproval,
258
+ args.toolCallId,
259
+ args.toolName,
260
+ args.input,
261
+ args.approvalId
262
+ );
263
+ },
264
+ onCustomEvent: (eventType, data, context) => {
265
+ this.callbacksRef.current.onCustomEvent(eventType, data, context);
266
+ }
267
+ }
268
+ });
269
+ this.persistor?.hydrateAsync(persistedMessages);
270
+ }
271
+ mountDevtools() {
272
+ if (this.devtoolsMounted) {
273
+ return;
274
+ }
275
+ this.devtoolsMounted = true;
276
+ this.devtoolsBridge.mountWithTools(this.processor.getMessages().length);
277
+ }
278
+ /**
279
+ * Drain a runId-less RUN_ERROR that belongs to a cleared run the client is
280
+ * still tracking. The persistor owns the cleared-run bookkeeping; the client
281
+ * owns the active-run / session / processing state.
282
+ */
283
+ drainIgnoredRunlessChunk(chunk) {
284
+ if (chunk.type !== "RUN_ERROR") return;
285
+ const runId = this.persistor?.takeRunlessRunId();
286
+ if (!runId) return;
287
+ this.activeRunIds.delete(runId);
288
+ this.setSessionGenerating(this.activeRunIds.size > 0);
289
+ this.resolveProcessing();
290
+ }
291
+ updateRunLifecycle(chunk, options) {
292
+ if (chunk.type === "RUN_STARTED") {
293
+ const chunkRunId = getChunkRunId(chunk) ?? chunk.runId;
294
+ this.activeRunIds.add(chunkRunId);
295
+ this.persistor?.onRunStarted(chunkRunId);
296
+ this.setSessionGenerating(true);
297
+ return;
298
+ }
299
+ if (chunk.type !== "RUN_FINISHED" && chunk.type !== "RUN_ERROR") {
300
+ return;
301
+ }
302
+ const runId = getChunkRunId(chunk);
303
+ if (runId) {
304
+ this.activeRunIds.delete(runId);
305
+ this.persistor?.onRunSettled(runId);
306
+ } else if (chunk.type === "RUN_ERROR") {
307
+ this.activeRunIds.clear();
308
+ this.persistor?.onSessionRunError();
309
+ }
310
+ this.setSessionGenerating(this.activeRunIds.size > 0);
311
+ if (options?.resolveProcessing !== false) {
312
+ this.resolveProcessing();
313
+ }
314
+ }
315
+ generateUniqueId(prefix) {
316
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`;
317
+ }
318
+ setIsLoading(isLoading) {
319
+ this.isLoading = isLoading;
320
+ this.callbacksRef.current.onLoadingChange(isLoading);
321
+ this.events.loadingChanged(isLoading);
322
+ }
323
+ setStatus(status) {
324
+ this.status = status;
325
+ this.callbacksRef.current.onStatusChange(status);
326
+ this.devtoolsBridge.emitSnapshot();
327
+ }
328
+ setIsSubscribed(isSubscribed) {
329
+ this.isSubscribed = isSubscribed;
330
+ this.callbacksRef.current.onSubscriptionChange(isSubscribed);
331
+ this.devtoolsBridge.emitSnapshot();
332
+ }
333
+ setConnectionStatus(status) {
334
+ this.connectionStatus = status;
335
+ this.callbacksRef.current.onConnectionStatusChange(status);
336
+ this.devtoolsBridge.emitSnapshot();
337
+ }
338
+ setSessionGenerating(isGenerating) {
339
+ if (this.sessionGenerating === isGenerating) return;
340
+ this.sessionGenerating = isGenerating;
341
+ this.callbacksRef.current.onSessionGeneratingChange(isGenerating);
342
+ this.devtoolsBridge.emitSnapshot();
343
+ }
344
+ resetSessionGenerating() {
345
+ this.activeRunIds.clear();
346
+ this.persistor?.resetIgnored();
347
+ this.setSessionGenerating(false);
348
+ }
349
+ setError(error) {
350
+ this.error = error;
351
+ this.callbacksRef.current.onErrorChange(error);
352
+ this.events.errorChanged(error?.message || null);
353
+ }
354
+ buildDevtoolsBridgeOptions(devtools) {
355
+ return {
356
+ hookId: this.uniqueId,
357
+ clientId: this.uniqueId,
358
+ threadId: this.threadId,
359
+ metadata: {
360
+ hookName: devtools?.hookName ?? "useChat",
361
+ outputKind: devtools?.outputKind ?? "chat",
362
+ ...devtools?.framework ? { framework: devtools.framework } : {},
363
+ ...devtools?.name ? { name: devtools.name } : {}
364
+ },
365
+ getSnapshot: () => this.getDevtoolsSnapshot(),
366
+ getTools: () => this.clientToolsRef.current.values(),
367
+ getMessages: () => this.processor.getMessages(),
368
+ setMessages: (messages) => {
369
+ this.processor.setMessages(messages);
370
+ },
371
+ addToolResult: (toolCallId, output, errorText) => {
372
+ this.processor.addToolResult(toolCallId, output, errorText);
373
+ },
374
+ generateId: (prefix) => this.generateUniqueId(prefix)
375
+ };
376
+ }
377
+ getDevtoolsSnapshot() {
378
+ return {
379
+ messages: this.processor.getMessages(),
380
+ status: this.status,
381
+ isLoading: this.isLoading,
382
+ isSubscribed: this.isSubscribed,
383
+ connectionStatus: this.connectionStatus,
384
+ sessionGenerating: this.sessionGenerating,
385
+ activeRunIds: Array.from(this.activeRunIds),
386
+ ...this.error ? { error: this.error.message } : {}
387
+ };
388
+ }
389
+ findMessageIdForToolCall(toolCallId) {
390
+ const messages = this.processor.getMessages();
391
+ for (const message of messages) {
392
+ const match = message.parts.find(
393
+ (part) => part.type === "tool-call" && part.id === toolCallId
394
+ );
395
+ if (match) return message.id;
396
+ }
397
+ return void 0;
398
+ }
399
+ abortSubscriptionLoop() {
400
+ this.subscriptionAbortController?.abort();
401
+ this.subscriptionAbortController = null;
402
+ }
403
+ resolveProcessing() {
404
+ this.processingResolve?.();
405
+ this.processingResolve = null;
406
+ }
407
+ cancelInFlightStream(options) {
408
+ this.abortController?.abort();
409
+ this.abortController = null;
410
+ if (options?.abortSubscription) {
411
+ this.abortSubscriptionLoop();
412
+ }
413
+ this.resolveProcessing();
414
+ this.setIsLoading(false);
415
+ if (options?.setReadyStatus) {
416
+ this.setStatus("ready");
417
+ }
418
+ }
419
+ reportStreamError(error) {
420
+ const alreadyReported = this.errorReportedGeneration === this.streamGeneration;
421
+ this.setError(error);
422
+ if (this.isLoading || this.status === "submitted" || this.status === "streaming") {
423
+ this.setStatus("error");
424
+ }
425
+ if (!alreadyReported) {
426
+ this.errorReportedGeneration = this.streamGeneration;
427
+ this.callbacksRef.current.onError(error);
428
+ }
429
+ }
430
+ /**
431
+ * Start the background subscription loop.
432
+ */
433
+ startSubscription() {
434
+ this.subscriptionAbortController = new AbortController();
435
+ const signal = this.subscriptionAbortController.signal;
436
+ this.consumeSubscription(signal).catch((err) => {
437
+ if (err instanceof Error && err.name !== "AbortError") {
438
+ this.setConnectionStatus("error");
439
+ this.resetSessionGenerating();
440
+ this.setIsSubscribed(false);
441
+ this.reportStreamError(err);
442
+ }
443
+ this.resolveProcessing();
444
+ }).finally(() => {
445
+ if (this.subscriptionAbortController?.signal !== signal) {
446
+ return;
447
+ }
448
+ this.subscriptionAbortController = null;
449
+ if (!signal.aborted && this.isSubscribed) {
450
+ this.setIsSubscribed(false);
451
+ if (this.connectionStatus !== "error") {
452
+ this.setConnectionStatus("disconnected");
453
+ }
454
+ }
455
+ });
456
+ }
457
+ /**
458
+ * Consume chunks from the connection subscription.
459
+ */
460
+ async consumeSubscription(signal) {
461
+ const stream = this.connection.subscribe(signal);
462
+ for await (const chunk of stream) {
463
+ if (signal.aborted) break;
464
+ if (this.connectionStatus === "connecting") {
465
+ this.setConnectionStatus("connected");
466
+ }
467
+ const shouldIgnore = this.persistor?.shouldIgnoreChunk(chunk) ?? false;
468
+ if (shouldIgnore) {
469
+ if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") {
470
+ if (getChunkRunId(chunk)) {
471
+ this.updateRunLifecycle(chunk, { resolveProcessing: false });
472
+ } else {
473
+ this.drainIgnoredRunlessChunk(chunk);
474
+ }
475
+ }
476
+ continue;
477
+ }
478
+ this.callbacksRef.current.onChunk(chunk);
479
+ this.devtoolsBridge.observeChunk(chunk);
480
+ this.processor.processChunk(chunk);
481
+ this.updateRunLifecycle(chunk);
482
+ await new Promise((resolve) => setTimeout(resolve, 0));
483
+ }
484
+ }
485
+ /**
486
+ * Ensure subscription loop is running, starting it if needed.
487
+ */
488
+ ensureSubscription() {
489
+ if (!this.isSubscribed) {
490
+ this.subscribe();
491
+ return;
492
+ }
493
+ if (!this.subscriptionAbortController || this.subscriptionAbortController.signal.aborted) {
494
+ this.subscribe({ restart: true });
495
+ }
496
+ }
497
+ /**
498
+ * Create a promise that resolves when onStreamEnd fires.
499
+ * Used by streamResponse to await processing completion.
500
+ */
501
+ waitForProcessing() {
502
+ this.resolveProcessing();
503
+ return new Promise((resolve) => {
504
+ this.processingResolve = resolve;
505
+ });
506
+ }
507
+ /**
508
+ * Send a message and stream the response.
509
+ * Supports both simple string content and multimodal content (images, audio, video, documents).
510
+ *
511
+ * @param content - The message content. Can be:
512
+ * - A simple string for text-only messages
513
+ * - A MultimodalContent object with content array and optional custom ID
514
+ * @param body - Optional body parameters to merge with the client's base body for this request.
515
+ * Uses shallow merge with per-message body taking priority.
516
+ *
517
+ * @example
518
+ * ```ts
519
+ * // Simple text message
520
+ * await client.sendMessage('Hello!')
521
+ *
522
+ * // Text message with custom body params
523
+ * await client.sendMessage('Hello!', { temperature: 0.7 })
524
+ *
525
+ * // Multimodal message with image
526
+ * await client.sendMessage({
527
+ * content: [
528
+ * { type: 'text', content: 'What is in this image?' },
529
+ * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }
530
+ * ]
531
+ * })
532
+ *
533
+ * // Multimodal message with custom ID and body params
534
+ * await client.sendMessage(
535
+ * {
536
+ * content: [
537
+ * { type: 'text', content: 'Describe this audio' },
538
+ * { type: 'audio', source: { type: 'data', value: 'base64...' } }
539
+ * ],
540
+ * id: 'custom-message-id'
541
+ * },
542
+ * { model: 'gpt-4-audio' }
543
+ * )
544
+ * ```
545
+ */
546
+ async sendMessage(content, body) {
547
+ this.mountDevtools();
548
+ const emptyMessage = typeof content === "string" && !content.trim();
549
+ if (emptyMessage || this.isLoading) {
550
+ return;
551
+ }
552
+ const normalizedContent = this.normalizeMessageInput(content);
553
+ this.pendingMessageBody = body;
554
+ const userMessage = this.processor.addUserMessage(
555
+ normalizedContent.content,
556
+ normalizedContent.id
557
+ );
558
+ this.events.messageSent(userMessage.id, normalizedContent.content);
559
+ await this.streamResponse();
560
+ }
561
+ /**
562
+ * Normalize the message input to extract content and optional id.
563
+ * Trims string content automatically.
564
+ */
565
+ normalizeMessageInput(input) {
566
+ if (typeof input === "string") {
567
+ return { content: input.trim() };
568
+ }
569
+ return { content: input.content, id: input.id };
570
+ }
571
+ /**
572
+ * Append a message and stream the response
573
+ */
574
+ async append(message) {
575
+ this.mountDevtools();
576
+ const normalizedMessage = normalizeToUIMessage(message, generateMessageId);
577
+ if (normalizedMessage.role === "system") {
578
+ return;
579
+ }
580
+ const uiMessage = normalizedMessage;
581
+ this.events.messageAppended(uiMessage);
582
+ const messages = this.processor.getMessages();
583
+ this.processor.setMessages([...messages, uiMessage]);
584
+ this.devtoolsBridge.emitSnapshot();
585
+ if (this.isLoading) {
586
+ this.queuePostStreamAction(async () => {
587
+ await this.streamResponse();
588
+ });
589
+ return;
590
+ }
591
+ await this.streamResponse();
592
+ }
593
+ /**
594
+ * Stream a response from the LLM.
595
+ * Returns true if the stream completed successfully, false on abort or error.
596
+ */
597
+ async streamResponse() {
598
+ if (this.isLoading) {
599
+ return false;
600
+ }
601
+ const generation = ++this.streamGeneration;
602
+ const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
603
+ this.currentRunId = runId;
604
+ this.setIsLoading(true);
605
+ this.setStatus("submitted");
606
+ this.setError(void 0);
607
+ this.errorReportedGeneration = null;
608
+ this.abortController = new AbortController();
609
+ const signal = this.abortController.signal;
610
+ this.pendingToolExecutions.clear();
611
+ let streamCompletedSuccessfully = false;
612
+ let activeDevtoolsRunId = null;
613
+ let runTerminalEventEmitted = false;
614
+ try {
615
+ const messages = this.processor.getMessages();
616
+ const clientTools = new Map(this.clientToolsRef.current);
617
+ const runtimeContext = this.context;
618
+ await this.callbacksRef.current.onResponse();
619
+ if (signal.aborted) {
620
+ return false;
621
+ }
622
+ const mergedBody = {
623
+ ...this.bodyOption,
624
+ ...this.forwardedPropsOption,
625
+ ...this.pendingMessageBody
626
+ };
627
+ this.pendingMessageBody = void 0;
628
+ this.currentStreamId = this.generateUniqueId("stream");
629
+ this.devtoolsBridge.setCurrentStreamId(this.currentStreamId);
630
+ this.currentMessageId = null;
631
+ this.activeClientTools = clientTools;
632
+ this.activeContext = runtimeContext;
633
+ this.processor.prepareAssistantMessage();
634
+ this.ensureSubscription();
635
+ const processingComplete = this.waitForProcessing();
636
+ const runContext = {
637
+ threadId: this.threadId,
638
+ runId,
639
+ clientTools: Array.from(clientTools.values()).map((t) => ({
640
+ name: t.name,
641
+ description: t.description,
642
+ parameters: t.inputSchema ? convertSchemaToJsonSchema(t.inputSchema) : { type: "object" }
643
+ })),
644
+ forwardedProps: { ...mergedBody }
645
+ };
646
+ this.devtoolsBridge.beginRun(runContext.runId, this.threadId);
647
+ activeDevtoolsRunId = runContext.runId;
648
+ this.devtoolsBridge.emitRunLifecycle(
649
+ "run:created",
650
+ runContext.runId,
651
+ "created"
652
+ );
653
+ this.devtoolsBridge.emitRunLifecycle(
654
+ "run:started",
655
+ runContext.runId,
656
+ "started"
657
+ );
658
+ this.devtoolsBridge.emitSnapshot();
659
+ await this.connection.send(messages, mergedBody, signal, runContext);
660
+ await processingComplete;
661
+ if (generation !== this.streamGeneration) {
662
+ return false;
663
+ }
664
+ if (this.status === "error") {
665
+ if (activeDevtoolsRunId) {
666
+ this.devtoolsBridge.emitRunLifecycle(
667
+ "run:errored",
668
+ activeDevtoolsRunId,
669
+ "errored",
670
+ this.error ? { error: this.error.message } : {}
671
+ );
672
+ runTerminalEventEmitted = true;
673
+ }
674
+ return false;
675
+ }
676
+ if (this.pendingToolExecutions.size > 0) {
677
+ await Promise.all(this.pendingToolExecutions.values());
678
+ }
679
+ this.processor.finalizeStream();
680
+ streamCompletedSuccessfully = true;
681
+ } catch (err) {
682
+ if (err instanceof Error) {
683
+ if (err.name === "AbortError") {
684
+ if (activeDevtoolsRunId) {
685
+ this.devtoolsBridge.emitRunLifecycle(
686
+ "run:cancelled",
687
+ activeDevtoolsRunId,
688
+ "cancelled"
689
+ );
690
+ runTerminalEventEmitted = true;
691
+ }
692
+ return false;
693
+ }
694
+ if (generation === this.streamGeneration) {
695
+ this.reportStreamError(err);
696
+ if (activeDevtoolsRunId) {
697
+ this.devtoolsBridge.emitRunLifecycle(
698
+ "run:errored",
699
+ activeDevtoolsRunId,
700
+ "errored",
701
+ { error: err.message }
702
+ );
703
+ runTerminalEventEmitted = true;
704
+ }
705
+ }
706
+ }
707
+ } finally {
708
+ if (generation === this.streamGeneration) {
709
+ this.currentStreamId = null;
710
+ this.devtoolsBridge.setCurrentStreamId(null);
711
+ this.currentMessageId = null;
712
+ this.currentRunId = null;
713
+ this.activeClientTools = null;
714
+ this.activeContext = void 0;
715
+ this.abortController = null;
716
+ this.setIsLoading(false);
717
+ this.pendingMessageBody = void 0;
718
+ if (activeDevtoolsRunId && !runTerminalEventEmitted) {
719
+ if (streamCompletedSuccessfully) {
720
+ this.devtoolsBridge.emitRunLifecycle(
721
+ "run:completed",
722
+ activeDevtoolsRunId,
723
+ "completed"
724
+ );
725
+ } else if (signal.aborted) {
726
+ this.devtoolsBridge.emitRunLifecycle(
727
+ "run:cancelled",
728
+ activeDevtoolsRunId,
729
+ "cancelled"
730
+ );
731
+ }
732
+ }
733
+ await this.drainPostStreamActions();
734
+ if (streamCompletedSuccessfully) {
735
+ const messages = this.processor.getMessages();
736
+ const lastPart = messages.at(-1)?.parts.at(-1);
737
+ const { finishReason } = this.processor.getState();
738
+ if (lastPart?.type === "tool-result" && finishReason !== "stop" && this.shouldAutoSend()) {
739
+ try {
740
+ await this.checkForContinuation();
741
+ } catch (error) {
742
+ console.error("Failed to continue flow after tool result:", error);
743
+ }
744
+ } else if (this.status !== "ready") {
745
+ this.setStatus("ready");
746
+ }
747
+ }
748
+ }
749
+ }
750
+ return streamCompletedSuccessfully;
751
+ }
752
+ /**
753
+ * Start the client subscription loop.
754
+ * This controls the connection lifecycle independently from request lifecycle.
755
+ */
756
+ subscribe(options) {
757
+ const restart = options?.restart === true;
758
+ if (this.isSubscribed && !restart) {
759
+ return;
760
+ }
761
+ if (this.isSubscribed && restart) {
762
+ this.abortSubscriptionLoop();
763
+ }
764
+ this.setIsSubscribed(true);
765
+ this.setConnectionStatus("connecting");
766
+ this.startSubscription();
767
+ }
768
+ /**
769
+ * Unsubscribe and fully tear down live behavior.
770
+ * This aborts an in-flight request and the subscription loop.
771
+ */
772
+ unsubscribe() {
773
+ this.cancelInFlightStream({
774
+ setReadyStatus: true,
775
+ abortSubscription: true
776
+ });
777
+ this.resetSessionGenerating();
778
+ this.setIsSubscribed(false);
779
+ this.setConnectionStatus("disconnected");
780
+ }
781
+ /**
782
+ * Reload the last assistant message
783
+ */
784
+ async reload() {
785
+ const messages = this.processor.getMessages();
786
+ if (messages.length === 0) return;
787
+ const lastUserMessageIndex = messages.findLastIndex(
788
+ (m) => m.role === "user"
789
+ );
790
+ if (lastUserMessageIndex === -1) return;
791
+ if (this.isLoading) {
792
+ this.cancelInFlightStream();
793
+ }
794
+ this.events.reloaded(lastUserMessageIndex);
795
+ this.processor.removeMessagesAfter(lastUserMessageIndex);
796
+ this.devtoolsBridge.emitSnapshot();
797
+ await this.streamResponse();
798
+ }
799
+ /**
800
+ * Stop the current stream
801
+ */
802
+ stop() {
803
+ const hadLocalStream = this.abortController !== null;
804
+ this.cancelInFlightStream({ setReadyStatus: true });
805
+ if (hadLocalStream) {
806
+ this.resetSessionGenerating();
807
+ }
808
+ this.events.stopped();
809
+ }
810
+ /**
811
+ * Clear all messages
812
+ */
813
+ clear() {
814
+ if (this.persistor) {
815
+ this.persistor.snapshotClear({
816
+ messages: this.processor.getMessages(),
817
+ activeRunIds: this.activeRunIds,
818
+ currentRunId: this.currentRunId
819
+ });
820
+ if (this.isLoading) {
821
+ this.cancelInFlightStream({ setReadyStatus: true });
822
+ this.resetSessionGenerating();
823
+ } else if (this.activeRunIds.size > 0) {
824
+ this.resetSessionGenerating();
825
+ }
826
+ this.persistor.beginClear();
827
+ }
828
+ this.processor.clearMessages();
829
+ this.persistor?.remove();
830
+ this.setError(void 0);
831
+ this.events.messagesCleared();
832
+ }
833
+ /**
834
+ * Add the result of a client-side tool execution
835
+ */
836
+ async addToolResult(result) {
837
+ const clientTool = this.clientToolsRef.current.get(result.tool);
838
+ await this.addToolResultForClientTool(result, clientTool);
839
+ }
840
+ async addToolResultForClientTool(result, clientTool, context) {
841
+ if (clientTool && result.state !== "output-error") {
842
+ try {
843
+ result = {
844
+ ...result,
845
+ output: this.validateClientToolOutput(clientTool, result.output)
846
+ };
847
+ } catch (error) {
848
+ result = {
849
+ ...result,
850
+ output: null,
851
+ state: "output-error",
852
+ errorText: error.message
853
+ };
854
+ }
855
+ }
856
+ this.events.toolResultAdded(
857
+ result.toolCallId,
858
+ result.tool,
859
+ result.output,
860
+ result.state || "output-available",
861
+ context
862
+ );
863
+ this.processor.addToolResult(
864
+ result.toolCallId,
865
+ result.output,
866
+ result.state === "output-error" ? result.errorText || "Tool execution failed" : void 0
867
+ );
868
+ if (this.isLoading) {
869
+ this.queuePostStreamAction(() => this.checkForContinuation());
870
+ return;
871
+ }
872
+ await this.checkForContinuation();
873
+ }
874
+ validateClientToolOutput(clientTool, output) {
875
+ if (clientTool.outputSchema && isStandardSchema(clientTool.outputSchema)) {
876
+ return parseWithStandardSchema(clientTool.outputSchema, output);
877
+ }
878
+ return output;
879
+ }
880
+ /**
881
+ * Respond to a tool approval request
882
+ */
883
+ async addToolApprovalResponse(response) {
884
+ const messages = this.processor.getMessages();
885
+ let foundToolCallId;
886
+ for (const msg of messages) {
887
+ const toolCallPart = msg.parts.find(
888
+ (p) => p.type === "tool-call" && p.approval?.id === response.id
889
+ );
890
+ if (toolCallPart) {
891
+ foundToolCallId = toolCallPart.id;
892
+ break;
893
+ }
894
+ }
895
+ if (foundToolCallId) {
896
+ this.events.toolApprovalResponded(
897
+ response.id,
898
+ foundToolCallId,
899
+ response.approved
900
+ );
901
+ }
902
+ this.processor.addToolApprovalResponse(response.id, response.approved);
903
+ this.devtoolsBridge.emitSnapshot();
904
+ if (this.isLoading) {
905
+ this.queuePostStreamAction(() => this.checkForContinuation());
906
+ return;
907
+ }
908
+ await this.checkForContinuation();
909
+ }
910
+ /**
911
+ * Queue an action to be executed after the current stream ends
912
+ */
913
+ queuePostStreamAction(action) {
914
+ this.postStreamActions.push(action);
915
+ }
916
+ /**
917
+ * Drain and execute all queued post-stream actions
918
+ */
919
+ async drainPostStreamActions() {
920
+ if (this.draining) return;
921
+ this.draining = true;
922
+ try {
923
+ let action;
924
+ while ((action = this.postStreamActions.shift()) !== void 0) {
925
+ await action();
926
+ }
927
+ } finally {
928
+ this.draining = false;
929
+ }
930
+ }
931
+ /**
932
+ * Check if we should continue the flow and do so if needed
933
+ */
934
+ async checkForContinuation() {
935
+ if (this.continuationPending || this.isLoading) {
936
+ this.continuationSkipped = true;
937
+ return;
938
+ }
939
+ if (this.shouldAutoSend()) {
940
+ this.continuationPending = true;
941
+ this.continuationSkipped = false;
942
+ let succeeded = false;
943
+ try {
944
+ succeeded = await this.streamResponse();
945
+ } finally {
946
+ this.continuationPending = false;
947
+ }
948
+ if (this.continuationSkipped && succeeded) {
949
+ this.continuationSkipped = false;
950
+ await this.checkForContinuation();
951
+ }
952
+ }
953
+ }
954
+ /**
955
+ * Check if all tool calls are complete and we should auto-send.
956
+ * Requires that there is at least one tool call in the last assistant message;
957
+ * a text-only response has nothing to auto-send.
958
+ */
959
+ shouldAutoSend() {
960
+ const messages = this.processor.getMessages();
961
+ const lastAssistant = messages.findLast(
962
+ (m) => m.role === "assistant"
963
+ );
964
+ if (!lastAssistant) return false;
965
+ const hasToolCalls = lastAssistant.parts.some(
966
+ (p) => p.type === "tool-call"
967
+ );
968
+ if (!hasToolCalls) return false;
969
+ return this.processor.areAllToolsComplete();
970
+ }
971
+ /**
972
+ * Get current messages
973
+ */
974
+ getMessages() {
975
+ return this.processor.getMessages();
976
+ }
977
+ /**
978
+ * Get loading state
979
+ */
980
+ getIsLoading() {
981
+ return this.isLoading;
982
+ }
983
+ /**
984
+ * Get current status
985
+ */
986
+ getStatus() {
987
+ return this.status;
988
+ }
989
+ /**
990
+ * Get whether the subscription loop is active
991
+ */
992
+ getIsSubscribed() {
993
+ return this.isSubscribed;
994
+ }
995
+ /**
996
+ * Get current connection lifecycle status
997
+ */
998
+ getConnectionStatus() {
999
+ return this.connectionStatus;
1000
+ }
1001
+ /**
1002
+ * Whether the shared session is actively generating.
1003
+ * Derived from stream run events (RUN_STARTED / RUN_FINISHED / RUN_ERROR).
1004
+ * Unlike `isLoading` (request-local), this reflects shared generation
1005
+ * activity visible to all subscribers (e.g. across tabs/devices).
1006
+ */
1007
+ getSessionGenerating() {
1008
+ return this.sessionGenerating;
1009
+ }
1010
+ /**
1011
+ * Get current error
1012
+ */
1013
+ getError() {
1014
+ return this.error;
1015
+ }
1016
+ /**
1017
+ * Manually set messages
1018
+ */
1019
+ setMessagesManually(messages) {
1020
+ this.processor.setMessages(messages);
1021
+ this.devtoolsBridge.emitSnapshot();
1022
+ }
1023
+ updateOptions(options) {
1024
+ if (options.connection !== void 0 || options.fetcher !== void 0) {
1025
+ const wasSubscribed = this.isSubscribed;
1026
+ if (this.isLoading) {
1027
+ this.cancelInFlightStream({
1028
+ setReadyStatus: true,
1029
+ abortSubscription: true
1030
+ });
1031
+ } else if (wasSubscribed) {
1032
+ this.abortSubscriptionLoop();
1033
+ }
1034
+ this.resetSessionGenerating();
1035
+ this.setIsSubscribed(false);
1036
+ this.setConnectionStatus("disconnected");
1037
+ this.connection = normalizeConnectionAdapter(
1038
+ resolveTransport({
1039
+ connection: options.connection,
1040
+ fetcher: options.fetcher
1041
+ })
1042
+ );
1043
+ if (wasSubscribed) {
1044
+ this.subscribe();
1045
+ }
1046
+ }
1047
+ if (options.body !== void 0) {
1048
+ this.bodyOption = options.body;
1049
+ }
1050
+ if (options.forwardedProps !== void 0) {
1051
+ this.forwardedPropsOption = options.forwardedProps;
1052
+ }
1053
+ if ("context" in options) {
1054
+ this.context = options.context;
1055
+ }
1056
+ if (options.tools !== void 0) {
1057
+ this.clientToolsRef.current = /* @__PURE__ */ new Map();
1058
+ for (const tool of options.tools) {
1059
+ this.clientToolsRef.current.set(tool.name, tool);
1060
+ }
1061
+ this.devtoolsBridge.notifyToolsChanged();
1062
+ }
1063
+ if (options.onResponse !== void 0) {
1064
+ this.callbacksRef.current.onResponse = options.onResponse;
1065
+ }
1066
+ if (options.onChunk !== void 0) {
1067
+ this.callbacksRef.current.onChunk = options.onChunk;
1068
+ }
1069
+ if (options.onFinish !== void 0) {
1070
+ this.callbacksRef.current.onFinish = options.onFinish;
1071
+ }
1072
+ if (options.onError !== void 0) {
1073
+ this.callbacksRef.current.onError = options.onError;
1074
+ }
1075
+ if (options.onSubscriptionChange !== void 0) {
1076
+ this.callbacksRef.current.onSubscriptionChange = options.onSubscriptionChange;
1077
+ }
1078
+ if (options.onConnectionStatusChange !== void 0) {
1079
+ this.callbacksRef.current.onConnectionStatusChange = options.onConnectionStatusChange;
1080
+ }
1081
+ if (options.onSessionGeneratingChange !== void 0) {
1082
+ this.callbacksRef.current.onSessionGeneratingChange = options.onSessionGeneratingChange;
1083
+ }
1084
+ if (options.onCustomEvent !== void 0) {
1085
+ this.callbacksRef.current.onCustomEvent = options.onCustomEvent;
1086
+ }
1087
+ }
1088
+ dispose() {
1089
+ this.unsubscribe();
1090
+ this.devtoolsBridge.dispose();
1091
+ this.devtoolsMounted = false;
1092
+ }
1093
+ }
1094
+ export {
1095
+ ChatClient
1096
+ };
1097
+ //# sourceMappingURL=chat-client.js.map