import type { ContentBlock, DbConversationSummary, MessageTurn, SessionStats, TurnUsage, } from "@/lib/types" import { AGENT_LABELS } from "@/lib/types" import { toPng } from "html-to-image" export interface ExportLabels { untitledConversation: string agent: string model: string status: string started: string updated: string tokens: string duration: string inputTokens: string outputTokens: string cacheRead: string cacheWrite: string user: string assistant: string system: string toolResult: string toolError: string statusLabels: Record } export interface ExportConversationData { summary: DbConversationSummary turns: MessageTurn[] sessionStats?: SessionStats | null labels: ExportLabels } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function downloadFile( content: string, filename: string, mimeType: string ): void { const blob = new Blob([content], { type: mimeType }) const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = filename document.body.append(link) link.click() link.remove() URL.revokeObjectURL(url) } function makeExportFilename(title: string | null, ext: string): string { const date = new Date().toISOString().slice(0, 10) const base = (title ?? "conversation") .replace(/[/\\:*?"<>|]+/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, "") .slice(0, 50) return `${base}-${date}.${ext}` } function formatTimestamp(iso: string): string { const date = new Date(iso) if (isNaN(date.getTime())) return iso return date.toLocaleString() } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms` const seconds = Math.floor(ms / 1000) if (seconds < 60) return `${seconds}s` const minutes = Math.floor(seconds / 60) const remainSeconds = seconds % 60 if (minutes < 60) return `${minutes}m ${remainSeconds}s` const hours = Math.floor(minutes / 60) const remainMinutes = minutes % 60 return `${hours}h ${remainMinutes}m` } function formatTokens(usage: TurnUsage, labels: ExportLabels): string { const parts: string[] = [] parts.push(`${labels.inputTokens}: ${usage.input_tokens.toLocaleString()}`) parts.push(`${labels.outputTokens}: ${usage.output_tokens.toLocaleString()}`) if (usage.cache_read_input_tokens > 0) parts.push( `${labels.cacheRead}: ${usage.cache_read_input_tokens.toLocaleString()}` ) if (usage.cache_creation_input_tokens > 0) parts.push( `${labels.cacheWrite}: ${usage.cache_creation_input_tokens.toLocaleString()}` ) return parts.join(" | ") } function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) } const ALLOWED_IMAGE_MIMES = new Set([ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", "image/bmp", ]) function sanitizeMimeType(mime: string): string { const lower = mime.toLowerCase().trim() return ALLOWED_IMAGE_MIMES.has(lower) ? lower : "image/png" } function localizeStatus(status: string, labels: ExportLabels): string { return labels.statusLabels[status] ?? status } function localizeRole(role: string, labels: ExportLabels): string { switch (role) { case "user": return labels.user case "assistant": return labels.assistant case "system": return labels.system default: return role } } // --------------------------------------------------------------------------- // Tool call formatting helpers // --------------------------------------------------------------------------- function formatToolName(name: string): string { return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) } function formatToolContent(raw: string | null): string { if (!raw) return "" const trimmed = raw.trim() if (!trimmed) return "" // Try to parse JSON and format as key-value summary try { const parsed = JSON.parse(trimmed) if ( typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ) { return Object.entries(parsed) .filter(([, v]) => v != null && v !== "") .map(([k, v]) => { const val = typeof v === "string" ? v : JSON.stringify(v) const display = val.length > 200 ? val.slice(0, 200) + "..." : val return `${k}: ${display}` }) .join("\n") } } catch { // Not JSON — use as-is } if (trimmed.length > 500) return trimmed.slice(0, 500) + "..." return trimmed } // --------------------------------------------------------------------------- // Content block formatters // --------------------------------------------------------------------------- function blocksToMarkdown( blocks: ContentBlock[], labels: ExportLabels ): string { return blocks .map((block) => { switch (block.type) { case "text": return block.text case "thinking": return block.text .split("\n") .map((line) => `> ${line}`) .join("\n") case "tool_use": { const name = formatToolName(block.tool_name) const content = formatToolContent(block.input_preview) if (!content) return `> **${name}**` return `> **${name}**\n>\n${content .split("\n") .map((line) => `> \`${line}\``) .join("\n")}` } case "tool_result": { const content = formatToolContent(block.output_preview) const label = block.is_error ? labels.toolError : labels.toolResult if (!content) return `> *${label}*` return `> *${label}:*\n>\n${content .split("\n") .map((line) => `> ${line}`) .join("\n")}` } case "image": return `![image](data:${sanitizeMimeType(block.mime_type)};base64,${block.data})` default: return "" } }) .filter(Boolean) .join("\n\n") } function blocksToHtml(blocks: ContentBlock[], labels: ExportLabels): string { return blocks .map((block) => { switch (block.type) { case "text": return `
${escapeHtml(block.text).replace(/\n/g, "
")}
` case "thinking": return `
${escapeHtml(block.text).replace(/\n/g, "
")}
` case "tool_use": { const name = formatToolName(block.tool_name) const content = formatToolContent(block.input_preview) return `
${escapeHtml(name)}${content ? `
${escapeHtml(content).replace(/\n/g, "
")}
` : ""}
` } case "tool_result": { const content = formatToolContent(block.output_preview) const label = block.is_error ? labels.toolError : labels.toolResult return `
${escapeHtml(label)}${content ? `
${escapeHtml(content).replace(/\n/g, "
")}
` : ""}
` } case "image": return `
image
` default: return "" } }) .filter(Boolean) .join("\n") } // --------------------------------------------------------------------------- // Metadata formatters // --------------------------------------------------------------------------- function metadataMarkdown( summary: DbConversationSummary, labels: ExportLabels, stats?: SessionStats | null ): string { const lines: string[] = [] lines.push(`# ${summary.title ?? labels.untitledConversation}`) lines.push("") lines.push(`| | |`) lines.push(`|---|---|`) lines.push( `| **${labels.agent}** | ${AGENT_LABELS[summary.agent_type] ?? summary.agent_type} |` ) if (summary.model) lines.push(`| **${labels.model}** | ${summary.model} |`) lines.push( `| **${labels.status}** | ${localizeStatus(summary.status, labels)} |` ) lines.push( `| **${labels.started}** | ${formatTimestamp(summary.created_at)} |` ) if (summary.updated_at) lines.push( `| **${labels.updated}** | ${formatTimestamp(summary.updated_at)} |` ) if (stats?.total_usage) lines.push( `| **${labels.tokens}** | ${formatTokens(stats.total_usage, labels)} |` ) if (stats?.total_duration_ms) lines.push( `| **${labels.duration}** | ${formatDuration(stats.total_duration_ms)} |` ) return lines.join("\n") } function metadataHtml( summary: DbConversationSummary, labels: ExportLabels, stats?: SessionStats | null ): string { const rows: string[] = [] rows.push( `${escapeHtml(labels.agent)}${escapeHtml(AGENT_LABELS[summary.agent_type] ?? summary.agent_type)}` ) if (summary.model) rows.push( `${escapeHtml(labels.model)}${escapeHtml(summary.model)}` ) rows.push( `${escapeHtml(labels.status)}${escapeHtml(localizeStatus(summary.status, labels))}` ) rows.push( `${escapeHtml(labels.started)}${escapeHtml(formatTimestamp(summary.created_at))}` ) if (summary.updated_at) rows.push( `${escapeHtml(labels.updated)}${escapeHtml(formatTimestamp(summary.updated_at))}` ) if (stats?.total_usage) rows.push( `${escapeHtml(labels.tokens)}${escapeHtml(formatTokens(stats.total_usage, labels))}` ) if (stats?.total_duration_ms) rows.push( `${escapeHtml(labels.duration)}${escapeHtml(formatDuration(stats.total_duration_ms))}` ) return `

${escapeHtml(summary.title ?? labels.untitledConversation)}

${rows.join("")}
` } // --------------------------------------------------------------------------- // HTML template // --------------------------------------------------------------------------- const HTML_STYLES = ` body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#f9fafb;color:#111827;line-height:1.6} .container{max-width:800px;margin:0 auto;padding:24px} header{margin-bottom:24px} h1{font-size:1.5rem;margin:0 0 16px} .meta{width:100%;border-collapse:collapse;margin-bottom:0;background:#f3f4f6;border-radius:8px;overflow:hidden;border:1px solid #e5e7eb} .meta td{padding:6px 12px;font-size:0.875rem} .meta td:first-child{font-weight:600;white-space:nowrap;width:100px} .message{margin-bottom:16px;padding:12px 16px;border-radius:12px;border:1px solid #e5e7eb;background:#fff} .message.user{background:#eff6ff;border-color:#bfdbfe} .message.assistant{background:#fff} .message.system{background:#fefce8;border-color:#fde68a} .role{font-weight:700;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:4px;opacity:0.7} .turn-meta{font-size:0.75rem;color:#6b7280;margin-bottom:8px} .text-block{white-space:pre-wrap;word-break:break-word} .thinking{border-left:3px solid #9ca3af;padding:8px 12px;margin:8px 0;color:#6b7280;font-style:italic;background:#f9fafb;border-radius:0 8px 8px 0} .tool-use,.tool-result{background:#f3f4f6;border:1px solid #e5e7eb;border-radius:8px;margin:8px 0;font-size:0.875rem} .tool-summary{padding:8px 12px;cursor:pointer;font-weight:600;font-size:0.75rem;user-select:none;list-style:none;display:flex;align-items:center;gap:6px} .tool-summary::before{content:"\\25B6";font-size:0.6rem;transition:transform 0.15s} details[open]>.tool-summary::before{transform:rotate(90deg)} .tool-summary::-webkit-details-marker{display:none} .tool-content{padding:4px 12px 8px;font-size:0.8125rem;color:#4b5563;border-top:1px solid #e5e7eb;line-height:1.5} .tool-result.error{border-color:#fca5a5;background:#fef2f2} .tool-result.error .tool-summary{color:#dc2626} pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:0.8125rem} .image-block{margin:8px 0} .footer{margin-top:24px;padding-top:12px;font-size:0.75rem;color:#9ca3af;text-align:center} ` function buildHtmlDocument(data: ExportConversationData): string { const { summary, turns, sessionStats, labels } = data const header = metadataHtml(summary, labels, sessionStats) const messages = turns .map((turn) => { const turnMeta: string[] = [] turnMeta.push(formatTimestamp(turn.timestamp)) if (turn.model) turnMeta.push(turn.model) if (turn.usage) turnMeta.push(formatTokens(turn.usage, labels)) if (turn.duration_ms) turnMeta.push(formatDuration(turn.duration_ms)) return `
${escapeHtml(localizeRole(turn.role, labels))}
${escapeHtml(turnMeta.join(" · "))}
${blocksToHtml(turn.blocks, labels)}
` }) .join("\n") return ` ${escapeHtml(summary.title ?? labels.untitledConversation)}
${header}
${messages}
` } // --------------------------------------------------------------------------- // Public export functions // --------------------------------------------------------------------------- export function exportAsMarkdown(data: ExportConversationData): void { const { summary, turns, sessionStats, labels } = data const parts: string[] = [] parts.push(metadataMarkdown(summary, labels, sessionStats)) parts.push("\n\n---\n") for (const turn of turns) { parts.push(`## ${localizeRole(turn.role, labels)}`) const meta: string[] = [] meta.push(formatTimestamp(turn.timestamp)) if (turn.model) meta.push(`${labels.model}: ${turn.model}`) if (turn.usage) meta.push(formatTokens(turn.usage, labels)) if (turn.duration_ms) meta.push(formatDuration(turn.duration_ms)) if (meta.length > 0) parts.push(`*${meta.join(" · ")}*`) parts.push("") parts.push(blocksToMarkdown(turn.blocks, labels)) parts.push("") } parts.push("---") parts.push("*Codeg*") const content = parts.join("\n") downloadFile( content, makeExportFilename(summary.title, "md"), "text/markdown" ) } export function exportAsHtml(data: ExportConversationData): void { const html = buildHtmlDocument(data) downloadFile( html, makeExportFilename(data.summary.title, "html"), "text/html" ) } // Safari caps at 16384, Chrome at ~32767. Use a safe limit. const MAX_IMAGE_HEIGHT = 16000 export class ExportTooLongError extends Error { constructor() { super("Content too long for image export") this.name = "ExportTooLongError" } } export async function exportAsImage( data: ExportConversationData ): Promise { const html = buildHtmlDocument(data) const iframe = document.createElement("iframe") iframe.style.cssText = "position:fixed;left:0;top:0;width:800px;height:0;border:none;opacity:0;pointer-events:none;z-index:-1;" document.body.appendChild(iframe) try { iframe.srcdoc = html await new Promise((resolve) => { iframe.onload = () => resolve() }) const iframeDoc = iframe.contentDocument if (!iframeDoc) throw new Error("Cannot access iframe document") const body = iframeDoc.body const contentHeight = Math.min(body.scrollHeight, MAX_IMAGE_HEIGHT) iframe.style.height = `${contentHeight}px` await new Promise((resolve) => { if (iframe.contentWindow) { iframe.contentWindow.requestAnimationFrame(() => resolve()) } else { setTimeout(resolve, 50) } }) const target = iframeDoc.querySelector(".container") ?? body let dataUrl: string try { dataUrl = await toPng(target as HTMLElement, { width: 800, pixelRatio: 2, backgroundColor: "#f9fafb", }) } catch { throw new ExportTooLongError() } const res = await fetch(dataUrl) const blob = await res.blob() const url = URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = makeExportFilename(data.summary.title, "png") document.body.append(link) link.click() link.remove() URL.revokeObjectURL(url) } finally { iframe.remove() } }