@tanstack/ai 0.3.0 → 0.4.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.
@@ -1,16 +1,34 @@
1
1
  import type {
2
+ AudioPart,
2
3
  ContentPart,
4
+ DocumentPart,
5
+ ImagePart,
3
6
  MessagePart,
4
7
  ModelMessage,
5
8
  TextPart,
6
9
  ToolCallPart,
7
10
  ToolResultPart,
8
11
  UIMessage,
12
+ VideoPart,
9
13
  } from '../../types'
10
14
  // ===========================
11
15
  // Message Converters
12
16
  // ===========================
13
17
 
18
+ /**
19
+ * Helper to check if a part is a multimodal content part (image, audio, video, document)
20
+ */
21
+ function isMultimodalPart(
22
+ part: MessagePart,
23
+ ): part is ImagePart | AudioPart | VideoPart | DocumentPart {
24
+ return (
25
+ part.type === 'image' ||
26
+ part.type === 'audio' ||
27
+ part.type === 'video' ||
28
+ part.type === 'document'
29
+ )
30
+ }
31
+
14
32
  /**
15
33
  * Helper to extract text content from string or ContentPart array
16
34
  * For multimodal content, this extracts only the text parts
@@ -52,7 +70,8 @@ export function convertMessagesToModelMessages(
52
70
  * Convert a UIMessage to ModelMessage(s)
53
71
  *
54
72
  * This conversion handles the parts-based structure:
55
- * - Text parts → content field
73
+ * - Text parts → content field (string or as part of ContentPart array)
74
+ * - Multimodal parts (image, audio, video, document) → ContentPart array
56
75
  * - ToolCall parts → toolCalls array
57
76
  * - ToolResult parts → separate role="tool" messages
58
77
  *
@@ -72,12 +91,17 @@ export function uiMessageToModelMessages(
72
91
  // Separate parts by type
73
92
  // Note: thinking parts are UI-only and not included in ModelMessages
74
93
  const textParts: Array<TextPart> = []
94
+ const multimodalParts: Array<
95
+ ImagePart | AudioPart | VideoPart | DocumentPart
96
+ > = []
75
97
  const toolCallParts: Array<ToolCallPart> = []
76
98
  const toolResultParts: Array<ToolResultPart> = []
77
99
 
78
100
  for (const part of uiMessage.parts) {
79
101
  if (part.type === 'text') {
80
102
  textParts.push(part)
103
+ } else if (isMultimodalPart(part)) {
104
+ multimodalParts.push(part)
81
105
  } else if (part.type === 'tool-call') {
82
106
  toolCallParts.push(part)
83
107
  } else if (part.type === 'tool-result') {
@@ -86,8 +110,26 @@ export function uiMessageToModelMessages(
86
110
  // thinking parts are skipped - they're UI-only
87
111
  }
88
112
 
89
- // Build the main message (user or assistant)
90
- const content = textParts.map((p) => p.content).join('') || null
113
+ // Build the content field
114
+ // If we have multimodal parts, use ContentPart array format
115
+ // Otherwise, use simple string format for backward compatibility
116
+ let content: string | null | Array<ContentPart>
117
+ if (multimodalParts.length > 0) {
118
+ // Build ContentPart array preserving the order of text and multimodal parts
119
+ const contentParts: Array<ContentPart> = []
120
+ for (const part of uiMessage.parts) {
121
+ if (part.type === 'text') {
122
+ contentParts.push(part)
123
+ } else if (isMultimodalPart(part)) {
124
+ contentParts.push(part)
125
+ }
126
+ }
127
+ content = contentParts
128
+ } else {
129
+ // Simple string content for text-only messages
130
+ content = textParts.map((p) => p.content).join('') || null
131
+ }
132
+
91
133
  const toolCalls =
92
134
  toolCallParts.length > 0
93
135
  ? toolCallParts
@@ -108,7 +150,9 @@ export function uiMessageToModelMessages(
108
150
  : undefined
109
151
 
110
152
  // Create the main message
111
- if (uiMessage.role !== 'assistant' || content || !toolCalls) {
153
+ // For multimodal content, we always create a message even if content is an empty array
154
+ const hasContent = Array.isArray(content) ? true : content !== null
155
+ if (uiMessage.role !== 'assistant' || hasContent || !toolCalls) {
112
156
  messageList.push({
113
157
  role: uiMessage.role,
114
158
  content,
@@ -123,7 +167,11 @@ export function uiMessageToModelMessages(
123
167
  })
124
168
  }
125
169
 
126
- // Add tool result messages (only completed ones)
170
+ // Add tool result messages for completed tool calls
171
+ // This includes:
172
+ // 1. Explicit tool-result parts (from server tools)
173
+ // 2. Client tool calls with output set
174
+ // 3. Approval-responded tool calls (approval result)
127
175
  for (const toolResultPart of toolResultParts) {
128
176
  if (
129
177
  toolResultPart.state === 'complete' ||
@@ -137,6 +185,41 @@ export function uiMessageToModelMessages(
137
185
  }
138
186
  }
139
187
 
188
+ // Add tool result messages for client tool results (tools with output)
189
+ // and approval responses (so iteration tracking works correctly)
190
+ for (const toolCallPart of toolCallParts) {
191
+ // Client tool with output - add as tool result
192
+ if (toolCallPart.output !== undefined && !toolCallPart.approval) {
193
+ messageList.push({
194
+ role: 'tool',
195
+ content: JSON.stringify(toolCallPart.output),
196
+ toolCallId: toolCallPart.id,
197
+ })
198
+ }
199
+
200
+ // Approval response - add as tool result for iteration tracking
201
+ // For APPROVED: includes pendingExecution marker so the tool still executes
202
+ // For DENIED: just marks the tool as complete (no execution needed)
203
+ if (
204
+ toolCallPart.state === 'approval-responded' &&
205
+ toolCallPart.approval?.approved !== undefined
206
+ ) {
207
+ const approved = toolCallPart.approval.approved
208
+ messageList.push({
209
+ role: 'tool',
210
+ content: JSON.stringify({
211
+ approved,
212
+ // Mark approved tools as pending execution - they still need to run
213
+ ...(approved && { pendingExecution: true }),
214
+ message: approved
215
+ ? 'User approved this action'
216
+ : 'User denied this action',
217
+ }),
218
+ toolCallId: toolCallPart.id,
219
+ })
220
+ }
221
+ }
222
+
140
223
  return messageList
141
224
  }
142
225
 
@@ -35,10 +35,13 @@ import type {
35
35
  ToolResultState,
36
36
  } from './types'
37
37
  import type {
38
+ ContentPart,
39
+ MessagePart,
38
40
  ModelMessage,
39
41
  StreamChunk,
40
42
  ToolCall,
41
43
  ToolCallPart,
44
+ ToolResultPart,
42
45
  UIMessage,
43
46
  } from '../../../types'
44
47
 
@@ -164,13 +167,42 @@ export class StreamProcessor {
164
167
  }
165
168
 
166
169
  /**
167
- * Add a user message to the conversation
168
- */
169
- addUserMessage(content: string): UIMessage {
170
+ * Add a user message to the conversation.
171
+ * Supports both simple string content and multimodal content arrays.
172
+ *
173
+ * @param content - The message content (string or array of content parts)
174
+ * @param id - Optional custom message ID (generated if not provided)
175
+ * @returns The created UIMessage
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * // Simple text message
180
+ * processor.addUserMessage('Hello!')
181
+ *
182
+ * // Multimodal message with image
183
+ * processor.addUserMessage([
184
+ * { type: 'text', content: 'What is in this image?' },
185
+ * { type: 'image', source: { type: 'url', value: 'https://example.com/photo.jpg' } }
186
+ * ])
187
+ *
188
+ * // With custom ID
189
+ * processor.addUserMessage('Hello!', 'custom-id-123')
190
+ * ```
191
+ */
192
+ addUserMessage(content: string | Array<ContentPart>, id?: string): UIMessage {
193
+ // Convert content to message parts
194
+ const parts: Array<MessagePart> =
195
+ typeof content === 'string'
196
+ ? [{ type: 'text', content }]
197
+ : content.map((part) => {
198
+ // ContentPart types (text, image, audio, video, document) are compatible with MessagePart
199
+ return part as MessagePart
200
+ })
201
+
170
202
  const userMessage: UIMessage = {
171
- id: generateMessageId(),
203
+ id: id ?? generateMessageId(),
172
204
  role: 'user',
173
- parts: [{ type: 'text', content }],
205
+ parts,
174
206
  createdAt: new Date(),
175
207
  }
176
208
 
@@ -296,11 +328,23 @@ export class StreamProcessor {
296
328
 
297
329
  if (toolParts.length === 0) return true
298
330
 
331
+ // Get tool result parts to check for server tool completion
332
+ const toolResultIds = new Set(
333
+ lastAssistant.parts
334
+ .filter((p): p is ToolResultPart => p.type === 'tool-result')
335
+ .map((p) => p.toolCallId),
336
+ )
337
+
299
338
  // All tool calls must be in a terminal state
339
+ // A tool call is complete if:
340
+ // 1. It was approved/denied (approval-responded state)
341
+ // 2. It has an output field set (client tool completed via addToolResult)
342
+ // 3. It has a corresponding tool-result part (server tool completed)
300
343
  return toolParts.every(
301
344
  (part) =>
302
345
  part.state === 'approval-responded' ||
303
- (part.output !== undefined && !part.approval),
346
+ (part.output !== undefined && !part.approval) ||
347
+ toolResultIds.has(part.id),
304
348
  )
305
349
  }
306
350
 
package/src/index.ts CHANGED
@@ -73,6 +73,9 @@ export {
73
73
  // All types
74
74
  export * from './types'
75
75
 
76
+ // Utility functions
77
+ export { detectImageMimeType } from './utils'
78
+
76
79
  // Event client + event types
77
80
  export * from './event-client'
78
81
 
package/src/types.ts CHANGED
@@ -108,24 +108,52 @@ export interface ToolCall {
108
108
  export type Modality = 'text' | 'image' | 'audio' | 'video' | 'document'
109
109
 
110
110
  /**
111
- * Source specification for multimodal content.
112
- * Supports both inline data (base64) and URL-based content.
111
+ * Source specification for inline data content (base64).
112
+ * Requires a mimeType to ensure providers receive proper content type information.
113
+ */
114
+ export interface ContentPartDataSource {
115
+ /**
116
+ * Indicates this is inline data content.
117
+ */
118
+ type: 'data'
119
+ /**
120
+ * The base64-encoded content value.
121
+ */
122
+ value: string
123
+ /**
124
+ * The MIME type of the content (e.g., 'image/png', 'audio/wav').
125
+ * Required for data sources to ensure proper handling by providers.
126
+ */
127
+ mimeType: string
128
+ }
129
+
130
+ /**
131
+ * Source specification for URL-based content.
132
+ * mimeType is optional as it can often be inferred from the URL or response headers.
113
133
  */
114
- export interface ContentPartSource {
134
+ export interface ContentPartUrlSource {
115
135
  /**
116
- * The type of source:
117
- * - 'data': Inline data (typically base64 encoded)
118
- * - 'url': URL reference to the content
136
+ * Indicates this is URL-referenced content.
119
137
  */
120
- type: 'data' | 'url'
138
+ type: 'url'
121
139
  /**
122
- * The actual content value:
123
- * - For 'data': base64-encoded string
124
- * - For 'url': HTTP(S) URL or data URI
140
+ * HTTP(S) URL or data URI pointing to the content.
125
141
  */
126
142
  value: string
143
+ /**
144
+ * Optional MIME type hint for cases where providers can't infer it from the URL.
145
+ */
146
+ mimeType?: string
127
147
  }
128
148
 
149
+ /**
150
+ * Source specification for multimodal content.
151
+ * Discriminated union supporting both inline data (base64) and URL-based content.
152
+ * - For 'data' sources: mimeType is required
153
+ * - For 'url' sources: mimeType is optional
154
+ */
155
+ export type ContentPartSource = ContentPartDataSource | ContentPartUrlSource
156
+
129
157
  /**
130
158
  * Image content part for multimodal messages.
131
159
  * @template TMetadata - Provider-specific metadata type (e.g., OpenAI's detail level)
@@ -282,6 +310,10 @@ export interface ThinkingPart {
282
310
 
283
311
  export type MessagePart =
284
312
  | TextPart
313
+ | ImagePart
314
+ | AudioPart
315
+ | VideoPart
316
+ | DocumentPart
285
317
  | ToolCallPart
286
318
  | ToolResultPart
287
319
  | ThinkingPart
package/src/utils.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Detect image mime type from base64 data using magic bytes.
3
+ * Returns undefined if the format cannot be detected.
4
+ *
5
+ * This function analyzes the first few bytes of base64-encoded image data
6
+ * to determine the image format based on file signature (magic bytes).
7
+ *
8
+ * @param base64Data - The base64-encoded image data
9
+ * @returns The detected mime type, or undefined if unrecognized
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * const mimeType = detectImageMimeType(imageBase64)
14
+ * // Returns 'image/jpeg', 'image/png', 'image/gif', 'image/webp', or undefined
15
+ * ```
16
+ */
17
+ export function detectImageMimeType(
18
+ base64Data: string,
19
+ ): 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | undefined {
20
+ // Get first few bytes (base64 encoded)
21
+ const prefix = base64Data.substring(0, 20)
22
+
23
+ // JPEG: starts with /9j/ (FFD8FF in base64)
24
+ if (prefix.startsWith('/9j/')) {
25
+ return 'image/jpeg'
26
+ }
27
+ // PNG: starts with iVBORw0KGgo (89504E47 in base64)
28
+ if (prefix.startsWith('iVBORw0KGgo')) {
29
+ return 'image/png'
30
+ }
31
+ // GIF: starts with R0lGOD (474946 in base64)
32
+ if (prefix.startsWith('R0lGOD')) {
33
+ return 'image/gif'
34
+ }
35
+ // WebP: starts with UklGR (52494646 in base64, followed by WEBP)
36
+ if (prefix.startsWith('UklGR')) {
37
+ return 'image/webp'
38
+ }
39
+
40
+ return undefined
41
+ }