/** * Pure serialize / deserialize for AgentBar conversation persistence. * * The rendered thread (`ThreadMessageLike[]`) is projected to a slim, JSON-safe * shape that keeps exactly what the cards re-render — text + tool-call metadata — * while dropping the heavy result payloads (full slide elements, base64) that * would bloat localStorage. Restoring rebuilds `ThreadMessageLike[]` of the same * shape the ExternalStore renders today. Pure + side-effect-free so it can be * unit-tested without React. */ import type { ThreadMessageLike } from '@assistant-ui/react'; export interface SlimToolResult { content?: { type: 'text'; text: string }[]; details?: { sceneId?: string; /** null = nothing applied (failure); {elements} = length-only placeholder. */ content?: { elements: unknown[] } | null; actions?: { type?: string }[]; /** * edit_interactive_html success marker: a non-null string when edits applied * (the heavy ~745 KB payload is replaced by a placeholder), `null` on a * refusal/unappliable edit. Without this the restored card mistakes a * successful edit for a failure. `editCount` survives for the count line. */ html?: string | null; editCount?: number; }; } export type SerializedPart = | { type: 'text'; text: string } | { type: 'reasoning'; text: string; durationMs?: number } | { type: 'tool-call'; toolCallId: string; toolName: string; args: Record; isError?: boolean; result?: SlimToolResult; }; export interface SerializedMessage { role: 'user' | 'assistant'; id?: string; content: SerializedPart[]; } type AnyPart = Record; function slimResult(result: unknown): SlimToolResult | undefined { if (!result || typeof result !== 'object') return undefined; const r = result as { content?: unknown; details?: unknown }; const out: SlimToolResult = {}; if (Array.isArray(r.content)) { out.content = r.content .filter((c): c is { type: 'text'; text: string } => { const p = c as AnyPart; return p?.type === 'text' && typeof p.text === 'string'; }) .map((c) => ({ type: 'text', text: c.text })); } if (r.details && typeof r.details === 'object') { const d = r.details as { sceneId?: unknown; content?: unknown; actions?: unknown }; const details: NonNullable = {}; if (typeof d.sceneId === 'string') details.sceneId = d.sceneId; // Preserve null (failure marker); replace a real content with a length-only // placeholder so the element-count line survives without the element data. if (d.content === null) details.content = null; else if (d.content && typeof d.content === 'object') { const els = (d.content as { elements?: unknown }).elements; details.content = { elements: Array.isArray(els) ? els.map(() => ({})) : [] }; } // edit_interactive_html: keep the success/failure signal, drop the payload. const dh = (d as { html?: unknown }).html; if (dh === null) details.html = null; else if (typeof dh === 'string') details.html = dh.length > 0 ? '…' : ''; const ec = (d as { editCount?: unknown }).editCount; if (typeof ec === 'number') details.editCount = ec; if (Array.isArray(d.actions)) { details.actions = d.actions.map((a) => ({ type: (a as AnyPart)?.type as string | undefined, })); } out.details = details; } return out; } function serializePart(part: unknown, durationMs?: number): SerializedPart | null { const p = part as AnyPart; if (p?.type === 'text' && typeof p.text === 'string') { return { type: 'text', text: p.text }; } if (p?.type === 'reasoning' && typeof p.text === 'string') { return { type: 'reasoning', text: p.text as string, ...(typeof durationMs === 'number' ? { durationMs } : {}), }; } if (p?.type === 'tool-call' && typeof p.toolCallId === 'string') { return { type: 'tool-call', toolCallId: p.toolCallId as string, toolName: (p.toolName as string) ?? 'tool', args: (p.args as Record) ?? {}, ...(p.isError ? { isError: true } : {}), ...(p.result !== undefined ? { result: slimResult(p.result) } : {}), }; } return null; } export function serializeThread( messages: ThreadMessageLike[], /** Final reasoning durations: (messageId, reasoningOrdinal) → ms. Lets the * restored "已思考 N s" survive a refresh (the live timer store is in-memory). */ reasoningDurationMs?: (messageId: string | undefined, ordinal: number) => number | undefined, ): SerializedMessage[] { const out: SerializedMessage[] = []; for (const m of messages) { let content: SerializedPart[] = []; if (Array.isArray(m.content)) { let ord = 0; content = m.content .map((part) => { const isReasoning = (part as AnyPart)?.type === 'reasoning'; const dur = isReasoning ? reasoningDurationMs?.(m.id, ord++) : undefined; return serializePart(part, dur); }) .filter(Boolean) as SerializedPart[]; } else if (typeof m.content === 'string') { content = [{ type: 'text', text: m.content } as SerializedPart]; } if (content.length === 0) continue; out.push({ role: m.role === 'user' ? 'user' : 'assistant', id: m.id, content }); } return out; } export function deserializeThread(saved: SerializedMessage[] | undefined): ThreadMessageLike[] { if (!Array.isArray(saved)) return []; return saved .filter((m) => m && (m.role === 'user' || m.role === 'assistant') && Array.isArray(m.content)) .map((m) => { // Reasoning parts carry our `durationMs` (re-seeded into the timer store on // restore, see use-agent-runtime); assistant-ui only accepts {type, text}. const content = m.content.map((p) => p.type === 'reasoning' ? { type: 'reasoning', text: p.text } : p, ) as ThreadMessageLike['content']; const base = { role: m.role, id: m.id, content }; // assistant-ui rejects a `status` on user messages ("status is only // supported for assistant messages") — only assistant turns carry it. return m.role === 'assistant' ? { ...base, status: { type: 'complete', reason: 'stop' } as ThreadMessageLike['status'] } : base; }); }