diff --git a/src/lib/components/SessionHistoryPanel.svelte b/src/lib/components/SessionHistoryPanel.svelte index f22995c..4d0642e 100644 --- a/src/lib/components/SessionHistoryPanel.svelte +++ b/src/lib/components/SessionHistoryPanel.svelte @@ -102,6 +102,16 @@ await sessionsStore.exportSessionAsMarkdown(sessionId); } + async function handleExportHtml(sessionId: string): Promise { + showExportMenu = null; + await sessionsStore.exportSessionAsHtml(sessionId); + } + + async function handleExportPdf(sessionId: string): Promise { + showExportMenu = null; + await sessionsStore.exportSessionAsPdf(sessionId); + } + async function handleImport(): Promise { isImporting = true; try { @@ -361,6 +371,18 @@ > Export as Markdown + + {/if} diff --git a/src/lib/stores/sessions.ts b/src/lib/stores/sessions.ts index c28f3d9..02c5c19 100644 --- a/src/lib/stores/sessions.ts +++ b/src/lib/stores/sessions.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { save, open } from "@tauri-apps/plugin-dialog"; import type { Conversation } from "./conversations"; import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs"; +import { openPath } from "@tauri-apps/plugin-opener"; // Debounce delay for auto-save (in milliseconds) const AUTO_SAVE_DEBOUNCE_MS = 2000; @@ -36,6 +37,335 @@ export interface SessionListItem { preview: string; } +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function generateHtmlExport(session: SavedSession, includeMetadata: boolean): string { + const styles = ` + :root { + --bg-primary: #1a1a2e; + --bg-secondary: #16213e; + --bg-code: #1e1e2e; + --accent-primary: #e94560; + --text-primary: #ffffff; + --text-secondary: #a0a0a0; + --border-color: #2a2a4a; + --terminal-user: #22d3ee; + --terminal-tool: #c084fc; + --terminal-error: #f87171; + --hljs-keyword: #f472b6; + --hljs-string: #a3e635; + --hljs-number: #fbbf24; + --hljs-comment: #6b7280; + --hljs-function: #c084fc; + --hljs-type: #22d3ee; + } + * { box-sizing: border-box; margin: 0; padding: 0; } + body { + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + padding: 2rem; + max-width: 900px; + margin: 0 auto; + } + h1 { color: var(--accent-primary); margin-bottom: 1rem; } + .metadata { + background: var(--bg-secondary); + padding: 1rem; + border-radius: 8px; + margin-bottom: 2rem; + border: 1px solid var(--border-color); + } + .metadata p { color: var(--text-secondary); margin: 0.25rem 0; } + .metadata strong { color: var(--text-primary); } + hr { border: none; border-top: 1px solid var(--border-color); margin: 1.5rem 0; } + .message { + background: var(--bg-secondary); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + border-left: 4px solid var(--border-color); + } + .message.user { border-left-color: var(--terminal-user); } + .message.assistant { border-left-color: var(--accent-primary); } + .message.tool_use { border-left-color: var(--terminal-tool); } + .message.tool_result { border-left-color: var(--hljs-string); } + .message.error { border-left-color: var(--terminal-error); } + .message.system { border-left-color: var(--text-secondary); } + .message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); + } + .message-type { + font-weight: bold; + text-transform: capitalize; + } + .message-type.user { color: var(--terminal-user); } + .message-type.assistant { color: var(--accent-primary); } + .message-type.tool_use { color: var(--terminal-tool); } + .message-type.tool_result { color: var(--hljs-string); } + .message-type.error { color: var(--terminal-error); } + .message-type.system { color: var(--text-secondary); } + .timestamp { color: var(--text-secondary); font-size: 0.85rem; } + .tool-name { color: var(--terminal-tool); font-size: 0.9rem; } + .message-content { + white-space: pre-wrap; + word-wrap: break-word; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.9rem; + } + pre { + background: var(--bg-code); + padding: 1rem; + border-radius: 4px; + overflow-x: auto; + margin: 0.5rem 0; + } + code { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.85rem; + } + .footer { + text-align: center; + color: var(--text-secondary); + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); + } + @media (prefers-color-scheme: light) { + :root { + --bg-primary: #f8f9fa; + --bg-secondary: #ffffff; + --bg-code: #f5f5f5; + --text-primary: #1a1a2e; + --text-secondary: #5a5a7a; + --border-color: #d0d0e0; + --terminal-user: #0891b2; + --terminal-tool: #7c3aed; + --terminal-error: #dc2626; + --hljs-keyword: #d946ef; + --hljs-string: #16a34a; + } + } + `; + + let messagesHtml = ""; + for (const message of session.messages) { + const timestamp = new Date(message.timestamp).toLocaleString(); + const typeLabel = + message.type === "tool_use" + ? "Tool Use" + : message.type === "tool_result" + ? "Tool Result" + : message.type.charAt(0).toUpperCase() + message.type.slice(1); + + messagesHtml += ` +
+
+ ${typeLabel} + ${timestamp} +
+ ${message.tool_name ? `
🔧 ${escapeHtml(message.tool_name)}
` : ""} +
${escapeHtml(message.content)}
+
+ `; + } + + const metadataHtml = includeMetadata + ? ` + + ` + : ""; + + return ` + + + + + ${escapeHtml(session.name)} - Hikari Desktop Export + + + +

💬 ${escapeHtml(session.name)}

+ ${metadataHtml} +
+ ${messagesHtml} + + +`; +} + +function generatePrintableHtml(session: SavedSession, includeMetadata: boolean): string { + const printStyles = ` + * { box-sizing: border-box; margin: 0; padding: 0; } + body { + font-family: 'Georgia', 'Times New Roman', serif; + font-size: 11pt; + line-height: 1.5; + padding: 0.5in; + max-width: 8.5in; + margin: 0 auto; + color: #000; + background: #fff; + } + h1 { + font-size: 18pt; + margin-bottom: 0.25in; + border-bottom: 2px solid #333; + padding-bottom: 0.1in; + } + .metadata { + background: #f5f5f5; + padding: 0.15in; + margin-bottom: 0.25in; + border: 1px solid #ddd; + font-size: 9pt; + } + .metadata p { margin: 2pt 0; } + hr { border: none; border-top: 1px solid #ccc; margin: 0.2in 0; } + .message { + margin-bottom: 0.15in; + padding: 0.1in; + border-left: 3px solid #ccc; + page-break-inside: avoid; + } + .message.user { border-left-color: #0088cc; } + .message.assistant { border-left-color: #cc0044; } + .message.tool_use { border-left-color: #8844cc; } + .message.tool_result { border-left-color: #44cc44; } + .message.error { border-left-color: #cc4444; } + .message.system { border-left-color: #888; } + .message-header { + display: flex; + justify-content: space-between; + font-size: 9pt; + color: #666; + margin-bottom: 0.05in; + border-bottom: 1px dotted #ddd; + padding-bottom: 0.03in; + } + .message-type { font-weight: bold; text-transform: uppercase; } + .message-type.user { color: #0066aa; } + .message-type.assistant { color: #aa0033; } + .message-type.tool_use { color: #6633aa; } + .message-type.tool_result { color: #33aa33; } + .message-type.error { color: #aa3333; } + .tool-name { color: #666; font-size: 9pt; font-style: italic; } + .message-content { + white-space: pre-wrap; + word-wrap: break-word; + font-family: 'Consolas', 'Courier New', monospace; + font-size: 9pt; + background: #fafafa; + padding: 0.05in; + } + .footer { + text-align: center; + font-size: 8pt; + color: #999; + margin-top: 0.25in; + padding-top: 0.1in; + border-top: 1px solid #ddd; + } + .print-instructions { + background: #ffffcc; + border: 1px solid #cccc00; + padding: 0.15in; + margin-bottom: 0.25in; + font-size: 10pt; + } + .print-instructions h2 { font-size: 12pt; margin-bottom: 0.1in; } + .print-instructions ol { margin-left: 0.2in; } + .print-instructions li { margin: 0.05in 0; } + @media print { + .print-instructions { display: none; } + body { padding: 0; } + } + @page { margin: 0.5in; } + `; + + let messagesHtml = ""; + for (const message of session.messages) { + const timestamp = new Date(message.timestamp).toLocaleString(); + const typeLabel = + message.type === "tool_use" + ? "Tool" + : message.type === "tool_result" + ? "Result" + : message.type.charAt(0).toUpperCase() + message.type.slice(1); + + messagesHtml += ` +
+
+ ${typeLabel} + ${timestamp} +
+ ${message.tool_name ? `
${escapeHtml(message.tool_name)}
` : ""} +
${escapeHtml(message.content)}
+
+ `; + } + + const metadataHtml = includeMetadata + ? ` + + ` + : ""; + + return ` + + + + + ${escapeHtml(session.name)} - Print to PDF + + + + +

${escapeHtml(session.name)}

+ ${metadataHtml} +
+ ${messagesHtml} + + +`; +} + function createSessionsStore() { const sessions = writable([]); const isLoading = writable(false); @@ -260,6 +590,67 @@ function createSessionsStore() { } } + async function exportSessionAsHtml( + sessionId: string, + includeMetadata: boolean = true + ): Promise { + try { + const session = await loadSession(sessionId); + if (!session) { + console.error("Session not found for export"); + return false; + } + + const filePath = await save({ + defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}.html`, + filters: [{ name: "HTML", extensions: ["html"] }], + }); + + if (!filePath) { + return false; + } + + const html = generateHtmlExport(session, includeMetadata); + await writeTextFile(filePath, html); + return true; + } catch (error) { + console.error("Failed to export session as HTML:", error); + return false; + } + } + + async function exportSessionAsPdf( + sessionId: string, + includeMetadata: boolean = true + ): Promise { + try { + const session = await loadSession(sessionId); + if (!session) { + console.error("Session not found for export"); + return false; + } + + const filePath = await save({ + defaultPath: `hikari-session-${session.name.replace(/[^a-zA-Z0-9]/g, "-")}-print.html`, + filters: [{ name: "HTML (for PDF printing)", extensions: ["html"] }], + }); + + if (!filePath) { + return false; + } + + const html = generatePrintableHtml(session, includeMetadata); + await writeTextFile(filePath, html); + + // Open the file in the default browser for print-to-PDF + await openPath(filePath); + return true; + } catch (error) { + console.error("Failed to export session for PDF:", error); + return false; + } + } + async function importSession(): Promise { try { const filePath = await open({ @@ -308,6 +699,8 @@ function createSessionsStore() { cancelAutoSave, exportSessionAsJson, exportSessionAsMarkdown, + exportSessionAsHtml, + exportSessionAsPdf, importSession, }; }