UNPKG

@tanstack/ai

Version:

Core TanStack AI library - Open source AI SDK

437 lines (397 loc) 12.8 kB
import type { ContentPart, MessagePart, ModelMessage, TextPart, ToolCallPart, UIMessage, } from '../../types' // =========================== // Message Converters // =========================== /** * Check if a MessagePart is a content part (text, image, audio, video, document) * that maps directly to a ModelMessage ContentPart. */ function isContentPart(part: MessagePart): part is ContentPart { return ( part.type === 'text' || part.type === 'image' || part.type === 'audio' || part.type === 'video' || part.type === 'document' ) } /** * Collapse an array of ContentParts into the most compact ModelMessage content: * - Empty array → null * - All text parts → joined string (or null if empty) * - Mixed content → ContentPart array as-is */ function collapseContentParts( parts: Array<ContentPart>, ): string | null | Array<ContentPart> { if (parts.length === 0) return null const allText = parts.every((p) => p.type === 'text') if (allText) { const joined = parts.map((p) => p.content).join('') return joined || null } return parts } /** * Extract text content from ModelMessage content (string, null, or ContentPart array). * Used when only the text portion is needed (e.g., tool result content). */ function getTextContent(content: string | null | Array<ContentPart>): string { if (content === null) return '' if (typeof content === 'string') return content return content .filter((part): part is TextPart => part.type === 'text') .map((part) => part.content) .join('') } /** * Convert UIMessages or ModelMessages to ModelMessages */ export function convertMessagesToModelMessages( messages: Array<UIMessage | ModelMessage>, ): Array<ModelMessage> { const modelMessages: Array<ModelMessage> = [] for (const msg of messages) { if ('parts' in msg) { // UIMessage - convert to ModelMessages modelMessages.push(...uiMessageToModelMessages(msg)) } else { // Already ModelMessage modelMessages.push(msg) } } return modelMessages } /** * Convert a UIMessage to ModelMessage(s) * * Walks the parts array IN ORDER to preserve the interleaving of text, * tool calls, and tool results. This is critical for multi-round tool * flows where the model generates text, calls a tool, gets the result, * then generates more text and calls another tool. * * The output preserves the sequential structure: * text1 → toolCall1 → toolResult1 → text2 → toolCall2 → toolResult2 * becomes: * assistant: {content: "text1", toolCalls: [toolCall1]} * tool: toolResult1 * assistant: {content: "text2", toolCalls: [toolCall2]} * tool: toolResult2 * * @param uiMessage - The UIMessage to convert * @returns An array of ModelMessages preserving part ordering */ export function uiMessageToModelMessages( uiMessage: UIMessage, ): Array<ModelMessage> { // Skip system messages - they're handled via systemPrompts, not ModelMessages if (uiMessage.role === 'system') { return [] } // For non-assistant messages (user), use the simpler path since they // don't have tool calls or tool results to interleave if (uiMessage.role !== 'assistant') { return [buildUserOrToolMessage(uiMessage)] } // For assistant messages, walk parts in order to preserve interleaving return buildAssistantMessages(uiMessage) } /** * Build a single ModelMessage for user messages (simple path). * Preserves ordering of text and multimodal content parts. */ function buildUserOrToolMessage(uiMessage: UIMessage): ModelMessage { const contentParts: Array<ContentPart> = [] for (const part of uiMessage.parts) { if (isContentPart(part)) { contentParts.push(part) } } return { role: uiMessage.role as 'user' | 'assistant' | 'tool', content: collapseContentParts(contentParts), } } // Accumulator for building an assistant segment (content + tool calls) interface AssistantSegment { contentParts: Array<ContentPart> toolCalls: Array<{ id: string type: 'function' function: { name: string; arguments: string } }> } function createSegment(): AssistantSegment { return { contentParts: [], toolCalls: [] } } function isToolCallIncluded(part: ToolCallPart): boolean { return ( part.state === 'input-complete' || part.state === 'approval-responded' || part.output !== undefined ) } /** * Build ModelMessages for an assistant UIMessage, preserving the * sequential interleaving of text, tool calls, and tool results. * * Walks parts in order. Text and tool-call parts accumulate into the * current "segment". When a tool-result part is encountered, the * current segment is flushed as an assistant message, then the tool * result is emitted as a tool message. */ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> { const messageList: Array<ModelMessage> = [] let current = createSegment() // Track emitted tool result IDs to avoid duplicates. // A tool call can have BOTH an explicit tool-result part AND an output // field on the tool-call part. We only want one per tool call ID. const emittedToolResultIds = new Set<string>() function flushSegment(): void { const content = collapseContentParts(current.contentParts) const hasContent = content !== null const hasToolCalls = current.toolCalls.length > 0 if (hasContent || hasToolCalls) { messageList.push({ role: 'assistant', content, ...(hasToolCalls && { toolCalls: current.toolCalls }), }) } current = createSegment() } for (const part of uiMessage.parts) { switch (part.type) { case 'text': case 'image': case 'audio': case 'video': case 'document': current.contentParts.push(part) break case 'tool-call': if (isToolCallIncluded(part)) { current.toolCalls.push({ id: part.id, type: 'function' as const, function: { name: part.name, arguments: part.arguments, }, }) } break case 'tool-result': // Flush the current assistant segment before emitting the tool result flushSegment() // Emit the tool result if ( (part.state === 'complete' || part.state === 'error') && !emittedToolResultIds.has(part.toolCallId) ) { messageList.push({ role: 'tool', content: part.content, toolCallId: part.toolCallId, }) emittedToolResultIds.add(part.toolCallId) } break // thinking parts are skipped - they're UI-only default: break } } // Flush any remaining accumulated content flushSegment() // Emit tool results from client tool-call parts with output or approval, // but only if not already covered by an explicit tool-result part above. // These are appended at the end since they don't have explicit tool-result // parts in the parts array to trigger inline emission. for (const part of uiMessage.parts) { if (part.type !== 'tool-call') continue // Output takes priority — if the tool has already produced a result, // emit the concrete output regardless of approval metadata. if (part.output !== undefined && !emittedToolResultIds.has(part.id)) { messageList.push({ role: 'tool', content: JSON.stringify(part.output), toolCallId: part.id, }) emittedToolResultIds.add(part.id) } // Approval response without output — emit approval status for iteration tracking if ( part.output === undefined && part.state === 'approval-responded' && part.approval?.approved !== undefined && !emittedToolResultIds.has(part.id) ) { const approved = part.approval.approved messageList.push({ role: 'tool', content: JSON.stringify({ approved, ...(approved && { pendingExecution: true }), message: approved ? 'User approved this action' : 'User denied this action', }), toolCallId: part.id, }) emittedToolResultIds.add(part.id) } } // If no messages were produced (e.g., empty parts), emit a minimal assistant message if (messageList.length === 0) { messageList.push({ role: 'assistant', content: null, }) } return messageList } /** * Convert a ModelMessage to UIMessage * * This conversion creates a parts-based structure: * - content field → TextPart * - toolCalls array → ToolCallPart[] * - role="tool" messages should be converted separately and merged * * @param modelMessage - The ModelMessage to convert * @param id - Optional ID for the UIMessage (generated if not provided) * @returns A UIMessage with parts */ export function modelMessageToUIMessage( modelMessage: ModelMessage, id?: string, ): UIMessage { const parts: Array<MessagePart> = [] // Handle tool results (when role is "tool") - only produce tool-result part, // not a text part (the content IS the tool result, not display text) if (modelMessage.role === 'tool' && modelMessage.toolCallId) { parts.push({ type: 'tool-result', toolCallId: modelMessage.toolCallId, content: getTextContent(modelMessage.content), state: 'complete', }) } else if (Array.isArray(modelMessage.content)) { // Multimodal content - preserve all content parts as MessageParts for (const part of modelMessage.content) { parts.push(part) } } else { // String or null content const textContent = getTextContent(modelMessage.content) if (textContent) { parts.push({ type: 'text', content: textContent, }) } } // Handle tool calls if (modelMessage.toolCalls && modelMessage.toolCalls.length > 0) { for (const toolCall of modelMessage.toolCalls) { parts.push({ type: 'tool-call', id: toolCall.id, name: toolCall.function.name, arguments: toolCall.function.arguments, state: 'input-complete', // Model messages have complete arguments }) } } return { id: id || generateMessageId(), role: modelMessage.role === 'tool' ? 'assistant' : modelMessage.role, parts, } } /** * Convert an array of ModelMessages to UIMessages * * This handles merging tool result messages with their corresponding assistant messages * * @param modelMessages - Array of ModelMessages to convert * @returns Array of UIMessages */ export function modelMessagesToUIMessages( modelMessages: Array<ModelMessage>, ): Array<UIMessage> { const uiMessages: Array<UIMessage> = [] let currentAssistantMessage: UIMessage | null = null for (const msg of modelMessages) { if (msg.role === 'tool') { // Tool result - merge into the last assistant message if possible if ( currentAssistantMessage && currentAssistantMessage.role === 'assistant' ) { currentAssistantMessage.parts.push({ type: 'tool-result', toolCallId: msg.toolCallId!, content: getTextContent(msg.content), state: 'complete', }) } else { // No assistant message to merge into, create a standalone one const toolResultUIMessage = modelMessageToUIMessage(msg) uiMessages.push(toolResultUIMessage) } } else { // Regular message const uiMessage = modelMessageToUIMessage(msg) uiMessages.push(uiMessage) // Track assistant messages for potential tool result merging if (msg.role === 'assistant') { currentAssistantMessage = uiMessage } else { currentAssistantMessage = null } } } return uiMessages } /** * Normalize a message (UIMessage or ModelMessage) to a UIMessage * Ensures the message has an ID and createdAt timestamp * * @param message - Either a UIMessage or ModelMessage * @param generateId - Function to generate a message ID if needed * @returns A UIMessage with guaranteed id and createdAt */ export function normalizeToUIMessage( message: UIMessage | ModelMessage, generateId: () => string, ): UIMessage { if ('parts' in message) { // Already a UIMessage return { ...message, id: message.id || generateId(), createdAt: message.createdAt || new Date(), } } else { // ModelMessage - convert to UIMessage return { ...modelMessageToUIMessage(message, generateId()), createdAt: new Date(), } } } /** * Generate a unique message ID */ export function generateMessageId(): string { return `msg-${Date.now()}-${Math.random().toString(36).substring(7)}` }