UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

432 lines (431 loc) 14 kB
import { normalizeToolResult } from "../../utilities/tool-result.js"; //#region src/activities/chat/messages.ts /** * Check if a MessagePart is a content part (text, image, audio, video, document) * that maps directly to a ModelMessage ContentPart. */ function isContentPart(part) { return part.type === "text" || part.type === "image" || part.type === "audio" || part.type === "video" || part.type === "document"; } function safeJsonStringify(value) { try { return JSON.stringify(value); } catch { return ""; } } function parseToolResultContent(content) { try { return JSON.parse(content); } catch { return content; } } /** * 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) { if (parts.length === 0) return null; if (parts.every((p) => p.type === "text")) return parts.map((p) => p.content).join("") || 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) { if (content === null) return ""; if (typeof content === "string") return content; return content.filter((part) => part.type === "text").map((part) => part.content).join(""); } /** * Convert UIMessages or ModelMessages to ModelMessages */ function convertMessagesToModelMessages(messages) { const anchoredToolCallIds = /* @__PURE__ */ new Set(); for (const msg of messages) if ("parts" in msg) { for (const part of msg.parts) if (part.type === "tool-result") anchoredToolCallIds.add(part.toolCallId); } const modelMessages = []; for (const msg of messages) { if ("parts" in msg) { modelMessages.push(...uiMessageToModelMessages(msg)); continue; } const role = msg.role; if (role === "tool" && msg.toolCallId && anchoredToolCallIds.has(msg.toolCallId)) continue; if (role === "reasoning" || role === "activity") continue; if (role === "developer") { modelMessages.push({ role: "system", content: msg.content }); continue; } 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 */ function uiMessageToModelMessages(uiMessage) { if (uiMessage.role === "system") return []; if (uiMessage.role !== "assistant") return [buildUserOrToolMessage(uiMessage)]; return buildAssistantMessages(uiMessage); } /** * Build a single ModelMessage for user messages (simple path). * Preserves ordering of text and multimodal content parts. */ function buildUserOrToolMessage(uiMessage) { const contentParts = []; for (const part of uiMessage.parts) if (isContentPart(part)) contentParts.push(part); return { role: uiMessage.role, content: collapseContentParts(contentParts) }; } function createSegment() { return { contentParts: [], toolCalls: [] }; } function isToolCallIncluded(part) { return part.state === "input-complete" || part.state === "complete" || part.state === "approval-requested" || part.state === "approval-responded" || part.state === "error" || part.output !== void 0; } /** * 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) { const messageList = []; let current = createSegment(); let pendingThinking = []; const emittedToolResultIds = /* @__PURE__ */ new Set(); function flushSegment() { 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 }, ...pendingThinking.length > 0 && { thinking: pendingThinking } }); pendingThinking = []; } 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", function: { name: part.name, arguments: part.arguments }, ...part.metadata !== void 0 && { metadata: part.metadata } }); break; case "tool-result": flushSegment(); 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; case "thinking": if (part.content) pendingThinking.push({ content: part.content, ...part.signature && { signature: part.signature } }); break; case "structured-output": if (part.status === "complete") { const serialized = part.raw !== "" ? part.raw : part.data !== void 0 ? safeJsonStringify(part.data) : ""; if (serialized !== "") current.contentParts.push({ type: "text", content: serialized }); } break; case "ui-resource": break; default: break; } flushSegment(); for (const part of uiMessage.parts) { if (part.type !== "tool-call") continue; if (part.output !== void 0 && !emittedToolResultIds.has(part.id)) { messageList.push({ role: "tool", content: normalizeToolResult(part.output), toolCallId: part.id }); emittedToolResultIds.add(part.id); } if (part.output === void 0 && part.state === "approval-responded" && part.approval?.approved !== void 0 && !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 (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 */ function modelMessageToUIMessage(modelMessage, id) { const parts = []; if (modelMessage.role === "assistant" && modelMessage.thinking?.length) for (const thinking of modelMessage.thinking) { if (!thinking.content) continue; parts.push({ type: "thinking", content: thinking.content, ...thinking.signature && { signature: thinking.signature } }); } 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)) for (const part of modelMessage.content) parts.push(part); else { const textContent = getTextContent(modelMessage.content); if (textContent) parts.push({ type: "text", content: textContent }); } if (modelMessage.toolCalls && modelMessage.toolCalls.length > 0) for (const toolCall of modelMessage.toolCalls) { let input; try { input = JSON.parse(toolCall.function.arguments); } catch { input = void 0; } parts.push({ type: "tool-call", id: toolCall.id, name: toolCall.function.name, arguments: toolCall.function.arguments, state: "input-complete", ...input !== void 0 && { input }, ...toolCall.metadata !== void 0 && { metadata: toolCall.metadata } }); } return { id: id || generateMessageId(), role: modelMessage.role === "tool" ? "assistant" : modelMessage.role, parts }; } /** * Normalize a single AG-UI `MESSAGES_SNAPSHOT` message into a `UIMessage`. * * AG-UI snapshot messages use the wire shape `{ id, role, content }` and have * no `parts` array. Casting them directly to `UIMessage` is unsafe: any code * that later reads `message.parts` (e.g. the devtools `onToolCallStateChange` * handler) crashes with "Cannot read properties of undefined (reading 'find')". * * Each role is mapped to the canonical `UIMessage` shape, reusing * `modelMessageToUIMessage` for the roles that share `ModelMessage`'s structure. * The original AG-UI `id` is preserved so later `TEXT_MESSAGE_CONTENT` / * `TOOL_CALL_*` events still route by `messageId` (falling back to a generated * id only when the snapshot omits one). Messages that already carry `parts` * (e.g. a TanStack server echoing `UIMessage`s back over the wire) pass through * unchanged apart from ensuring an id. */ function aguiSnapshotMessageToUIMessage(message) { if ("parts" in message) return { ...message, id: message.id || generateMessageId() }; const id = message.id || generateMessageId(); switch (message.role) { case "user": return { id, role: "user", parts: aguiUserContentToParts(message.content) }; case "assistant": return modelMessageToUIMessage({ role: "assistant", content: message.content ?? null, ...message.toolCalls && { toolCalls: message.toolCalls } }, id); case "tool": return modelMessageToUIMessage({ role: "tool", content: message.content, toolCallId: message.toolCallId }, id); case "system": case "developer": return { id, role: "system", parts: message.content ? [{ type: "text", content: message.content }] : [] }; case "reasoning": return { id, role: "assistant", parts: message.content ? [{ type: "thinking", content: message.content }] : [] }; default: return { id, role: "assistant", parts: [] }; } } /** * Convert AG-UI user message content into `UIMessage` parts. * * AG-UI user content is either a plain string or a multimodal array whose text * entries use `{ type: 'text', text }` (vs. TanStack's `{ type: 'text', content }`). * Text entries are rewritten to the TanStack shape; image/audio/video/document * entries already match `ContentPart` and pass through. `binary` entries have no * TanStack equivalent and are dropped. */ function aguiUserContentToParts(content) { if (typeof content === "string") return content ? [{ type: "text", content }] : []; const parts = []; for (const part of content) if (part.type === "text") parts.push({ type: "text", content: part.text }); else if (part.type !== "binary") parts.push(part); return 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 */ function modelMessagesToUIMessages(modelMessages) { const uiMessages = []; let currentAssistantMessage = null; for (const msg of modelMessages) if (msg.role === "tool") if (msg.toolCallId !== void 0 && currentAssistantMessage && currentAssistantMessage.role === "assistant") { const content = getTextContent(msg.content); const toolCallPart = currentAssistantMessage.parts.find((part) => part.type === "tool-call" && part.id === msg.toolCallId); if (toolCallPart) { toolCallPart.output = parseToolResultContent(content); toolCallPart.state = "complete"; } currentAssistantMessage.parts.push({ type: "tool-result", toolCallId: msg.toolCallId, content, state: "complete" }); } else { const toolResultUIMessage = modelMessageToUIMessage(msg, msg.id); uiMessages.push(toolResultUIMessage); } else { const uiMessage = modelMessageToUIMessage(msg, msg.id); uiMessages.push(uiMessage); 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 */ function normalizeToUIMessage(message, generateId) { if ("parts" in message) return { ...message, id: message.id || generateId(), createdAt: message.createdAt || /* @__PURE__ */ new Date() }; else return { ...modelMessageToUIMessage(message, generateId()), createdAt: /* @__PURE__ */ new Date() }; } /** * Generate a unique message ID */ function generateMessageId() { return `msg-${Date.now()}-${Math.random().toString(36).substring(7)}`; } //#endregion export { aguiSnapshotMessageToUIMessage, convertMessagesToModelMessages, generateMessageId, modelMessageToUIMessage, modelMessagesToUIMessages, normalizeToUIMessage, uiMessageToModelMessages }; //# sourceMappingURL=messages.js.map