diff --git a/packages/web/src/composables/api/useChat.ts b/packages/web/src/composables/api/useChat.ts index 7fe5225f..811db359 100644 --- a/packages/web/src/composables/api/useChat.ts +++ b/packages/web/src/composables/api/useChat.ts @@ -277,6 +277,86 @@ export async function streamMessageEvents( // ---- Content extraction utilities ---- +/** + * Extract tool-call content parts from a stored assistant message. + * The DB stores the full ModelMessage JSON in the content field. + * Tool calls are stored as content parts with type "tool-call" + * (Vercel AI SDK format). + */ +export function extractToolCalls( + message: Message, +): Array<{ id: string; name: string; input: unknown }> { + const parts = getContentParts(message) + if (!parts) return [] + return parts + .filter((p) => String((p as Record).type ?? '').toLowerCase() === 'tool-call') + .map((p) => { + const part = p as Record + return { + id: String(part.toolCallId ?? ''), + name: String(part.toolName ?? ''), + input: part.input ?? null, + } + }) +} + +/** + * Extract tool_call_id from a stored tool-role message (first match). + */ +export function extractToolCallId(message: Message): string { + const raw = message.content + if (!raw || typeof raw !== 'object') return '' + const obj = raw as Record + if (typeof obj.tool_call_id === 'string') return obj.tool_call_id.trim() + const parts = getContentParts(message) + if (!parts) return '' + for (const p of parts) { + const part = p as Record + if (String(part.type ?? '').toLowerCase() === 'tool-result' && typeof part.toolCallId === 'string') { + return part.toolCallId.trim() + } + } + return '' +} + +/** + * Extract ALL tool results from a tool-role message. + * A single tool message can contain multiple tool-result parts. + */ +export function extractAllToolResults( + message: Message, +): Array<{ toolCallId: string; output: unknown }> { + const parts = getContentParts(message) + if (!parts) return [] + return parts + .filter((p) => String((p as Record).type ?? '').toLowerCase() === 'tool-result') + .map((p) => { + const part = p as Record + return { + toolCallId: String(part.toolCallId ?? ''), + output: part.output ?? null, + } + }) +} + +/** + * Get the inner content parts array from a message. + * The DB content is the full ModelMessage JSON: { role, content: [...parts] } + */ +function getContentParts(message: Message): unknown[] | null { + const raw = message.content + if (!raw || typeof raw !== 'object') return null + const obj = raw as Record + if (Array.isArray(obj.content)) return obj.content + if (typeof obj.content === 'string') { + try { + const parsed = JSON.parse(obj.content) + if (Array.isArray(parsed)) return parsed + } catch { /* ignore */ } + } + return null +} + export function extractMessageText(message: Message): string { const raw = message.content if (!raw) return '' diff --git a/packages/web/src/store/chat-list.ts b/packages/web/src/store/chat-list.ts index a44f487f..4c2afd3a 100644 --- a/packages/web/src/store/chat-list.ts +++ b/packages/web/src/store/chat-list.ts @@ -13,6 +13,8 @@ import { fetchMessages, fetchChats, extractMessageText, + extractToolCalls, + extractAllToolResults, streamMessage, streamMessageEvents, } from '@/composables/api/useChat' @@ -143,6 +145,103 @@ export const useChatStore = defineStore('chat', () => { } } + /** + * Convert an ordered array of raw messages into ChatMessages, + * merging consecutive assistant(tool_calls) + tool + assistant(text) + * sequences into a single ChatMessage with ToolCallBlocks. + */ + function convertMessagesToChats(rows: Message[]): ChatMessage[] { + const result: ChatMessage[] = [] + let pendingAssistant: ChatMessage | null = null + const pendingToolCallMap = new Map() + + function flushPending() { + if (!pendingAssistant) return + for (const block of pendingAssistant.blocks) { + if (block.type === 'tool_call' && !block.done) block.done = true + } + result.push(pendingAssistant) + pendingAssistant = null + pendingToolCallMap.clear() + } + + function makeTimestamp(raw: Message): Date { + const d = raw.created_at ? new Date(raw.created_at) : new Date() + return Number.isNaN(d.getTime()) ? new Date() : d + } + + for (const raw of rows) { + if (raw.role === 'user') { + flushPending() + const chat = messageToChat(raw) + if (chat) result.push(chat) + continue + } + + if (raw.role === 'assistant') { + const toolCalls = extractToolCalls(raw) + const text = extractMessageText(raw) + + if (toolCalls.length > 0) { + if (!pendingAssistant) { + const platform = (raw.platform ?? '').trim().toLowerCase() + const channelTag = platform && platform !== 'web' ? platform : undefined + pendingAssistant = { + id: raw.id || nextId(), + role: 'assistant', + blocks: [], + timestamp: makeTimestamp(raw), + streaming: false, + ...(channelTag && { platform: channelTag }), + } + } + if (text) { + pendingAssistant.blocks.push({ type: 'text', content: text }) + } + for (const tc of toolCalls) { + const block: ToolCallBlock = { + type: 'tool_call', + toolName: tc.name, + input: tc.input, + result: null, + done: false, + } + pendingAssistant.blocks.push(block) + if (tc.id) pendingToolCallMap.set(tc.id, block) + } + continue + } + + // Assistant message without tool_calls + if (pendingAssistant && text) { + pendingAssistant.blocks.push({ type: 'text', content: text }) + flushPending() + continue + } + + flushPending() + const chat = messageToChat(raw) + if (chat) result.push(chat) + continue + } + + if (raw.role === 'tool') { + const results = extractAllToolResults(raw) + for (const r of results) { + if (r.toolCallId && pendingToolCallMap.has(r.toolCallId)) { + const block = pendingToolCallMap.get(r.toolCallId)! + block.result = r.output + block.done = true + } + } + continue + } + } + + flushPending() + return result + } + function resolveIsSelf(raw: Message): boolean { const platform = (raw.platform ?? '').trim().toLowerCase() if (!platform || platform === 'web') return true @@ -242,7 +341,7 @@ export const useChatStore = defineStore('chat', () => { try { await streamMessageEvents( bid, controller.signal, - (e) => handleStreamEvent(bid, e as Record), + (e) => handleStreamEvent(bid, e as unknown as Record), messageEventsSince || undefined, ) delay = 1000 @@ -285,7 +384,7 @@ export const useChatStore = defineStore('chat', () => { async function loadMessages(botId: string, cid: string) { const rows = await fetchMessages(botId, cid, { limit: PAGE_SIZE }) - const items = rows.map(messageToChat).filter((m): m is ChatMessage => m !== null) + const items = convertMessagesToChats(rows) replaceMessages(items) hasMoreOlder.value = true updateSinceFromRows(rows) @@ -302,8 +401,8 @@ export const useChatStore = defineStore('chat', () => { loadingOlder.value = true try { const rows = await fetchMessages(bid, cid, { limit: PAGE_SIZE, before }) - const items = rows.map(messageToChat).filter((m): m is ChatMessage => m !== null) - if (items.length < PAGE_SIZE) hasMoreOlder.value = false + const items = convertMessagesToChats(rows) + if (rows.length < PAGE_SIZE) hasMoreOlder.value = false messages.unshift(...items) return items.length } finally {