From 4c36369dd230fa62eccc9bcab4d1a1ebbc297e88 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 3 Apr 2026 18:56:37 +0800 Subject: [PATCH] feat: add export conversation to image, markdown, and HTML formats Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 1 + pnpm-lock.yaml | 8 + .../conversation-detail-panel.tsx | 129 ++++- src/i18n/messages/ar.json | 27 +- src/i18n/messages/de.json | 27 +- src/i18n/messages/en.json | 27 +- src/i18n/messages/es.json | 27 +- src/i18n/messages/fr.json | 27 +- src/i18n/messages/ja.json | 27 +- src/i18n/messages/ko.json | 27 +- src/i18n/messages/pt.json | 27 +- src/i18n/messages/zh-CN.json | 27 +- src/i18n/messages/zh-TW.json | 27 +- src/lib/export-conversation.ts | 498 ++++++++++++++++++ 14 files changed, 895 insertions(+), 11 deletions(-) create mode 100644 src/lib/export-conversation.ts diff --git a/package.json b/package.json index 1898f99..abc8092 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "html-to-image": "^1.11.13", "ignore": "^7.0.5", "lucide-react": "^0.563.0", "monaco-editor": "^0.55.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ded933..e8da434 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 ignore: specifier: ^7.0.5 version: 7.0.5 @@ -4200,6 +4203,9 @@ packages: resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} engines: {node: '>=16.9.0'} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -11108,6 +11114,8 @@ snapshots: hono@4.11.9: {} + html-to-image@1.11.13: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 2f74af6..abe9374 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -1,7 +1,15 @@ "use client" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" -import { Plus, RefreshCw, X } from "lucide-react" +import { + Download, + FileCode, + FileImage, + FileText, + Plus, + RefreshCw, + X, +} from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { disposeTauriListener } from "@/lib/tauri-listener" @@ -9,6 +17,7 @@ import { useAcpActions } from "@/contexts/acp-connections-context" import { useFolderContext } from "@/contexts/folder-context" import { useTabContext } from "@/contexts/tab-context" import { useSessionStats } from "@/contexts/session-stats-context" +import { useTaskContext } from "@/contexts/task-context" import { cn, randomUUID } from "@/lib/utils" import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle" import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue" @@ -53,8 +62,17 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, ContextMenuTrigger, } from "@/components/ui/context-menu" +import { + exportAsHtml, + exportAsImage, + exportAsMarkdown, + type ExportLabels, +} from "@/lib/export-conversation" interface ConversationTabViewProps { tabId: string @@ -991,6 +1009,8 @@ const ConversationTabView = memo(function ConversationTabView({ export function ConversationDetailPanel() { const t = useTranslations("Folder.conversation") + const tStatus = useTranslations("Folder.statusLabels") + const tExport = useTranslations("Folder.conversation.exportLabels") const { completeTurn: runtimeCompleteTurn, getConversationIdByExternalId, @@ -1014,6 +1034,7 @@ export function ConversationDetailPanel() { onPreviewTabReplaced, } = useTabContext() const { disconnect: disconnectByKey } = useAcpActions() + const { addTask, updateTask } = useTaskContext() const [reloadByTabId, setReloadByTabId] = useState>({}) const tabsRef = useRef(tabs) const conversationsRef = useRef(conversations) @@ -1026,6 +1047,35 @@ export function ConversationDetailPanel() { conversationsRef.current = conversations }, [conversations]) + const exportLabels = useMemo( + () => ({ + untitledConversation: tExport("untitledConversation"), + agent: tExport("agent"), + model: tExport("model"), + status: tExport("status"), + started: tExport("started"), + updated: tExport("updated"), + tokens: tExport("tokens"), + duration: tExport("duration"), + inputTokens: tExport("inputTokens"), + outputTokens: tExport("outputTokens"), + cacheRead: tExport("cacheRead"), + cacheWrite: tExport("cacheWrite"), + user: tExport("user"), + assistant: tExport("assistant"), + system: tExport("system"), + toolResult: tExport("toolResult"), + toolError: tExport("toolError"), + statusLabels: { + in_progress: tStatus("in_progress"), + pending_review: tStatus("pending_review"), + completed: tStatus("completed"), + cancelled: tStatus("cancelled"), + }, + }), + [tExport, tStatus] + ) + // Disconnect the old connection immediately when a preview tab is replaced useEffect(() => { return onPreviewTabReplaced((replacedTabId) => { @@ -1173,6 +1223,63 @@ export function ConversationDetailPanel() { closeTab(activeTabId) }, [activeTabId, closeTab]) + const canExport = + activeConversationTab?.conversationId != null && + getSession(activeConversationTab.conversationId)?.detail != null + + const getExportData = useCallback(() => { + if (!activeConversationTab?.conversationId) return null + const session = getSession(activeConversationTab.conversationId) + if (!session?.detail) return null + return { + summary: session.detail.summary, + turns: session.detail.turns, + sessionStats: session.detail.session_stats, + labels: exportLabels, + } + }, [activeConversationTab, getSession, exportLabels]) + + const handleExportMarkdown = useCallback(() => { + const data = getExportData() + if (!data) return + try { + exportAsMarkdown(data) + toast.success(t("exportSuccess")) + } catch (err) { + toast.error(t("exportFailed")) + console.error("[ConversationDetailPanel] export markdown:", err) + } + }, [getExportData, t]) + + const handleExportHtml = useCallback(() => { + const data = getExportData() + if (!data) return + try { + exportAsHtml(data) + toast.success(t("exportSuccess")) + } catch (err) { + toast.error(t("exportFailed")) + console.error("[ConversationDetailPanel] export html:", err) + } + }, [getExportData, t]) + + const handleExportImage = useCallback(async () => { + const data = getExportData() + if (!data) return + const taskId = `export-image-${Date.now()}` + addTask(taskId, t("exportImage")) + updateTask(taskId, { status: "running" }) + try { + await exportAsImage(data) + updateTask(taskId, { status: "completed" }) + toast.success(t("exportSuccess")) + } catch (err) { + updateTask(taskId, { status: "failed" }) + toast.error(t("exportFailed")) + console.error("[ConversationDetailPanel] export image:", err) + } + }, [getExportData, t, addTask, updateTask]) + // Ensure no-tab state is immediately bridged to a real new-conversation tab. useEffect(() => { if (!folder) return @@ -1247,6 +1354,26 @@ export function ConversationDetailPanel() { {t("newConversation")} + + + + {t("exportConversation")} + + + + + {t("exportImage")} + + + + {t("exportMarkdown")} + + + + {t("exportHtml")} + + + +} + +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 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 + const dataUrl = await toPng(target as HTMLElement, { + width: 800, + pixelRatio: 2, + backgroundColor: "#f9fafb", + }) + 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() + } +}