generated from nhcarrigan/template
feat: add session import/export functionality
- Add HTML export with dark/light theme support and styled message types - Add PDF export via print-optimized HTML opened in browser - Include metadata toggle option for all export formats - Add export buttons to SessionHistoryPanel dropdown Closes #34
This commit is contained in:
@@ -102,6 +102,16 @@
|
||||
await sessionsStore.exportSessionAsMarkdown(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportHtml(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsHtml(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportPdf(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsPdf(sessionId);
|
||||
}
|
||||
|
||||
async function handleImport(): Promise<void> {
|
||||
isImporting = true;
|
||||
try {
|
||||
@@ -361,6 +371,18 @@
|
||||
>
|
||||
Export as Markdown
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportHtml(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as HTML
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportPdf(session.id)}
|
||||
class="w-full px-3 py-2 text-left text-sm text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Export as PDF
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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, """)
|
||||
.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 += `
|
||||
<div class="message ${message.type}">
|
||||
<div class="message-header">
|
||||
<span class="message-type ${message.type}">${typeLabel}</span>
|
||||
<span class="timestamp">${timestamp}</span>
|
||||
</div>
|
||||
${message.tool_name ? `<div class="tool-name">🔧 ${escapeHtml(message.tool_name)}</div>` : ""}
|
||||
<div class="message-content">${escapeHtml(message.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const metadataHtml = includeMetadata
|
||||
? `
|
||||
<div class="metadata">
|
||||
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
|
||||
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
|
||||
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
|
||||
<p><strong>Messages:</strong> ${session.message_count}</p>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(session.name)} - Hikari Desktop Export</title>
|
||||
<style>${styles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>💬 ${escapeHtml(session.name)}</h1>
|
||||
${metadataHtml}
|
||||
<hr>
|
||||
${messagesHtml}
|
||||
<div class="footer">
|
||||
<p>✨ Exported from Hikari Desktop</p>
|
||||
<p>Export date: ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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 += `
|
||||
<div class="message ${message.type}">
|
||||
<div class="message-header">
|
||||
<span class="message-type ${message.type}">${typeLabel}</span>
|
||||
<span class="timestamp">${timestamp}</span>
|
||||
</div>
|
||||
${message.tool_name ? `<div class="tool-name">${escapeHtml(message.tool_name)}</div>` : ""}
|
||||
<div class="message-content">${escapeHtml(message.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const metadataHtml = includeMetadata
|
||||
? `
|
||||
<div class="metadata">
|
||||
<p><strong>Created:</strong> ${new Date(session.created_at).toLocaleString()}</p>
|
||||
<p><strong>Last Activity:</strong> ${new Date(session.last_activity_at).toLocaleString()}</p>
|
||||
<p><strong>Working Directory:</strong> ${session.working_directory || "Not set"}</p>
|
||||
<p><strong>Messages:</strong> ${session.message_count}</p>
|
||||
</div>
|
||||
`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(session.name)} - Print to PDF</title>
|
||||
<style>${printStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-instructions">
|
||||
<h2>Print to PDF Instructions</h2>
|
||||
<ol>
|
||||
<li>Press <strong>Ctrl+P</strong> (or <strong>Cmd+P</strong> on Mac) to open the print dialog</li>
|
||||
<li>Select <strong>"Save as PDF"</strong> or <strong>"Microsoft Print to PDF"</strong> as the printer</li>
|
||||
<li>Click <strong>Print</strong> to save your PDF</li>
|
||||
</ol>
|
||||
<p><em>This instruction box will not appear in your PDF.</em></p>
|
||||
</div>
|
||||
<h1>${escapeHtml(session.name)}</h1>
|
||||
${metadataHtml}
|
||||
<hr>
|
||||
${messagesHtml}
|
||||
<div class="footer">
|
||||
<p>Exported from Hikari Desktop | ${new Date().toLocaleString()}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function createSessionsStore() {
|
||||
const sessions = writable<SessionListItem[]>([]);
|
||||
const isLoading = writable(false);
|
||||
@@ -260,6 +590,67 @@ function createSessionsStore() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsHtml(
|
||||
sessionId: string,
|
||||
includeMetadata: boolean = true
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const filePath = await open({
|
||||
@@ -308,6 +699,8 @@ function createSessionsStore() {
|
||||
cancelAutoSave,
|
||||
exportSessionAsJson,
|
||||
exportSessionAsMarkdown,
|
||||
exportSessionAsHtml,
|
||||
exportSessionAsPdf,
|
||||
importSession,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user