import { writable } from "svelte/store"; 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; export interface SavedMessage { id: string; type: string; content: string; timestamp: string; tool_name?: string; } export interface SavedSession { id: string; name: string; created_at: string; last_activity_at: string; working_directory: string; message_count: number; preview: string; messages: SavedMessage[]; } export interface SessionListItem { id: string; name: string; created_at: string; last_activity_at: string; working_directory: string; message_count: number; 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 ? `

Created: ${new Date(session.created_at).toLocaleString()}

Last Activity: ${new Date(session.last_activity_at).toLocaleString()}

Working Directory: ${session.working_directory || "Not set"}

Messages: ${session.message_count}

` : ""; 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 ? `

Created: ${new Date(session.created_at).toLocaleString()}

Last Activity: ${new Date(session.last_activity_at).toLocaleString()}

Working Directory: ${session.working_directory || "Not set"}

Messages: ${session.message_count}

` : ""; return ` ${escapeHtml(session.name)} - Print to PDF

${escapeHtml(session.name)}

${metadataHtml}
${messagesHtml} `; } function createSessionsStore() { const sessions = writable([]); const isLoading = writable(false); const searchQuery = writable(""); // Track pending auto-save timeouts per conversation const pendingAutoSaves = new Map>(); async function loadSessions(): Promise { isLoading.set(true); try { const result = await invoke("list_sessions"); sessions.set(result); } catch (error) { console.error("Failed to load sessions:", error); } finally { isLoading.set(false); } } async function saveConversation(conversation: Conversation): Promise { const messages: SavedMessage[] = conversation.terminalLines.map((line) => ({ id: line.id, type: line.type, content: line.content, timestamp: line.timestamp.toISOString(), tool_name: line.toolName, })); const userAndAssistantMessages = conversation.terminalLines.filter( (line) => line.type === "user" || line.type === "assistant" ); const previewContent = userAndAssistantMessages .slice(0, 3) .map((m) => m.content) .join(" ") .slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : ""); const session: SavedSession = { id: conversation.id, name: conversation.name, created_at: conversation.createdAt.toISOString(), last_activity_at: conversation.lastActivityAt.toISOString(), working_directory: conversation.workingDirectory, message_count: conversation.terminalLines.length, preview: previewContent || "Empty conversation", messages, }; try { await invoke("save_session", { session }); await loadSessions(); } catch (error) { console.error("Failed to save session:", error); } } async function loadSession(sessionId: string): Promise { try { const session = await invoke("load_session", { sessionId, }); return session; } catch (error) { console.error("Failed to load session:", error); return null; } } async function deleteSession(sessionId: string): Promise { try { await invoke("delete_session", { sessionId }); await loadSessions(); } catch (error) { console.error("Failed to delete session:", error); } } async function searchSessions(query: string): Promise { searchQuery.set(query); if (!query.trim()) { await loadSessions(); return; } isLoading.set(true); try { const result = await invoke("search_sessions", { query, }); sessions.set(result); } catch (error) { console.error("Failed to search sessions:", error); } finally { isLoading.set(false); } } async function clearAllSessions(): Promise { try { await invoke("clear_all_sessions"); sessions.set([]); } catch (error) { console.error("Failed to clear sessions:", error); } } function scheduleAutoSave(conversation: Conversation): void { // Don't auto-save empty conversations if (conversation.terminalLines.length === 0) { return; } // Clear any pending auto-save for this conversation const existingTimeout = pendingAutoSaves.get(conversation.id); if (existingTimeout) { clearTimeout(existingTimeout); } // Schedule a new auto-save const timeout = setTimeout(async () => { pendingAutoSaves.delete(conversation.id); try { await saveConversation(conversation); } catch (error) { console.error("Auto-save failed:", error); } }, AUTO_SAVE_DEBOUNCE_MS); pendingAutoSaves.set(conversation.id, timeout); } function cancelAutoSave(conversationId: string): void { const existingTimeout = pendingAutoSaves.get(conversationId); if (existingTimeout) { clearTimeout(existingTimeout); pendingAutoSaves.delete(conversationId); } } async function exportSessionAsJson(sessionId: string): 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, "-")}.json`, filters: [{ name: "JSON", extensions: ["json"] }], }); if (!filePath) { return false; } const exportData = { version: 1, exported_at: new Date().toISOString(), session, }; await writeTextFile(filePath, JSON.stringify(exportData, null, 2)); return true; } catch (error) { console.error("Failed to export session as JSON:", error); return false; } } async function exportSessionAsMarkdown(sessionId: string): 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, "-")}.md`, filters: [{ name: "Markdown", extensions: ["md"] }], }); if (!filePath) { return false; } let markdown = `# ${session.name}\n\n`; markdown += `**Created:** ${new Date(session.created_at).toLocaleString()}\n`; markdown += `**Last Activity:** ${new Date(session.last_activity_at).toLocaleString()}\n`; markdown += `**Working Directory:** ${session.working_directory || "Not set"}\n`; markdown += `**Messages:** ${session.message_count}\n\n`; markdown += `---\n\n`; for (const message of session.messages) { const timestamp = new Date(message.timestamp).toLocaleTimeString(); if (message.type === "user") { markdown += `## 👤 User (${timestamp})\n\n${message.content}\n\n`; } else if (message.type === "assistant") { markdown += `## 🤖 Assistant (${timestamp})\n\n${message.content}\n\n`; } else if (message.type === "tool_use") { markdown += `### 🔧 Tool: ${message.tool_name || "Unknown"} (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`; } else if (message.type === "tool_result") { markdown += `### 📋 Result (${timestamp})\n\n\`\`\`\n${message.content}\n\`\`\`\n\n`; } else if (message.type === "system") { markdown += `> **System:** ${message.content}\n\n`; } else if (message.type === "error") { markdown += `> ⚠️ **Error:** ${message.content}\n\n`; } } markdown += `---\n\n*Exported from Hikari Desktop*\n`; await writeTextFile(filePath, markdown); return true; } catch (error) { console.error("Failed to export session as Markdown:", error); return false; } } 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({ filters: [{ name: "JSON", extensions: ["json"] }], multiple: false, }); if (!filePath) { return false; } const content = await readTextFile(filePath as string); const importData = JSON.parse(content); if (!importData.session || !importData.session.id) { console.error("Invalid session file format"); return false; } const session: SavedSession = importData.session; // Generate a new ID to avoid conflicts with existing sessions session.id = `imported-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; session.name = `${session.name} (Imported)`; await invoke("save_session", { session }); await loadSessions(); return true; } catch (error) { console.error("Failed to import session:", error); return false; } } return { sessions: { subscribe: sessions.subscribe }, isLoading: { subscribe: isLoading.subscribe }, searchQuery: { subscribe: searchQuery.subscribe }, loadSessions, saveConversation, loadSession, deleteSession, searchSessions, clearAllSessions, scheduleAutoSave, cancelAutoSave, exportSessionAsJson, exportSessionAsMarkdown, exportSessionAsHtml, exportSessionAsPdf, importSession, }; } export const sessionsStore = createSessionsStore();