generated from nhcarrigan/template
feat: add session import/export functionality
- Add JSON export with full session metadata for backup/restore - Add Markdown export with formatted conversation history - Add import capability for previously exported sessions - Add tauri-plugin-fs for file system operations - Add export dropdown menu and import button to session history panel
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "^2",
|
||||
|
||||
Generated
+10
@@ -17,6 +17,9 @@ importers:
|
||||
'@tauri-apps/plugin-dialog':
|
||||
specifier: ^2
|
||||
version: 2.6.0
|
||||
'@tauri-apps/plugin-fs':
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.5
|
||||
'@tauri-apps/plugin-notification':
|
||||
specifier: ^2
|
||||
version: 2.3.3
|
||||
@@ -744,6 +747,9 @@ packages:
|
||||
'@tauri-apps/plugin-dialog@2.6.0':
|
||||
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
|
||||
|
||||
'@tauri-apps/plugin-fs@2.4.5':
|
||||
resolution: {integrity: sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==}
|
||||
|
||||
'@tauri-apps/plugin-notification@2.3.3':
|
||||
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
||||
|
||||
@@ -2300,6 +2306,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
'@tauri-apps/plugin-fs@2.4.5':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
'@tauri-apps/plugin-notification@2.3.3':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.9.1
|
||||
|
||||
Generated
+1
@@ -1613,6 +1613,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
|
||||
@@ -27,6 +27,7 @@ tauri-plugin-notification = "2"
|
||||
tauri-plugin-os = "2"
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tempfile = "3"
|
||||
semver = "1"
|
||||
chrono = { version = "0.4.43", features = ["serde"] }
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"notification:allow-notify",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-read-image",
|
||||
"core:tray:default"
|
||||
"core:tray:default",
|
||||
"fs:default",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.manage(bridge_manager.clone())
|
||||
.manage(temp_manager.clone())
|
||||
.setup(move |app| {
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
let searchInput = $state("");
|
||||
let selectedSession = $state<SavedSession | null>(null);
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
let showExportMenu = $state<string | null>(null);
|
||||
let isImporting = $state(false);
|
||||
|
||||
const sessions = $derived(sessionsStore.sessions);
|
||||
const isLoading = $derived(sessionsStore.isLoading);
|
||||
@@ -89,6 +91,34 @@
|
||||
function handleBackToList(): void {
|
||||
selectedSession = null;
|
||||
}
|
||||
|
||||
async function handleExportJson(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsJson(sessionId);
|
||||
}
|
||||
|
||||
async function handleExportMarkdown(sessionId: string): Promise<void> {
|
||||
showExportMenu = null;
|
||||
await sessionsStore.exportSessionAsMarkdown(sessionId);
|
||||
}
|
||||
|
||||
async function handleImport(): Promise<void> {
|
||||
isImporting = true;
|
||||
try {
|
||||
await sessionsStore.importSession();
|
||||
} finally {
|
||||
isImporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExportMenu(sessionId: string): void {
|
||||
if (showExportMenu === sessionId) {
|
||||
showExportMenu = null;
|
||||
} else {
|
||||
showExportMenu = sessionId;
|
||||
showDeleteConfirm = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -128,20 +158,40 @@
|
||||
{selectedSession ? selectedSession.name : "Session History"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !selectedSession}
|
||||
<button
|
||||
onclick={handleImport}
|
||||
disabled={isImporting}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title="Import session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
|
||||
/>
|
||||
</svg>
|
||||
{isImporting ? "Importing..." : "Import"}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if selectedSession}
|
||||
@@ -280,6 +330,40 @@
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => toggleExportMenu(session.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
title="Export session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if showExportMenu === session.id}
|
||||
<div
|
||||
class="absolute right-0 top-full mt-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-lg py-1 z-10 min-w-[140px]"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleExportJson(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 JSON
|
||||
</button>
|
||||
<button
|
||||
onclick={() => handleExportMarkdown(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 Markdown
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showDeleteConfirm === session.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
|
||||
// Debounce delay for auto-save (in milliseconds)
|
||||
const AUTO_SAVE_DEBOUNCE_MS = 2000;
|
||||
@@ -176,6 +178,122 @@ function createSessionsStore() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportSessionAsJson(sessionId: string): 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, "-")}.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<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, "-")}.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 importSession(): Promise<boolean> {
|
||||
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 },
|
||||
@@ -188,6 +306,9 @@ function createSessionsStore() {
|
||||
clearAllSessions,
|
||||
scheduleAutoSave,
|
||||
cancelAutoSave,
|
||||
exportSessionAsJson,
|
||||
exportSessionAsMarkdown,
|
||||
importSession,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user