@tanstack/ai-client 0.3.0 → 0.4.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.
@@ -4,29 +4,42 @@ import {
4
4
  normalizeToUIMessage,
5
5
  } from '@tanstack/ai'
6
6
  import { DefaultChatClientEventEmitter } from './events'
7
+ import type {
8
+ AnyClientTool,
9
+ ContentPart,
10
+ ModelMessage,
11
+ StreamChunk,
12
+ } from '@tanstack/ai'
13
+ import type { ConnectionAdapter } from './connection-adapters'
14
+ import type { ChatClientEventEmitter } from './events'
7
15
  import type {
8
16
  ChatClientOptions,
17
+ ChatClientState,
9
18
  MessagePart,
19
+ MultimodalContent,
10
20
  ToolCallPart,
11
21
  UIMessage,
12
22
  } from './types'
13
- import type { AnyClientTool, ModelMessage, StreamChunk } from '@tanstack/ai'
14
- import type { ConnectionAdapter } from './connection-adapters'
15
- import type { ChatClientEventEmitter } from './events'
16
23
 
17
24
  export class ChatClient {
18
25
  private processor: StreamProcessor
19
26
  private connection: ConnectionAdapter
20
27
  private uniqueId: string
21
28
  private body: Record<string, any> = {}
29
+ private pendingMessageBody: Record<string, any> | undefined = undefined
22
30
  private isLoading = false
23
31
  private error: Error | undefined = undefined
32
+ private status: ChatClientState = 'ready'
24
33
  private abortController: AbortController | null = null
25
34
  private events: ChatClientEventEmitter
26
35
  private clientToolsRef: { current: Map<string, AnyClientTool> }
27
36
  private currentStreamId: string | null = null
28
37
  private currentMessageId: string | null = null
29
38
  private postStreamActions: Array<() => Promise<void>> = []
39
+ // Track pending client tool executions to await them before stream finalization
40
+ private pendingToolExecutions: Map<string, Promise<void>> = new Map()
41
+ // Flag to deduplicate continuation checks during action draining
42
+ private continuationPending = false
30
43
 
31
44
  private callbacksRef: {
32
45
  current: {
@@ -37,6 +50,7 @@ export class ChatClient {
37
50
  onMessagesChange: (messages: Array<UIMessage>) => void
38
51
  onLoadingChange: (isLoading: boolean) => void
39
52
  onErrorChange: (error: Error | undefined) => void
53
+ onStatusChange: (status: ChatClientState) => void
40
54
  }
41
55
  }
42
56
 
@@ -63,6 +77,7 @@ export class ChatClient {
63
77
  onMessagesChange: options.onMessagesChange || (() => {}),
64
78
  onLoadingChange: options.onLoadingChange || (() => {}),
65
79
  onErrorChange: options.onErrorChange || (() => {}),
80
+ onStatusChange: options.onStatusChange || (() => {}),
66
81
  },
67
82
  }
68
83
 
@@ -75,13 +90,15 @@ export class ChatClient {
75
90
  this.callbacksRef.current.onMessagesChange(messages)
76
91
  },
77
92
  onStreamStart: () => {
78
- // Stream started
93
+ this.setStatus('streaming')
79
94
  },
80
95
  onStreamEnd: (message: UIMessage) => {
81
96
  this.callbacksRef.current.onFinish(message)
97
+ this.setStatus('ready')
82
98
  },
83
99
  onError: (error: Error) => {
84
100
  this.setError(error)
101
+ this.setStatus('error')
85
102
  this.callbacksRef.current.onError(error)
86
103
  },
87
104
  onTextUpdate: (messageId: string, content: string) => {
@@ -127,31 +144,41 @@ export class ChatClient {
127
144
  )
128
145
  }
129
146
  },
130
- onToolCall: async (args: {
147
+ onToolCall: (args: {
131
148
  toolCallId: string
132
149
  toolName: string
133
150
  input: any
134
151
  }) => {
135
152
  // Handle client-side tool execution automatically
136
153
  const clientTool = this.clientToolsRef.current.get(args.toolName)
137
- if (clientTool?.execute) {
138
- try {
139
- const output = await clientTool.execute(args.input)
140
- await this.addToolResult({
141
- toolCallId: args.toolCallId,
142
- tool: args.toolName,
143
- output,
144
- state: 'output-available',
145
- })
146
- } catch (error: any) {
147
- await this.addToolResult({
148
- toolCallId: args.toolCallId,
149
- tool: args.toolName,
150
- output: null,
151
- state: 'output-error',
152
- errorText: error.message,
153
- })
154
- }
154
+ const executeFunc = clientTool?.execute
155
+ if (executeFunc) {
156
+ // Create and track the execution promise
157
+ const executionPromise = (async () => {
158
+ try {
159
+ const output = await executeFunc(args.input)
160
+ await this.addToolResult({
161
+ toolCallId: args.toolCallId,
162
+ tool: args.toolName,
163
+ output,
164
+ state: 'output-available',
165
+ })
166
+ } catch (error: any) {
167
+ await this.addToolResult({
168
+ toolCallId: args.toolCallId,
169
+ tool: args.toolName,
170
+ output: null,
171
+ state: 'output-error',
172
+ errorText: error.message,
173
+ })
174
+ } finally {
175
+ // Remove from pending when complete
176
+ this.pendingToolExecutions.delete(args.toolCallId)
177
+ }
178
+ })()
179
+
180
+ // Track the pending execution
181
+ this.pendingToolExecutions.set(args.toolCallId, executionPromise)
155
182
  }
156
183
  },
157
184
  onApprovalRequest: (args: {
@@ -187,6 +214,11 @@ export class ChatClient {
187
214
  this.events.loadingChanged(isLoading)
188
215
  }
189
216
 
217
+ private setStatus(status: ChatClientState): void {
218
+ this.status = status
219
+ this.callbacksRef.current.onStatusChange(status)
220
+ }
221
+
190
222
  private setError(error: Error | undefined): void {
191
223
  this.error = error
192
224
  this.callbacksRef.current.onErrorChange(error)
@@ -227,6 +259,12 @@ export class ChatClient {
227
259
  await new Promise((resolve) => setTimeout(resolve, 0))
228
260
  }
229
261
 
262
+ // Wait for all pending tool executions to complete before finalizing
263
+ // This ensures client tools finish before we check for continuation
264
+ if (this.pendingToolExecutions.size > 0) {
265
+ await Promise.all(this.pendingToolExecutions.values())
266
+ }
267
+
230
268
  // Finalize the stream
231
269
  this.processor.finalizeStream()
232
270
 
@@ -251,20 +289,82 @@ export class ChatClient {
251
289
  }
252
290
 
253
291
  /**
254
- * Send a message and stream the response
292
+ * Send a message and stream the response.
293
+ * Supports both simple string content and multimodal content (images, audio, video, documents).
294
+ *
295
+ * @param content - The message content. Can be:
296
+ * - A simple string for text-only messages
297
+ * - A MultimodalContent object with content array and optional custom ID
298
+ * @param body - Optional body parameters to merge with the client's base body for this request.
299
+ * Uses shallow merge with per-message body taking priority.
300
+ *
301
+ * @example
302
+ * ```ts
303
+ * // Simple text message
304
+ * await client.sendMessage('Hello!')
305
+ *
306
+ * // Text message with custom body params
307
+ * await client.sendMessage('Hello!', { temperature: 0.7 })
308
+ *
309
+ * // Multimodal message with image
310
+ * await client.sendMessage({
311
+ * content: [
312
+ * { type: 'text', content: 'What is in this image?' },
313
+ * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }
314
+ * ]
315
+ * })
316
+ *
317
+ * // Multimodal message with custom ID and body params
318
+ * await client.sendMessage(
319
+ * {
320
+ * content: [
321
+ * { type: 'text', content: 'Describe this audio' },
322
+ * { type: 'audio', source: { type: 'data', value: 'base64...' } }
323
+ * ],
324
+ * id: 'custom-message-id'
325
+ * },
326
+ * { model: 'gpt-4-audio' }
327
+ * )
328
+ * ```
255
329
  */
256
- async sendMessage(content: string): Promise<void> {
257
- if (!content.trim() || this.isLoading) {
330
+ async sendMessage(
331
+ content: string | MultimodalContent,
332
+ body?: Record<string, any>,
333
+ ): Promise<void> {
334
+ const emptyMessage = typeof content === 'string' && !content.trim()
335
+ if (emptyMessage || this.isLoading) {
258
336
  return
259
337
  }
338
+ // Normalize input to extract content, id, and validate
339
+ const normalizedContent = this.normalizeMessageInput(content)
340
+
341
+ // Store the per-message body for use in streamResponse
342
+ this.pendingMessageBody = body
260
343
 
261
344
  // Add user message via processor
262
- const userMessage = this.processor.addUserMessage(content.trim())
263
- this.events.messageSent(userMessage.id, content.trim())
345
+ const userMessage = this.processor.addUserMessage(
346
+ normalizedContent.content,
347
+ normalizedContent.id,
348
+ )
349
+ this.events.messageSent(userMessage.id, normalizedContent.content)
264
350
 
265
351
  await this.streamResponse()
266
352
  }
267
353
 
354
+ /**
355
+ * Normalize the message input to extract content and optional id.
356
+ * Trims string content automatically.
357
+ */
358
+ private normalizeMessageInput(input: string | MultimodalContent): {
359
+ content: string | Array<ContentPart>
360
+ id?: string
361
+ } {
362
+ if (typeof input === 'string') {
363
+ return { content: input.trim() }
364
+ }
365
+ return { content: input.content, id: input.id }
366
+ }
367
+
268
368
  /**
269
369
  * Append a message and stream the response
270
370
  */
@@ -287,6 +387,12 @@ export class ChatClient {
287
387
  const messages = this.processor.getMessages()
288
388
  this.processor.setMessages([...messages, uiMessage])
289
389
 
390
+ // If stream is in progress, queue the response for after it ends
391
+ if (this.isLoading) {
392
+ this.queuePostStreamAction(() => this.streamResponse())
393
+ return
394
+ }
395
+
290
396
  await this.streamResponse()
291
397
  }
292
398
 
@@ -294,45 +400,76 @@ export class ChatClient {
294
400
  * Stream a response from the LLM
295
401
  */
296
402
  private async streamResponse(): Promise<void> {
403
+ // Guard against concurrent streams - if already loading, skip
404
+ if (this.isLoading) {
405
+ return
406
+ }
407
+
297
408
  this.setIsLoading(true)
409
+ this.setStatus('submitted')
298
410
  this.setError(undefined)
299
411
  this.abortController = new AbortController()
412
+ // Reset pending tool executions for the new stream
413
+ this.pendingToolExecutions.clear()
414
+ let streamCompletedSuccessfully = false
300
415
 
301
416
  try {
302
- // Get model messages for the LLM
303
- const modelMessages = this.processor.toModelMessages()
417
+ // Get UIMessages with parts (preserves approval state and client tool results)
418
+ const messages = this.processor.getMessages()
304
419
 
305
420
  // Call onResponse callback
306
421
  await this.callbacksRef.current.onResponse()
307
422
 
308
- // Include conversationId in the body for server-side event correlation
309
- const bodyWithConversationId = {
423
+ // Merge body: base body + per-message body (per-message takes priority)
424
+ // Include conversationId for server-side event correlation
425
+ const mergedBody = {
310
426
  ...this.body,
427
+ ...this.pendingMessageBody,
311
428
  conversationId: this.uniqueId,
312
429
  }
313
430
 
431
+ // Clear the pending message body after use
432
+ this.pendingMessageBody = undefined
433
+
314
434
  // Connect and stream
315
435
  const stream = this.connection.connect(
316
- modelMessages,
317
- bodyWithConversationId,
436
+ messages,
437
+ mergedBody,
318
438
  this.abortController.signal,
319
439
  )
320
440
 
321
441
  await this.processStream(stream)
442
+ streamCompletedSuccessfully = true
322
443
  } catch (err) {
323
444
  if (err instanceof Error) {
324
445
  if (err.name === 'AbortError') {
325
446
  return
326
447
  }
327
448
  this.setError(err)
449
+ this.setStatus('error')
328
450
  this.callbacksRef.current.onError(err)
329
451
  }
330
452
  } finally {
331
453
  this.abortController = null
332
454
  this.setIsLoading(false)
455
+ this.pendingMessageBody = undefined // Ensure it's cleared even on error
333
456
 
334
457
  // Drain any actions that were queued while the stream was in progress
335
458
  await this.drainPostStreamActions()
459
+
460
+ // Continue conversation if the stream ended with a tool result (server tool completed)
461
+ if (streamCompletedSuccessfully) {
462
+ const messages = this.processor.getMessages()
463
+ const lastPart = messages.at(-1)?.parts.at(-1)
464
+
465
+ if (lastPart?.type === 'tool-result' && this.shouldAutoSend()) {
466
+ try {
467
+ await this.checkForContinuation()
468
+ } catch (error) {
469
+ console.error('Failed to continue flow after tool result:', error)
470
+ }
471
+ }
472
+ }
336
473
  }
337
474
  }
338
475
 
@@ -368,6 +505,7 @@ export class ChatClient {
368
505
  this.abortController = null
369
506
  }
370
507
  this.setIsLoading(false)
508
+ this.setStatus('ready')
371
509
  this.events.stopped()
372
510
  }
373
511
 
@@ -476,8 +614,18 @@ export class ChatClient {
476
614
  * Check if we should continue the flow and do so if needed
477
615
  */
478
616
  private async checkForContinuation(): Promise<void> {
617
+ // Prevent duplicate continuation attempts
618
+ if (this.continuationPending || this.isLoading) {
619
+ return
620
+ }
621
+
479
622
  if (this.shouldAutoSend()) {
480
- await this.streamResponse()
623
+ this.continuationPending = true
624
+ try {
625
+ await this.streamResponse()
626
+ } finally {
627
+ this.continuationPending = false
628
+ }
481
629
  }
482
630
  }
483
631
 
@@ -502,6 +650,13 @@ export class ChatClient {
502
650
  return this.isLoading
503
651
  }
504
652
 
653
+ /**
654
+ * Get current status
655
+ */
656
+ getStatus(): ChatClientState {
657
+ return this.status
658
+ }
659
+
505
660
  /**
506
661
  * Get current error
507
662
  */
@@ -1,4 +1,3 @@
1
- import { convertMessagesToModelMessages } from '@tanstack/ai'
2
1
  import type { ModelMessage, StreamChunk, UIMessage } from '@tanstack/ai'
3
2
 
4
3
  /**
@@ -138,16 +137,15 @@ export function fetchServerSentEvents(
138
137
  const resolvedOptions =
139
138
  typeof options === 'function' ? await options() : options
140
139
 
141
- const modelMessages = convertMessagesToModelMessages(messages)
142
-
143
140
  const requestHeaders: Record<string, string> = {
144
141
  'Content-Type': 'application/json',
145
142
  ...mergeHeaders(resolvedOptions.headers),
146
143
  }
147
144
 
148
- // Merge body from options with messages and data
145
+ // Send messages as-is (UIMessages with parts preserved)
146
+ // Server-side TextEngine handles conversion to ModelMessages
149
147
  const requestBody = {
150
- messages: modelMessages,
148
+ messages,
151
149
  data,
152
150
  ...resolvedOptions.body,
153
151
  }
@@ -238,17 +236,15 @@ export function fetchHttpStream(
238
236
  const resolvedOptions =
239
237
  typeof options === 'function' ? await options() : options
240
238
 
241
- // Convert UIMessages to ModelMessages if needed
242
- const modelMessages = convertMessagesToModelMessages(messages)
243
-
244
239
  const requestHeaders: Record<string, string> = {
245
240
  'Content-Type': 'application/json',
246
241
  ...mergeHeaders(resolvedOptions.headers),
247
242
  }
248
243
 
249
- // Merge body from options with messages and data
244
+ // Send messages as-is (UIMessages with parts preserved)
245
+ // Server-side TextEngine handles conversion to ModelMessages
250
246
  const requestBody = {
251
- messages: modelMessages,
247
+ messages,
252
248
  data,
253
249
  ...resolvedOptions.body,
254
250
  }
@@ -302,14 +298,15 @@ export function fetchHttpStream(
302
298
  */
303
299
  export function stream(
304
300
  streamFactory: (
305
- messages: Array<ModelMessage>,
301
+ messages: Array<UIMessage> | Array<ModelMessage>,
306
302
  data?: Record<string, any>,
307
303
  ) => AsyncIterable<StreamChunk>,
308
304
  ): ConnectionAdapter {
309
305
  return {
310
306
  async *connect(messages, data) {
311
- const modelMessages = convertMessagesToModelMessages(messages)
312
- yield* streamFactory(modelMessages, data)
307
+ // Pass messages as-is (UIMessages with parts preserved)
308
+ // Server-side chat() handles conversion to ModelMessages
309
+ yield* streamFactory(messages, data)
313
310
  },
314
311
  }
315
312
  }
@@ -332,16 +329,15 @@ export function stream(
332
329
  */
333
330
  export function rpcStream(
334
331
  rpcCall: (
335
- messages: Array<ModelMessage>,
332
+ messages: Array<UIMessage> | Array<ModelMessage>,
336
333
  data?: Record<string, any>,
337
334
  ) => AsyncIterable<StreamChunk>,
338
335
  ): ConnectionAdapter {
339
336
  return {
340
337
  async *connect(messages, data) {
341
- const modelMessages = convertMessagesToModelMessages(messages)
342
- // Simply yield from the RPC call
343
- // The RPC layer handles WebSocket transport
344
- yield* rpcCall(modelMessages, data)
338
+ // Pass messages as-is (UIMessages with parts preserved)
339
+ // Server-side chat() handles conversion to ModelMessages
340
+ yield* rpcCall(messages, data)
345
341
  },
346
342
  }
347
343
  }
package/src/events.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { aiEventClient } from '@tanstack/ai/event-client'
2
+ import type { ContentPart } from '@tanstack/ai'
2
3
  import type { UIMessage } from './types'
3
4
 
4
5
  /**
@@ -137,19 +138,36 @@ export abstract class ChatClientEventEmitter {
137
138
  }
138
139
 
139
140
  /**
140
- * Emit message sent event
141
+ * Emit message sent event.
142
+ * Supports both simple string content and multimodal content arrays.
143
+ *
144
+ * @param messageId - The ID of the sent message
145
+ * @param content - The message content (string or array of ContentPart for multimodal)
141
146
  */
142
- messageSent(messageId: string, content: string): void {
147
+ messageSent(messageId: string, content: string | Array<ContentPart>): void {
148
+ // For text content, extract it; for multimodal, provide the array
149
+ const textContent =
150
+ typeof content === 'string'
151
+ ? content
152
+ : content
153
+ .filter((part) => part.type === 'text')
154
+ .map((part) => (part as { type: 'text'; content: string }).content)
155
+ .join('')
156
+
143
157
  this.emitEvent('text:message:created', {
144
158
  messageId,
145
159
  role: 'user',
146
- content,
160
+ content: textContent,
161
+ // Include full content for multimodal messages
162
+ ...(Array.isArray(content) && { parts: content }),
147
163
  })
148
164
 
149
165
  this.emitEvent('text:message:user', {
150
166
  messageId,
151
167
  role: 'user',
152
- content,
168
+ content: textContent,
169
+ // Include full content for multimodal messages
170
+ ...(Array.isArray(content) && { parts: content }),
153
171
  })
154
172
  }
155
173
 
@@ -161,7 +179,6 @@ export abstract class ChatClientEventEmitter {
161
179
  fromMessageIndex,
162
180
  })
163
181
  }
164
-
165
182
  /**
166
183
  * Emit stopped event
167
184
  */
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@ export type {
11
11
  ChatClientOptions,
12
12
  ChatRequestBody,
13
13
  InferChatMessages,
14
+ ChatClientState,
15
+ // Multimodal content input type
16
+ MultimodalContent,
14
17
  } from './types'
15
18
  export { clientTools, createChatClientOptions } from './types'
16
19
  export type {
package/src/types.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  import type {
2
2
  AnyClientTool,
3
+ AudioPart,
3
4
  ChunkStrategy,
5
+ ContentPart,
6
+ DocumentPart,
7
+ ImagePart,
4
8
  InferToolInput,
5
9
  InferToolOutput,
6
10
  ModelMessage,
7
11
  StreamChunk,
12
+ VideoPart,
8
13
  } from '@tanstack/ai'
9
14
  import type { ConnectionAdapter } from './connection-adapters'
10
15
 
@@ -26,6 +31,40 @@ export type ToolResultState =
26
31
  | 'complete' // Result is complete
27
32
  | 'error' // Error occurred
28
33
 
34
+ /**
35
+ * ChatClient state - track the lifecycle of a chat
36
+ */
37
+ export type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error'
38
+
39
+ /**
40
+ * Multimodal content input for sending messages with rich media.
41
+ * Allows sending text, images, audio, video, and documents to the LLM.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * // Send an image with a question
46
+ * client.sendMessage({
47
+ * content: [
48
+ * { type: 'text', content: 'What is in this image?' },
49
+ * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }
50
+ * ],
51
+ * id: 'custom-message-id' // optional
52
+ * })
53
+ * ```
54
+ */
55
+ export interface MultimodalContent {
56
+ /**
57
+ * The content of the message.
58
+ * Can be a simple string or an array of content parts for multimodal messages.
59
+ */
60
+ content: string | Array<ContentPart>
61
+ /**
62
+ * Optional custom ID for the message.
63
+ * If not provided, a unique ID will be generated.
64
+ */
65
+ id?: string
66
+ }
67
+
29
68
  /**
30
69
  * Message parts - building blocks of UIMessage
31
70
  */
@@ -116,6 +155,10 @@ export interface ThinkingPart {
116
155
 
117
156
  export type MessagePart<TTools extends ReadonlyArray<AnyClientTool> = any> =
118
157
  | TextPart
158
+ | ImagePart
159
+ | AudioPart
160
+ | VideoPart
161
+ | DocumentPart
119
162
  | ToolCallPart<TTools>
120
163
  | ToolResultPart
121
164
  | ThinkingPart
@@ -191,6 +234,11 @@ export interface ChatClientOptions<
191
234
  */
192
235
  onErrorChange?: (error: Error | undefined) => void
193
236
 
237
+ /**
238
+ * Callback when chat status changes
239
+ */
240
+ onStatusChange?: (status: ChatClientState) => void
241
+
194
242
  /**
195
243
  * Client-side tools with execution logic
196
244
  * When provided, tools with execute functions will be called automatically