fix(web): persist tool call messages across page refresh

Tool call messages disappeared after page refresh because messageToChat()
filtered out messages without text content. Added convertMessagesToChats()
to merge consecutive assistant(tool_calls) + tool(results) + assistant(text)
into a single ChatMessage with ToolCallBlocks.
This commit is contained in:
BBQ
2026-02-13 17:42:29 +08:00
parent faaadf14c5
commit c08e34cbcc
2 changed files with 183 additions and 4 deletions
@@ -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<string, unknown>).type ?? '').toLowerCase() === 'tool-call')
.map((p) => {
const part = p as Record<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>).type ?? '').toLowerCase() === 'tool-result')
.map((p) => {
const part = p as Record<string, unknown>
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<string, unknown>
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 ''
+103 -4
View File
@@ -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<string, ToolCallBlock>()
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<string, unknown>),
(e) => handleStreamEvent(bid, e as unknown as Record<string, unknown>),
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 {