mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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 ''
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user