feat: add export conversation to image, markdown, and HTML formats
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"html-to-image": "^1.11.13",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -80,6 +80,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
html-to-image:
|
||||||
|
specifier: ^1.11.13
|
||||||
|
version: 1.11.13
|
||||||
ignore:
|
ignore:
|
||||||
specifier: ^7.0.5
|
specifier: ^7.0.5
|
||||||
version: 7.0.5
|
version: 7.0.5
|
||||||
@@ -4200,6 +4203,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==}
|
resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
html-to-image@1.11.13:
|
||||||
|
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||||
|
|
||||||
html-url-attributes@3.0.1:
|
html-url-attributes@3.0.1:
|
||||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||||
|
|
||||||
@@ -11108,6 +11114,8 @@ snapshots:
|
|||||||
|
|
||||||
hono@4.11.9: {}
|
hono@4.11.9: {}
|
||||||
|
|
||||||
|
html-to-image@1.11.13: {}
|
||||||
|
|
||||||
html-url-attributes@3.0.1: {}
|
html-url-attributes@3.0.1: {}
|
||||||
|
|
||||||
html-void-elements@3.0.0: {}
|
html-void-elements@3.0.0: {}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
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 { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||||
@@ -9,6 +17,7 @@ import { useAcpActions } from "@/contexts/acp-connections-context"
|
|||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useTabContext } from "@/contexts/tab-context"
|
import { useTabContext } from "@/contexts/tab-context"
|
||||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||||
|
import { useTaskContext } from "@/contexts/task-context"
|
||||||
import { cn, randomUUID } from "@/lib/utils"
|
import { cn, randomUUID } from "@/lib/utils"
|
||||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
||||||
import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue"
|
import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue"
|
||||||
@@ -53,8 +62,17 @@ import {
|
|||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
|
ContextMenuSub,
|
||||||
|
ContextMenuSubContent,
|
||||||
|
ContextMenuSubTrigger,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu"
|
} from "@/components/ui/context-menu"
|
||||||
|
import {
|
||||||
|
exportAsHtml,
|
||||||
|
exportAsImage,
|
||||||
|
exportAsMarkdown,
|
||||||
|
type ExportLabels,
|
||||||
|
} from "@/lib/export-conversation"
|
||||||
|
|
||||||
interface ConversationTabViewProps {
|
interface ConversationTabViewProps {
|
||||||
tabId: string
|
tabId: string
|
||||||
@@ -991,6 +1009,8 @@ const ConversationTabView = memo(function ConversationTabView({
|
|||||||
|
|
||||||
export function ConversationDetailPanel() {
|
export function ConversationDetailPanel() {
|
||||||
const t = useTranslations("Folder.conversation")
|
const t = useTranslations("Folder.conversation")
|
||||||
|
const tStatus = useTranslations("Folder.statusLabels")
|
||||||
|
const tExport = useTranslations("Folder.conversation.exportLabels")
|
||||||
const {
|
const {
|
||||||
completeTurn: runtimeCompleteTurn,
|
completeTurn: runtimeCompleteTurn,
|
||||||
getConversationIdByExternalId,
|
getConversationIdByExternalId,
|
||||||
@@ -1014,6 +1034,7 @@ export function ConversationDetailPanel() {
|
|||||||
onPreviewTabReplaced,
|
onPreviewTabReplaced,
|
||||||
} = useTabContext()
|
} = useTabContext()
|
||||||
const { disconnect: disconnectByKey } = useAcpActions()
|
const { disconnect: disconnectByKey } = useAcpActions()
|
||||||
|
const { addTask, updateTask } = useTaskContext()
|
||||||
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
|
const [reloadByTabId, setReloadByTabId] = useState<Record<string, number>>({})
|
||||||
const tabsRef = useRef(tabs)
|
const tabsRef = useRef(tabs)
|
||||||
const conversationsRef = useRef(conversations)
|
const conversationsRef = useRef(conversations)
|
||||||
@@ -1026,6 +1047,35 @@ export function ConversationDetailPanel() {
|
|||||||
conversationsRef.current = conversations
|
conversationsRef.current = conversations
|
||||||
}, [conversations])
|
}, [conversations])
|
||||||
|
|
||||||
|
const exportLabels = useMemo<ExportLabels>(
|
||||||
|
() => ({
|
||||||
|
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
|
// Disconnect the old connection immediately when a preview tab is replaced
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onPreviewTabReplaced((replacedTabId) => {
|
return onPreviewTabReplaced((replacedTabId) => {
|
||||||
@@ -1173,6 +1223,63 @@ export function ConversationDetailPanel() {
|
|||||||
closeTab(activeTabId)
|
closeTab(activeTabId)
|
||||||
}, [activeTabId, closeTab])
|
}, [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.
|
// Ensure no-tab state is immediately bridged to a real new-conversation tab.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!folder) return
|
if (!folder) return
|
||||||
@@ -1247,6 +1354,26 @@ export function ConversationDetailPanel() {
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
{t("newConversation")}
|
{t("newConversation")}
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSub>
|
||||||
|
<ContextMenuSubTrigger disabled={!canExport}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{t("exportConversation")}
|
||||||
|
</ContextMenuSubTrigger>
|
||||||
|
<ContextMenuSubContent>
|
||||||
|
<ContextMenuItem onSelect={handleExportImage}>
|
||||||
|
<FileImage className="h-4 w-4" />
|
||||||
|
{t("exportImage")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={handleExportMarkdown}>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
{t("exportMarkdown")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onSelect={handleExportHtml}>
|
||||||
|
<FileCode className="h-4 w-4" />
|
||||||
|
{t("exportHtml")}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuSubContent>
|
||||||
|
</ContextMenuSub>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
disabled={!activeTabId}
|
disabled={!activeTabId}
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "إغلاق المحادثة",
|
"closeConversation": "إغلاق المحادثة",
|
||||||
"forkSession": "تفريع الجلسة",
|
"forkSession": "تفريع الجلسة",
|
||||||
"forkSessionSuccess": "تم تفريع الجلسة بنجاح",
|
"forkSessionSuccess": "تم تفريع الجلسة بنجاح",
|
||||||
"forkSessionFailed": "فشل في تفريع الجلسة: {error}"
|
"forkSessionFailed": "فشل في تفريع الجلسة: {error}",
|
||||||
|
"exportConversation": "تصدير المحادثة",
|
||||||
|
"exportImage": "صورة",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "تم تصدير المحادثة",
|
||||||
|
"exportFailed": "فشل التصدير",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "محادثة بدون عنوان",
|
||||||
|
"agent": "الوكيل",
|
||||||
|
"model": "النموذج",
|
||||||
|
"status": "الحالة",
|
||||||
|
"started": "البداية",
|
||||||
|
"updated": "التحديث",
|
||||||
|
"tokens": "إحصائيات الرموز",
|
||||||
|
"duration": "المدة",
|
||||||
|
"inputTokens": "الإدخال",
|
||||||
|
"outputTokens": "الإخراج",
|
||||||
|
"cacheRead": "قراءة الذاكرة",
|
||||||
|
"cacheWrite": "كتابة الذاكرة",
|
||||||
|
"user": "المستخدم",
|
||||||
|
"assistant": "المساعد",
|
||||||
|
"system": "النظام",
|
||||||
|
"toolResult": "النتيجة",
|
||||||
|
"toolError": "خطأ"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "محادثة بدون عنوان",
|
"untitledConversation": "محادثة بدون عنوان",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "Konversation schließen",
|
"closeConversation": "Konversation schließen",
|
||||||
"forkSession": "Sitzung forken",
|
"forkSession": "Sitzung forken",
|
||||||
"forkSessionSuccess": "Sitzung erfolgreich geforkt",
|
"forkSessionSuccess": "Sitzung erfolgreich geforkt",
|
||||||
"forkSessionFailed": "Sitzung konnte nicht geforkt werden: {error}"
|
"forkSessionFailed": "Sitzung konnte nicht geforkt werden: {error}",
|
||||||
|
"exportConversation": "Konversation exportieren",
|
||||||
|
"exportImage": "Bild",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "Konversation exportiert",
|
||||||
|
"exportFailed": "Export fehlgeschlagen",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "Unbenannte Konversation",
|
||||||
|
"agent": "Agent",
|
||||||
|
"model": "Modell",
|
||||||
|
"status": "Status",
|
||||||
|
"started": "Gestartet",
|
||||||
|
"updated": "Aktualisiert",
|
||||||
|
"tokens": "Token-Statistik",
|
||||||
|
"duration": "Dauer",
|
||||||
|
"inputTokens": "Eingabe",
|
||||||
|
"outputTokens": "Ausgabe",
|
||||||
|
"cacheRead": "Cache gelesen",
|
||||||
|
"cacheWrite": "Cache geschrieben",
|
||||||
|
"user": "Benutzer",
|
||||||
|
"assistant": "Assistent",
|
||||||
|
"system": "System",
|
||||||
|
"toolResult": "Ergebnis",
|
||||||
|
"toolError": "Fehler"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "Unbenannte Konversation",
|
"untitledConversation": "Unbenannte Konversation",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "Close Conversation",
|
"closeConversation": "Close Conversation",
|
||||||
"forkSession": "Fork Session",
|
"forkSession": "Fork Session",
|
||||||
"forkSessionSuccess": "Session forked successfully",
|
"forkSessionSuccess": "Session forked successfully",
|
||||||
"forkSessionFailed": "Failed to fork session: {error}"
|
"forkSessionFailed": "Failed to fork session: {error}",
|
||||||
|
"exportConversation": "Export Conversation",
|
||||||
|
"exportImage": "Image",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "Conversation exported",
|
||||||
|
"exportFailed": "Export failed",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "Untitled Conversation",
|
||||||
|
"agent": "Agent",
|
||||||
|
"model": "Model",
|
||||||
|
"status": "Status",
|
||||||
|
"started": "Started",
|
||||||
|
"updated": "Updated",
|
||||||
|
"tokens": "Token Stats",
|
||||||
|
"duration": "Duration",
|
||||||
|
"inputTokens": "Input",
|
||||||
|
"outputTokens": "Output",
|
||||||
|
"cacheRead": "Cache Read",
|
||||||
|
"cacheWrite": "Cache Write",
|
||||||
|
"user": "User",
|
||||||
|
"assistant": "Assistant",
|
||||||
|
"system": "System",
|
||||||
|
"toolResult": "Result",
|
||||||
|
"toolError": "Error"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "Untitled conversation",
|
"untitledConversation": "Untitled conversation",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "Cerrar conversación",
|
"closeConversation": "Cerrar conversación",
|
||||||
"forkSession": "Bifurcar sesión",
|
"forkSession": "Bifurcar sesión",
|
||||||
"forkSessionSuccess": "Sesión bifurcada exitosamente",
|
"forkSessionSuccess": "Sesión bifurcada exitosamente",
|
||||||
"forkSessionFailed": "Error al bifurcar la sesión: {error}"
|
"forkSessionFailed": "Error al bifurcar la sesión: {error}",
|
||||||
|
"exportConversation": "Exportar conversación",
|
||||||
|
"exportImage": "Imagen",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "Conversación exportada",
|
||||||
|
"exportFailed": "Error al exportar",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "Conversación sin título",
|
||||||
|
"agent": "Agente",
|
||||||
|
"model": "Modelo",
|
||||||
|
"status": "Estado",
|
||||||
|
"started": "Inicio",
|
||||||
|
"updated": "Actualizado",
|
||||||
|
"tokens": "Estadísticas de tokens",
|
||||||
|
"duration": "Duración",
|
||||||
|
"inputTokens": "Entrada",
|
||||||
|
"outputTokens": "Salida",
|
||||||
|
"cacheRead": "Caché leída",
|
||||||
|
"cacheWrite": "Caché escrita",
|
||||||
|
"user": "Usuario",
|
||||||
|
"assistant": "Asistente",
|
||||||
|
"system": "Sistema",
|
||||||
|
"toolResult": "Resultado",
|
||||||
|
"toolError": "Error"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "Conversación sin título",
|
"untitledConversation": "Conversación sin título",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "Fermer la conversation",
|
"closeConversation": "Fermer la conversation",
|
||||||
"forkSession": "Dupliquer la session",
|
"forkSession": "Dupliquer la session",
|
||||||
"forkSessionSuccess": "Session dupliquée avec succès",
|
"forkSessionSuccess": "Session dupliquée avec succès",
|
||||||
"forkSessionFailed": "Échec de la duplication de la session : {error}"
|
"forkSessionFailed": "Échec de la duplication de la session : {error}",
|
||||||
|
"exportConversation": "Exporter la conversation",
|
||||||
|
"exportImage": "Image",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "Conversation exportée",
|
||||||
|
"exportFailed": "Échec de l'exportation",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "Conversation sans titre",
|
||||||
|
"agent": "Agent",
|
||||||
|
"model": "Modèle",
|
||||||
|
"status": "Statut",
|
||||||
|
"started": "Début",
|
||||||
|
"updated": "Mis à jour",
|
||||||
|
"tokens": "Statistiques de tokens",
|
||||||
|
"duration": "Durée",
|
||||||
|
"inputTokens": "Entrée",
|
||||||
|
"outputTokens": "Sortie",
|
||||||
|
"cacheRead": "Cache lu",
|
||||||
|
"cacheWrite": "Cache écrit",
|
||||||
|
"user": "Utilisateur",
|
||||||
|
"assistant": "Assistant",
|
||||||
|
"system": "Système",
|
||||||
|
"toolResult": "Résultat",
|
||||||
|
"toolError": "Erreur"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "Conversation sans titre",
|
"untitledConversation": "Conversation sans titre",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "会話を閉じる",
|
"closeConversation": "会話を閉じる",
|
||||||
"forkSession": "セッションをフォーク",
|
"forkSession": "セッションをフォーク",
|
||||||
"forkSessionSuccess": "セッションのフォークに成功しました",
|
"forkSessionSuccess": "セッションのフォークに成功しました",
|
||||||
"forkSessionFailed": "セッションのフォークに失敗しました:{error}"
|
"forkSessionFailed": "セッションのフォークに失敗しました:{error}",
|
||||||
|
"exportConversation": "会話をエクスポート",
|
||||||
|
"exportImage": "画像",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "会話をエクスポートしました",
|
||||||
|
"exportFailed": "エクスポートに失敗しました",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "無題の会話",
|
||||||
|
"agent": "エージェント",
|
||||||
|
"model": "モデル",
|
||||||
|
"status": "ステータス",
|
||||||
|
"started": "開始",
|
||||||
|
"updated": "更新",
|
||||||
|
"tokens": "トークン統計",
|
||||||
|
"duration": "所要時間",
|
||||||
|
"inputTokens": "入力",
|
||||||
|
"outputTokens": "出力",
|
||||||
|
"cacheRead": "キャッシュ読取",
|
||||||
|
"cacheWrite": "キャッシュ書込",
|
||||||
|
"user": "ユーザー",
|
||||||
|
"assistant": "アシスタント",
|
||||||
|
"system": "システム",
|
||||||
|
"toolResult": "結果",
|
||||||
|
"toolError": "エラー"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "無題の会話",
|
"untitledConversation": "無題の会話",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "대화 닫기",
|
"closeConversation": "대화 닫기",
|
||||||
"forkSession": "세션 포크",
|
"forkSession": "세션 포크",
|
||||||
"forkSessionSuccess": "세션 포크 성공",
|
"forkSessionSuccess": "세션 포크 성공",
|
||||||
"forkSessionFailed": "세션 포크 실패: {error}"
|
"forkSessionFailed": "세션 포크 실패: {error}",
|
||||||
|
"exportConversation": "대화 내보내기",
|
||||||
|
"exportImage": "이미지",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "대화를 내보냈습니다",
|
||||||
|
"exportFailed": "내보내기 실패",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "제목 없는 대화",
|
||||||
|
"agent": "에이전트",
|
||||||
|
"model": "모델",
|
||||||
|
"status": "상태",
|
||||||
|
"started": "시작",
|
||||||
|
"updated": "업데이트",
|
||||||
|
"tokens": "토큰 통계",
|
||||||
|
"duration": "소요 시간",
|
||||||
|
"inputTokens": "입력",
|
||||||
|
"outputTokens": "출력",
|
||||||
|
"cacheRead": "캐시 읽기",
|
||||||
|
"cacheWrite": "캐시 쓰기",
|
||||||
|
"user": "사용자",
|
||||||
|
"assistant": "어시스턴트",
|
||||||
|
"system": "시스템",
|
||||||
|
"toolResult": "결과",
|
||||||
|
"toolError": "오류"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "제목 없는 대화",
|
"untitledConversation": "제목 없는 대화",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "Fechar conversa",
|
"closeConversation": "Fechar conversa",
|
||||||
"forkSession": "Bifurcar sessão",
|
"forkSession": "Bifurcar sessão",
|
||||||
"forkSessionSuccess": "Sessão bifurcada com sucesso",
|
"forkSessionSuccess": "Sessão bifurcada com sucesso",
|
||||||
"forkSessionFailed": "Falha ao bifurcar a sessão: {error}"
|
"forkSessionFailed": "Falha ao bifurcar a sessão: {error}",
|
||||||
|
"exportConversation": "Exportar conversa",
|
||||||
|
"exportImage": "Imagem",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "Conversa exportada",
|
||||||
|
"exportFailed": "Falha ao exportar",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "Conversa sem título",
|
||||||
|
"agent": "Agente",
|
||||||
|
"model": "Modelo",
|
||||||
|
"status": "Estado",
|
||||||
|
"started": "Início",
|
||||||
|
"updated": "Atualizado",
|
||||||
|
"tokens": "Estatísticas de tokens",
|
||||||
|
"duration": "Duração",
|
||||||
|
"inputTokens": "Entrada",
|
||||||
|
"outputTokens": "Saída",
|
||||||
|
"cacheRead": "Cache lido",
|
||||||
|
"cacheWrite": "Cache escrito",
|
||||||
|
"user": "Utilizador",
|
||||||
|
"assistant": "Assistente",
|
||||||
|
"system": "Sistema",
|
||||||
|
"toolResult": "Resultado",
|
||||||
|
"toolError": "Erro"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "Conversa sem título",
|
"untitledConversation": "Conversa sem título",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "关闭会话",
|
"closeConversation": "关闭会话",
|
||||||
"forkSession": "分叉会话",
|
"forkSession": "分叉会话",
|
||||||
"forkSessionSuccess": "会话分叉成功",
|
"forkSessionSuccess": "会话分叉成功",
|
||||||
"forkSessionFailed": "会话分叉失败:{error}"
|
"forkSessionFailed": "会话分叉失败:{error}",
|
||||||
|
"exportConversation": "导出会话",
|
||||||
|
"exportImage": "图片",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "会话已导出",
|
||||||
|
"exportFailed": "导出失败",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "未命名会话",
|
||||||
|
"agent": "代理",
|
||||||
|
"model": "模型",
|
||||||
|
"status": "状态",
|
||||||
|
"started": "开始时间",
|
||||||
|
"updated": "更新时间",
|
||||||
|
"tokens": "令牌统计",
|
||||||
|
"duration": "时长",
|
||||||
|
"inputTokens": "输入",
|
||||||
|
"outputTokens": "输出",
|
||||||
|
"cacheRead": "缓存读取",
|
||||||
|
"cacheWrite": "缓存写入",
|
||||||
|
"user": "用户",
|
||||||
|
"assistant": "助手",
|
||||||
|
"system": "系统",
|
||||||
|
"toolResult": "结果",
|
||||||
|
"toolError": "错误"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "未命名会话",
|
"untitledConversation": "未命名会话",
|
||||||
|
|||||||
@@ -736,7 +736,32 @@
|
|||||||
"closeConversation": "關閉會話",
|
"closeConversation": "關閉會話",
|
||||||
"forkSession": "分叉會話",
|
"forkSession": "分叉會話",
|
||||||
"forkSessionSuccess": "會話分叉成功",
|
"forkSessionSuccess": "會話分叉成功",
|
||||||
"forkSessionFailed": "會話分叉失敗:{error}"
|
"forkSessionFailed": "會話分叉失敗:{error}",
|
||||||
|
"exportConversation": "匯出對話",
|
||||||
|
"exportImage": "圖片",
|
||||||
|
"exportMarkdown": "Markdown",
|
||||||
|
"exportHtml": "HTML",
|
||||||
|
"exportSuccess": "對話已匯出",
|
||||||
|
"exportFailed": "匯出失敗",
|
||||||
|
"exportLabels": {
|
||||||
|
"untitledConversation": "未命名對話",
|
||||||
|
"agent": "代理",
|
||||||
|
"model": "模型",
|
||||||
|
"status": "狀態",
|
||||||
|
"started": "開始時間",
|
||||||
|
"updated": "更新時間",
|
||||||
|
"tokens": "令牌統計",
|
||||||
|
"duration": "時長",
|
||||||
|
"inputTokens": "輸入",
|
||||||
|
"outputTokens": "輸出",
|
||||||
|
"cacheRead": "快取讀取",
|
||||||
|
"cacheWrite": "快取寫入",
|
||||||
|
"user": "使用者",
|
||||||
|
"assistant": "助手",
|
||||||
|
"system": "系統",
|
||||||
|
"toolResult": "結果",
|
||||||
|
"toolError": "錯誤"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"conversationCard": {
|
"conversationCard": {
|
||||||
"untitledConversation": "未命名會話",
|
"untitledConversation": "未命名會話",
|
||||||
|
|||||||
498
src/lib/export-conversation.ts
Normal file
498
src/lib/export-conversation.ts
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
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<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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, ">")
|
||||||
|
.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 `};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 `<div class="text-block">${escapeHtml(block.text).replace(/\n/g, "<br>")}</div>`
|
||||||
|
case "thinking":
|
||||||
|
return `<blockquote class="thinking">${escapeHtml(block.text).replace(/\n/g, "<br>")}</blockquote>`
|
||||||
|
case "tool_use": {
|
||||||
|
const name = formatToolName(block.tool_name)
|
||||||
|
const content = formatToolContent(block.input_preview)
|
||||||
|
return `<details class="tool-use"><summary class="tool-summary">${escapeHtml(name)}</summary>${content ? `<div class="tool-content">${escapeHtml(content).replace(/\n/g, "<br>")}</div>` : ""}</details>`
|
||||||
|
}
|
||||||
|
case "tool_result": {
|
||||||
|
const content = formatToolContent(block.output_preview)
|
||||||
|
const label = block.is_error ? labels.toolError : labels.toolResult
|
||||||
|
return `<details class="tool-result ${block.is_error ? "error" : ""}"><summary class="tool-summary">${escapeHtml(label)}</summary>${content ? `<div class="tool-content">${escapeHtml(content).replace(/\n/g, "<br>")}</div>` : ""}</details>`
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
return `<div class="image-block"><img src="data:${sanitizeMimeType(block.mime_type)};base64,${escapeHtml(block.data)}" alt="image" style="max-width:100%;border-radius:8px;" /></div>`
|
||||||
|
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(
|
||||||
|
`<tr><td>${escapeHtml(labels.agent)}</td><td>${escapeHtml(AGENT_LABELS[summary.agent_type] ?? summary.agent_type)}</td></tr>`
|
||||||
|
)
|
||||||
|
if (summary.model)
|
||||||
|
rows.push(
|
||||||
|
`<tr><td>${escapeHtml(labels.model)}</td><td>${escapeHtml(summary.model)}</td></tr>`
|
||||||
|
)
|
||||||
|
rows.push(
|
||||||
|
`<tr><td>${escapeHtml(labels.status)}</td><td>${escapeHtml(localizeStatus(summary.status, labels))}</td></tr>`
|
||||||
|
)
|
||||||
|
rows.push(
|
||||||
|
`<tr><td>${escapeHtml(labels.started)}</td><td>${escapeHtml(formatTimestamp(summary.created_at))}</td></tr>`
|
||||||
|
)
|
||||||
|
if (summary.updated_at)
|
||||||
|
rows.push(
|
||||||
|
`<tr><td>${escapeHtml(labels.updated)}</td><td>${escapeHtml(formatTimestamp(summary.updated_at))}</td></tr>`
|
||||||
|
)
|
||||||
|
if (stats?.total_usage)
|
||||||
|
rows.push(
|
||||||
|
`<tr><td>${escapeHtml(labels.tokens)}</td><td>${escapeHtml(formatTokens(stats.total_usage, labels))}</td></tr>`
|
||||||
|
)
|
||||||
|
if (stats?.total_duration_ms)
|
||||||
|
rows.push(
|
||||||
|
`<tr><td>${escapeHtml(labels.duration)}</td><td>${escapeHtml(formatDuration(stats.total_duration_ms))}</td></tr>`
|
||||||
|
)
|
||||||
|
|
||||||
|
return `<header>
|
||||||
|
<h1>${escapeHtml(summary.title ?? labels.untitledConversation)}</h1>
|
||||||
|
<table class="meta">${rows.join("")}</table>
|
||||||
|
</header>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 `<div class="message ${turn.role}">
|
||||||
|
<div class="role">${escapeHtml(localizeRole(turn.role, labels))}</div>
|
||||||
|
<div class="turn-meta">${escapeHtml(turnMeta.join(" · "))}</div>
|
||||||
|
${blocksToHtml(turn.blocks, labels)}
|
||||||
|
</div>`
|
||||||
|
})
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${escapeHtml(summary.title ?? labels.untitledConversation)}</title>
|
||||||
|
<style>${HTML_STYLES}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
${header}
|
||||||
|
<main>${messages}</main>
|
||||||
|
<div class="footer">Codeg</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<void> {
|
||||||
|
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<void>((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<void>((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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user