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/api": "^2",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||||
"@tauri-apps/plugin-dialog": "^2",
|
"@tauri-apps/plugin-dialog": "^2",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||||
"@tauri-apps/plugin-notification": "^2",
|
"@tauri-apps/plugin-notification": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-os": "^2",
|
"@tauri-apps/plugin-os": "^2",
|
||||||
|
|||||||
Generated
+10
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@tauri-apps/plugin-dialog':
|
'@tauri-apps/plugin-dialog':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
|
'@tauri-apps/plugin-fs':
|
||||||
|
specifier: ^2.4.5
|
||||||
|
version: 2.4.5
|
||||||
'@tauri-apps/plugin-notification':
|
'@tauri-apps/plugin-notification':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.3.3
|
version: 2.3.3
|
||||||
@@ -744,6 +747,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-dialog@2.6.0':
|
'@tauri-apps/plugin-dialog@2.6.0':
|
||||||
resolution: {integrity: sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==}
|
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':
|
'@tauri-apps/plugin-notification@2.3.3':
|
||||||
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
||||||
|
|
||||||
@@ -2300,6 +2306,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.9.1
|
'@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':
|
'@tauri-apps/plugin-notification@2.3.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.9.1
|
'@tauri-apps/api': 2.9.1
|
||||||
|
|||||||
Generated
+1
@@ -1613,6 +1613,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-http",
|
"tauri-plugin-http",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ tauri-plugin-notification = "2"
|
|||||||
tauri-plugin-os = "2"
|
tauri-plugin-os = "2"
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-clipboard-manager = "2"
|
tauri-plugin-clipboard-manager = "2"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
semver = "1"
|
semver = "1"
|
||||||
chrono = { version = "0.4.43", features = ["serde"] }
|
chrono = { version = "0.4.43", features = ["serde"] }
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
"clipboard-manager:default",
|
"clipboard-manager:default",
|
||||||
"clipboard-manager:allow-read-image",
|
"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_os::init())
|
||||||
.plugin(tauri_plugin_http::init())
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.manage(bridge_manager.clone())
|
.manage(bridge_manager.clone())
|
||||||
.manage(temp_manager.clone())
|
.manage(temp_manager.clone())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
let searchInput = $state("");
|
let searchInput = $state("");
|
||||||
let selectedSession = $state<SavedSession | null>(null);
|
let selectedSession = $state<SavedSession | null>(null);
|
||||||
let showDeleteConfirm = $state<string | null>(null);
|
let showDeleteConfirm = $state<string | null>(null);
|
||||||
|
let showExportMenu = $state<string | null>(null);
|
||||||
|
let isImporting = $state(false);
|
||||||
|
|
||||||
const sessions = $derived(sessionsStore.sessions);
|
const sessions = $derived(sessionsStore.sessions);
|
||||||
const isLoading = $derived(sessionsStore.isLoading);
|
const isLoading = $derived(sessionsStore.isLoading);
|
||||||
@@ -89,6 +91,34 @@
|
|||||||
function handleBackToList(): void {
|
function handleBackToList(): void {
|
||||||
selectedSession = null;
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -128,20 +158,40 @@
|
|||||||
{selectedSession ? selectedSession.name : "Session History"}
|
{selectedSession ? selectedSession.name : "Session History"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
onclick={onClose}
|
{#if !selectedSession}
|
||||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
<button
|
||||||
aria-label="Close"
|
onclick={handleImport}
|
||||||
>
|
disabled={isImporting}
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
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"
|
||||||
<path
|
title="Import session"
|
||||||
stroke-linecap="round"
|
>
|
||||||
stroke-linejoin="round"
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
stroke-width="2"
|
<path
|
||||||
d="M6 18L18 6M6 6l12 12"
|
stroke-linecap="round"
|
||||||
/>
|
stroke-linejoin="round"
|
||||||
</svg>
|
stroke-width="2"
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{#if selectedSession}
|
{#if selectedSession}
|
||||||
@@ -280,6 +330,40 @@
|
|||||||
>
|
>
|
||||||
Resume
|
Resume
|
||||||
</button>
|
</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}
|
{#if showDeleteConfirm === session.id}
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { Conversation } from "./conversations";
|
import type { Conversation } from "./conversations";
|
||||||
|
import { writeTextFile, readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
// Debounce delay for auto-save (in milliseconds)
|
// Debounce delay for auto-save (in milliseconds)
|
||||||
const AUTO_SAVE_DEBOUNCE_MS = 2000;
|
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 {
|
return {
|
||||||
sessions: { subscribe: sessions.subscribe },
|
sessions: { subscribe: sessions.subscribe },
|
||||||
isLoading: { subscribe: isLoading.subscribe },
|
isLoading: { subscribe: isLoading.subscribe },
|
||||||
@@ -188,6 +306,9 @@ function createSessionsStore() {
|
|||||||
clearAllSessions,
|
clearAllSessions,
|
||||||
scheduleAutoSave,
|
scheduleAutoSave,
|
||||||
cancelAutoSave,
|
cancelAutoSave,
|
||||||
|
exportSessionAsJson,
|
||||||
|
exportSessionAsMarkdown,
|
||||||
|
importSession,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user