]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui : added download action (#13552) (#16282)
authorSascha Rogmann <redacted>
Tue, 7 Oct 2025 09:11:08 +0000 (11:11 +0200)
committerGitHub <redacted>
Tue, 7 Oct 2025 09:11:08 +0000 (11:11 +0200)
* webui : added download action (#13552)

* webui : import and export (for all conversations)

* webui : fixed download-format, import of one conversation

* webui : add ExportedConversations type for chat import/export

* feat: Update naming & order

* chore: Linting

* webui : Updated static build output

---------

Co-authored-by: Aleksander Grygier <redacted>
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/stores/database.ts
tools/server/webui/src/lib/types/database.d.ts

index 2801319c98d7086e111df9a21fa1790ad90d6d4c..8d57b4a16772acdaac0ed120ad786c5350512a6e 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index 30d1f9d4b7e98a3f8cf846d8d73bb3e25660e9ae..e91673e98b036ecfe633b983efdc086ab4b656d4 100644 (file)
@@ -1,8 +1,9 @@
 <script lang="ts">
-       import { Search, SquarePen, X } from '@lucide/svelte';
+       import { Search, SquarePen, X, Download, Upload } from '@lucide/svelte';
        import { KeyboardShortcutInfo } from '$lib/components/app';
        import { Button } from '$lib/components/ui/button';
        import { Input } from '$lib/components/ui/input';
+       import { exportAllConversations, importConversations } from '$lib/stores/chat.svelte';
 
        interface Props {
                handleMobileSidebarItemClick: () => void;
 
                        <KeyboardShortcutInfo keys={['cmd', 'k']} />
                </Button>
+
+               <Button
+                       class="w-full justify-start text-sm"
+                       onclick={() => {
+                               importConversations().catch((err) => {
+                                       console.error('Import failed:', err);
+                                       // Optional: show toast or dialog
+                               });
+                       }}
+                       variant="ghost"
+               >
+                       <div class="flex items-center gap-2">
+                               <Upload class="h-4 w-4" />
+                               Import conversations
+                       </div>
+               </Button>
+
+               <Button
+                       class="w-full justify-start text-sm"
+                       onclick={() => {
+                               exportAllConversations();
+                       }}
+                       variant="ghost"
+               >
+                       <div class="flex items-center gap-2">
+                               <Download class="h-4 w-4" />
+                               Export all conversations
+                       </div>
+               </Button>
        {/if}
 </div>
index 579d00aabc14adc313a56d374123d9c119a1c93a..b63e6f5962a7f04d9b9df98cc90d39e2673abb91 100644 (file)
@@ -1,6 +1,7 @@
 <script lang="ts">
-       import { Trash2, Pencil, MoreHorizontal } from '@lucide/svelte';
+       import { Trash2, Pencil, MoreHorizontal, Download } from '@lucide/svelte';
        import { ActionDropdown } from '$lib/components/app';
+       import { downloadConversation } from '$lib/stores/chat.svelte';
        import { onMount } from 'svelte';
 
        interface Props {
                                                onclick: handleEdit,
                                                shortcut: ['shift', 'cmd', 'e']
                                        },
+                                       {
+                                               icon: Download,
+                                               label: 'Export',
+                                               onclick: (e) => {
+                                                       e.stopPropagation();
+                                                       downloadConversation(conversation.id);
+                                               },
+                                               shortcut: ['shift', 'cmd', 's']
+                                       },
                                        {
                                                icon: Trash2,
                                                label: 'Delete',
index 3f9226efeb3b4f9fe0e08dbf9970c43a9bb94cb8..5bf964f9adfd465d0989c35b678ba93a4da39e11 100644 (file)
@@ -6,6 +6,8 @@ import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/u
 import { browser } from '$app/environment';
 import { goto } from '$app/navigation';
 import { extractPartialThinking } from '$lib/utils/thinking';
+import { toast } from 'svelte-sonner';
+import type { ExportedConversations } from '$lib/types/database';
 
 /**
  * ChatStore - Central state management for chat conversations and AI interactions
@@ -951,6 +953,166 @@ class ChatStore {
                }
        }
 
+       /**
+        * Downloads a conversation as JSON file
+        * @param convId - The conversation ID to download
+        */
+       async downloadConversation(convId: string): Promise<void> {
+               if (!this.activeConversation || this.activeConversation.id !== convId) {
+                       // Load the conversation if not currently active
+                       const conversation = await DatabaseStore.getConversation(convId);
+                       if (!conversation) return;
+
+                       const messages = await DatabaseStore.getConversationMessages(convId);
+                       const conversationData = {
+                               conv: conversation,
+                               messages
+                       };
+
+                       this.triggerDownload(conversationData);
+               } else {
+                       // Use current active conversation data
+                       const conversationData: ExportedConversations = {
+                               conv: this.activeConversation!,
+                               messages: this.activeMessages
+                       };
+
+                       this.triggerDownload(conversationData);
+               }
+       }
+
+       /**
+        * Triggers file download in browser
+        * @param data - Data to download (expected: { conv: DatabaseConversation, messages: DatabaseMessage[] })
+        * @param filename - Optional filename
+        */
+       private triggerDownload(data: ExportedConversations, filename?: string): void {
+               const conversation =
+                       'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
+               if (!conversation) {
+                       console.error('Invalid data: missing conversation');
+                       return;
+               }
+               const conversationName = conversation.name ? conversation.name.trim() : '';
+               const convId = conversation.id || 'unknown';
+               const truncatedSuffix = conversationName
+                       .toLowerCase()
+                       .replace(/[^a-z0-9]/gi, '_')
+                       .replace(/_+/g, '_')
+                       .substring(0, 20);
+               const downloadFilename = filename || `conversation_${convId}_${truncatedSuffix}.json`;
+
+               const conversationJson = JSON.stringify(data, null, 2);
+               const blob = new Blob([conversationJson], {
+                       type: 'application/json'
+               });
+               const url = URL.createObjectURL(blob);
+               const a = document.createElement('a');
+               a.href = url;
+               a.download = downloadFilename;
+               document.body.appendChild(a);
+               a.click();
+               document.body.removeChild(a);
+               URL.revokeObjectURL(url);
+       }
+
+       /**
+        * Exports all conversations with their messages as a JSON file
+        */
+       async exportAllConversations(): Promise<void> {
+               try {
+                       const allConversations = await DatabaseStore.getAllConversations();
+                       if (allConversations.length === 0) {
+                               throw new Error('No conversations to export');
+                       }
+
+                       const allData: ExportedConversations = await Promise.all(
+                               allConversations.map(async (conv) => {
+                                       const messages = await DatabaseStore.getConversationMessages(conv.id);
+                                       return { conv, messages };
+                               })
+                       );
+
+                       const blob = new Blob([JSON.stringify(allData, null, 2)], {
+                               type: 'application/json'
+                       });
+                       const url = URL.createObjectURL(blob);
+                       const a = document.createElement('a');
+                       a.href = url;
+                       a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
+                       document.body.appendChild(a);
+                       a.click();
+                       document.body.removeChild(a);
+                       URL.revokeObjectURL(url);
+
+                       toast.success(`All conversations (${allConversations.length}) prepared for download`);
+               } catch (err) {
+                       console.error('Failed to export conversations:', err);
+                       throw err;
+               }
+       }
+
+       /**
+        * Imports conversations from a JSON file.
+        * Supports both single conversation (object) and multiple conversations (array).
+        * Uses DatabaseStore for safe, encapsulated data access
+        */
+       async importConversations(): Promise<void> {
+               return new Promise((resolve, reject) => {
+                       const input = document.createElement('input');
+                       input.type = 'file';
+                       input.accept = '.json';
+
+                       input.onchange = async (e) => {
+                               const file = (e.target as HTMLInputElement)?.files?.[0];
+                               if (!file) {
+                                       reject(new Error('No file selected'));
+                                       return;
+                               }
+
+                               try {
+                                       const text = await file.text();
+                                       const parsedData = JSON.parse(text);
+                                       let importedData: ExportedConversations;
+
+                                       if (Array.isArray(parsedData)) {
+                                               importedData = parsedData;
+                                       } else if (
+                                               parsedData &&
+                                               typeof parsedData === 'object' &&
+                                               'conv' in parsedData &&
+                                               'messages' in parsedData
+                                       ) {
+                                               // Single conversation object
+                                               importedData = [parsedData];
+                                       } else {
+                                               throw new Error(
+                                                       'Invalid file format: expected array of conversations or single conversation object'
+                                               );
+                                       }
+
+                                       const result = await DatabaseStore.importConversations(importedData);
+
+                                       // Refresh UI
+                                       await this.loadConversations();
+
+                                       toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
+
+                                       resolve(undefined);
+                               } catch (err: unknown) {
+                                       const message = err instanceof Error ? err.message : 'Unknown error';
+                                       console.error('Failed to import conversations:', err);
+                                       toast.error('Import failed', {
+                                               description: message
+                                       });
+                                       reject(new Error(`Import failed: ${message}`));
+                               }
+                       };
+
+                       input.click();
+               });
+       }
+
        /**
         * Deletes a conversation and all its messages
         * @param convId - The conversation ID to delete
@@ -1427,6 +1589,9 @@ export const isInitialized = () => chatStore.isInitialized;
 export const maxContextError = () => chatStore.maxContextError;
 
 export const createConversation = chatStore.createConversation.bind(chatStore);
+export const downloadConversation = chatStore.downloadConversation.bind(chatStore);
+export const exportAllConversations = chatStore.exportAllConversations.bind(chatStore);
+export const importConversations = chatStore.importConversations.bind(chatStore);
 export const deleteConversation = chatStore.deleteConversation.bind(chatStore);
 export const sendMessage = chatStore.sendMessage.bind(chatStore);
 export const gracefulStop = chatStore.gracefulStop.bind(chatStore);
index f2e3a677a48ccfc24ece6bb1101ed3da62349cb2..6394c5b7eda74d90866a5f0e2679e8e67ef2a552 100644 (file)
@@ -346,4 +346,39 @@ export class DatabaseStore {
        ): Promise<void> {
                await db.messages.update(id, updates);
        }
+
+       /**
+        * Imports multiple conversations and their messages.
+        * Skips conversations that already exist.
+        *
+        * @param data - Array of { conv, messages } objects
+        */
+       static async importConversations(
+               data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
+       ): Promise<{ imported: number; skipped: number }> {
+               let importedCount = 0;
+               let skippedCount = 0;
+
+               return await db.transaction('rw', [db.conversations, db.messages], async () => {
+                       for (const item of data) {
+                               const { conv, messages } = item;
+
+                               const existing = await db.conversations.get(conv.id);
+                               if (existing) {
+                                       console.warn(`Conversation "${conv.name}" already exists, skipping...`);
+                                       skippedCount++;
+                                       continue;
+                               }
+
+                               await db.conversations.add(conv);
+                               for (const msg of messages) {
+                                       await db.messages.put(msg);
+                               }
+
+                               importedCount++;
+                       }
+
+                       return { imported: importedCount, skipped: skippedCount };
+               });
+       }
 }
index c6e12b3cac8b3a17d3677a88927bdfbaa68b9d79..7f6b76ba271cc087ffde989dccbc70b462560316 100644 (file)
@@ -54,3 +54,18 @@ export interface DatabaseMessage {
        timings?: ChatMessageTimings;
        model?: string;
 }
+
+/**
+ * Represents a single conversation with its associated messages,
+ * typically used for import/export operations.
+ */
+export type ExportedConversation = {
+       conv: DatabaseConversation;
+       messages: DatabaseMessage[];
+};
+
+/**
+ * Type representing one or more exported conversations.
+ * Can be a single conversation object or an array of them.
+ */
+export type ExportedConversations = ExportedConversation | ExportedConversation[];