@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.
- package/dist/esm/activities/chat/index.js +58 -14
- package/dist/esm/activities/chat/index.js.map +1 -1
- package/dist/esm/activities/chat/messages.d.ts +2 -1
- package/dist/esm/activities/chat/messages.js +44 -2
- package/dist/esm/activities/chat/messages.js.map +1 -1
- package/dist/esm/activities/chat/stream/processor.d.ts +24 -4
- package/dist/esm/activities/chat/stream/processor.js +32 -6
- package/dist/esm/activities/chat/stream/processor.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/types.d.ts +37 -11
- package/dist/esm/utils.d.ts +17 -0
- package/dist/esm/utils.js +20 -0
- package/dist/esm/utils.js.map +1 -0
- package/package.json +1 -1
- package/src/activities/chat/index.ts +95 -25
- package/src/activities/chat/messages.ts +88 -5
- package/src/activities/chat/stream/processor.ts +50 -6
- package/src/index.ts +3 -0
- package/src/types.ts +42 -10
- package/src/utils.ts +41 -0
|
@@ -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
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
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
|
|
112
|
-
*
|
|
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
|
|
134
|
+
export interface ContentPartUrlSource {
|
|
115
135
|
/**
|
|
116
|
-
*
|
|
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: '
|
|
138
|
+
type: 'url'
|
|
121
139
|
/**
|
|
122
|
-
*
|
|
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
|
+
}
|