]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
Import/Export UX improvements (#16619)
authorAleksander Grygier <redacted>
Mon, 20 Oct 2025 11:29:14 +0000 (13:29 +0200)
committerGitHub <redacted>
Mon, 20 Oct 2025 11:29:14 +0000 (13:29 +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

* feat: Import/Export UX improvements

* chore: update webui build output

* feat: Update UI placement of Import/Export tab in Chat Settings Dialog

* refactor: Cleanup

chore: update webui build output

* feat: Enable shift-click multiple conversation items selection

* chore: update webui static build

* chore: update webui static build

---------

Co-authored-by: Sascha Rogmann <redacted>
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte
tools/server/webui/src/lib/components/app/chat/ChatSettings/ConversationSelectionDialog.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatSettings/ImportExportTab.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte
tools/server/webui/src/lib/components/app/index.ts
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/utils/conversation-utils.ts [new file with mode: 0644]

index ddf101ac385c46953277bfe8ba0dd9b04cb58030..33202076a0f3c69b2e7471f0145b4c059d6aa9d2 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index bf17633095242f58250665a41c1f9aae26bf9942..ad5d617b5ff641ded957b5515745a4f92c1d26a8 100644 (file)
@@ -9,9 +9,11 @@
                Sun,
                Moon,
                ChevronLeft,
-               ChevronRight
+               ChevronRight,
+               Database
        } from '@lucide/svelte';
        import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
+       import ImportExportTab from './ImportExportTab.svelte';
        import * as Dialog from '$lib/components/ui/dialog';
        import { ScrollArea } from '$lib/components/ui/scroll-area';
        import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
                                }
                        ]
                },
+               {
+                       title: 'Import/Export',
+                       icon: Database,
+                       fields: []
+               },
                {
                        title: 'Developer',
                        icon: Code,
 
                        <ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
                                <div class="space-y-6 p-4 md:p-6">
-                                       <div>
+                                       <div class="grid">
                                                <div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
                                                        <currentSection.icon class="h-5 w-5" />
 
                                                        <h3 class="text-lg font-semibold">{currentSection.title}</h3>
                                                </div>
 
-                                               <div class="space-y-6">
-                                                       <ChatSettingsFields
-                                                               fields={currentSection.fields}
-                                                               {localConfig}
-                                                               onConfigChange={handleConfigChange}
-                                                               onThemeChange={handleThemeChange}
-                                                       />
-                                               </div>
+                                               {#if currentSection.title === 'Import/Export'}
+                                                       <ImportExportTab />
+                                               {:else}
+                                                       <div class="space-y-6">
+                                                               <ChatSettingsFields
+                                                                       fields={currentSection.fields}
+                                                                       {localConfig}
+                                                                       onConfigChange={handleConfigChange}
+                                                                       onThemeChange={handleThemeChange}
+                                                               />
+                                                       </div>
+                                               {/if}
                                        </div>
 
                                        <div class="mt-8 border-t pt-6">
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ConversationSelectionDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ConversationSelectionDialog.svelte
new file mode 100644 (file)
index 0000000..bc92a50
--- /dev/null
@@ -0,0 +1,249 @@
+<script lang="ts">
+       import { Search, X } from '@lucide/svelte';
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { Button } from '$lib/components/ui/button';
+       import { Input } from '$lib/components/ui/input';
+       import { Checkbox } from '$lib/components/ui/checkbox';
+       import { ScrollArea } from '$lib/components/ui/scroll-area';
+       import { SvelteSet } from 'svelte/reactivity';
+
+       interface Props {
+               conversations: DatabaseConversation[];
+               messageCountMap?: Map<string, number>;
+               mode: 'export' | 'import';
+               onCancel: () => void;
+               onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+               open?: boolean;
+       }
+
+       let {
+               conversations,
+               messageCountMap = new Map(),
+               mode,
+               onCancel,
+               onConfirm,
+               open = $bindable(false)
+       }: Props = $props();
+
+       let searchQuery = $state('');
+       let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
+       let lastClickedId = $state<string | null>(null);
+
+       let filteredConversations = $derived(
+               conversations.filter((conv) => {
+                       const name = conv.name || 'Untitled conversation';
+                       return name.toLowerCase().includes(searchQuery.toLowerCase());
+               })
+       );
+
+       let allSelected = $derived(
+               filteredConversations.length > 0 &&
+                       filteredConversations.every((conv) => selectedIds.has(conv.id))
+       );
+
+       let someSelected = $derived(
+               filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
+       );
+
+       function toggleConversation(id: string, shiftKey: boolean = false) {
+               const newSet = new SvelteSet(selectedIds);
+
+               if (shiftKey && lastClickedId !== null) {
+                       const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
+                       const currentIndex = filteredConversations.findIndex((c) => c.id === id);
+
+                       if (lastIndex !== -1 && currentIndex !== -1) {
+                               const start = Math.min(lastIndex, currentIndex);
+                               const end = Math.max(lastIndex, currentIndex);
+
+                               const shouldSelect = !newSet.has(id);
+
+                               for (let i = start; i <= end; i++) {
+                                       if (shouldSelect) {
+                                               newSet.add(filteredConversations[i].id);
+                                       } else {
+                                               newSet.delete(filteredConversations[i].id);
+                                       }
+                               }
+
+                               selectedIds = newSet;
+                               return;
+                       }
+               }
+
+               if (newSet.has(id)) {
+                       newSet.delete(id);
+               } else {
+                       newSet.add(id);
+               }
+
+               selectedIds = newSet;
+               lastClickedId = id;
+       }
+
+       function toggleAll() {
+               if (allSelected) {
+                       const newSet = new SvelteSet(selectedIds);
+
+                       filteredConversations.forEach((conv) => newSet.delete(conv.id));
+                       selectedIds = newSet;
+               } else {
+                       const newSet = new SvelteSet(selectedIds);
+
+                       filteredConversations.forEach((conv) => newSet.add(conv.id));
+                       selectedIds = newSet;
+               }
+       }
+
+       function handleConfirm() {
+               const selected = conversations.filter((conv) => selectedIds.has(conv.id));
+               onConfirm(selected);
+       }
+
+       function handleCancel() {
+               selectedIds = new SvelteSet(conversations.map((c) => c.id));
+               searchQuery = '';
+               lastClickedId = null;
+
+               onCancel();
+       }
+
+       let previousOpen = $state(false);
+
+       $effect(() => {
+               if (open && !previousOpen) {
+                       selectedIds = new SvelteSet(conversations.map((c) => c.id));
+                       searchQuery = '';
+                       lastClickedId = null;
+               } else if (!open && previousOpen) {
+                       onCancel();
+               }
+
+               previousOpen = open;
+       });
+</script>
+
+<Dialog.Root bind:open>
+       <Dialog.Portal>
+               <Dialog.Overlay class="z-[1000000]" />
+
+               <Dialog.Content class="z-[1000001] max-w-2xl">
+                       <Dialog.Header>
+                               <Dialog.Title>
+                                       Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
+                               </Dialog.Title>
+
+                               <Dialog.Description>
+                                       {#if mode === 'export'}
+                                               Choose which conversations you want to export. Selected conversations will be downloaded
+                                               as a JSON file.
+                                       {:else}
+                                               Choose which conversations you want to import. Selected conversations will be merged
+                                               with your existing conversations.
+                                       {/if}
+                               </Dialog.Description>
+                       </Dialog.Header>
+
+                       <div class="space-y-4">
+                               <div class="relative">
+                                       <Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+
+                                       <Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
+
+                                       {#if searchQuery}
+                                               <button
+                                                       class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+                                                       onclick={() => (searchQuery = '')}
+                                                       type="button"
+                                               >
+                                                       <X class="h-4 w-4" />
+                                               </button>
+                                       {/if}
+                               </div>
+
+                               <div class="flex items-center justify-between text-sm text-muted-foreground">
+                                       <span>
+                                               {selectedIds.size} of {conversations.length} selected
+                                               {#if searchQuery}
+                                                       ({filteredConversations.length} shown)
+                                               {/if}
+                                       </span>
+                               </div>
+
+                               <div class="overflow-hidden rounded-md border">
+                                       <ScrollArea class="h-[400px]">
+                                               <table class="w-full">
+                                                       <thead class="sticky top-0 z-10 bg-muted">
+                                                               <tr class="border-b">
+                                                                       <th class="w-12 p-3 text-left">
+                                                                               <Checkbox
+                                                                                       checked={allSelected}
+                                                                                       indeterminate={someSelected}
+                                                                                       onCheckedChange={toggleAll}
+                                                                               />
+                                                                       </th>
+
+                                                                       <th class="p-3 text-left text-sm font-medium">Conversation Name</th>
+
+                                                                       <th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
+                                                               </tr>
+                                                       </thead>
+                                                       <tbody>
+                                                               {#if filteredConversations.length === 0}
+                                                                       <tr>
+                                                                               <td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
+                                                                                       {#if searchQuery}
+                                                                                               No conversations found matching "{searchQuery}"
+                                                                                       {:else}
+                                                                                               No conversations available
+                                                                                       {/if}
+                                                                               </td>
+                                                                       </tr>
+                                                               {:else}
+                                                                       {#each filteredConversations as conv (conv.id)}
+                                                                               <tr
+                                                                                       class="cursor-pointer border-b transition-colors hover:bg-muted/50"
+                                                                                       onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
+                                                                               >
+                                                                                       <td class="p-3">
+                                                                                               <Checkbox
+                                                                                                       checked={selectedIds.has(conv.id)}
+                                                                                                       onclick={(e) => {
+                                                                                                               e.preventDefault();
+                                                                                                               e.stopPropagation();
+                                                                                                               toggleConversation(conv.id, e.shiftKey);
+                                                                                                       }}
+                                                                                               />
+                                                                                       </td>
+
+                                                                                       <td class="p-3 text-sm">
+                                                                                               <div
+                                                                                                       class="max-w-[17rem] truncate"
+                                                                                                       title={conv.name || 'Untitled conversation'}
+                                                                                               >
+                                                                                                       {conv.name || 'Untitled conversation'}
+                                                                                               </div>
+                                                                                       </td>
+
+                                                                                       <td class="p-3 text-sm text-muted-foreground">
+                                                                                               {messageCountMap.get(conv.id) ?? 0}
+                                                                                       </td>
+                                                                               </tr>
+                                                                       {/each}
+                                                               {/if}
+                                                       </tbody>
+                                               </table>
+                                       </ScrollArea>
+                               </div>
+                       </div>
+
+                       <Dialog.Footer>
+                               <Button variant="outline" onclick={handleCancel}>Cancel</Button>
+
+                               <Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
+                                       {mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
+                               </Button>
+                       </Dialog.Footer>
+               </Dialog.Content>
+       </Dialog.Portal>
+</Dialog.Root>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ImportExportTab.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ImportExportTab.svelte
new file mode 100644 (file)
index 0000000..19c982c
--- /dev/null
@@ -0,0 +1,255 @@
+<script lang="ts">
+       import { Download, Upload } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import ConversationSelectionDialog from './ConversationSelectionDialog.svelte';
+       import { DatabaseStore } from '$lib/stores/database';
+       import type { ExportedConversations } from '$lib/types/database';
+       import { createMessageCountMap } from '$lib/utils/conversation-utils';
+       import { chatStore } from '$lib/stores/chat.svelte';
+
+       let exportedConversations = $state<DatabaseConversation[]>([]);
+       let importedConversations = $state<DatabaseConversation[]>([]);
+       let showExportSummary = $state(false);
+       let showImportSummary = $state(false);
+
+       let showExportDialog = $state(false);
+       let showImportDialog = $state(false);
+       let availableConversations = $state<DatabaseConversation[]>([]);
+       let messageCountMap = $state<Map<string, number>>(new Map());
+       let fullImportData = $state<Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>>(
+               []
+       );
+
+       async function handleExportClick() {
+               try {
+                       const allConversations = await DatabaseStore.getAllConversations();
+                       if (allConversations.length === 0) {
+                               alert('No conversations to export');
+                               return;
+                       }
+
+                       const conversationsWithMessages = await Promise.all(
+                               allConversations.map(async (conv) => {
+                                       const messages = await DatabaseStore.getConversationMessages(conv.id);
+                                       return { conv, messages };
+                               })
+                       );
+
+                       messageCountMap = createMessageCountMap(conversationsWithMessages);
+                       availableConversations = allConversations;
+                       showExportDialog = true;
+               } catch (err) {
+                       console.error('Failed to load conversations:', err);
+                       alert('Failed to load conversations');
+               }
+       }
+
+       async function handleExportConfirm(selectedConversations: DatabaseConversation[]) {
+               try {
+                       const allData: ExportedConversations = await Promise.all(
+                               selectedConversations.map(async (conv) => {
+                                       const messages = await DatabaseStore.getConversationMessages(conv.id);
+                                       return { conv: $state.snapshot(conv), messages: $state.snapshot(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 = `conversations_${new Date().toISOString().split('T')[0]}.json`;
+                       document.body.appendChild(a);
+                       a.click();
+                       document.body.removeChild(a);
+                       URL.revokeObjectURL(url);
+
+                       exportedConversations = selectedConversations;
+                       showExportSummary = true;
+                       showImportSummary = false;
+                       showExportDialog = false;
+               } catch (err) {
+                       console.error('Export failed:', err);
+                       alert('Failed to export conversations');
+               }
+       }
+
+       async function handleImportClick() {
+               try {
+                       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) 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'
+                                               );
+                                       }
+
+                                       fullImportData = importedData;
+                                       availableConversations = importedData.map(
+                                               (item: { conv: DatabaseConversation; messages: DatabaseMessage[] }) => item.conv
+                                       );
+                                       messageCountMap = createMessageCountMap(importedData);
+                                       showImportDialog = true;
+                               } catch (err: unknown) {
+                                       const message = err instanceof Error ? err.message : 'Unknown error';
+
+                                       console.error('Failed to parse file:', err);
+                                       alert(`Failed to parse file: ${message}`);
+                               }
+                       };
+
+                       input.click();
+               } catch (err) {
+                       console.error('Import failed:', err);
+                       alert('Failed to import conversations');
+               }
+       }
+
+       async function handleImportConfirm(selectedConversations: DatabaseConversation[]) {
+               try {
+                       const selectedIds = new Set(selectedConversations.map((c) => c.id));
+                       const selectedData = $state
+                               .snapshot(fullImportData)
+                               .filter((item) => selectedIds.has(item.conv.id));
+
+                       await DatabaseStore.importConversations(selectedData);
+
+                       await chatStore.loadConversations();
+
+                       importedConversations = selectedConversations;
+                       showImportSummary = true;
+                       showExportSummary = false;
+                       showImportDialog = false;
+               } catch (err) {
+                       console.error('Import failed:', err);
+                       alert('Failed to import conversations. Please check the file format.');
+               }
+       }
+</script>
+
+<div class="space-y-6">
+       <div class="space-y-4">
+               <div class="grid">
+                       <h4 class="mb-2 text-sm font-medium">Export Conversations</h4>
+
+                       <p class="mb-4 text-sm text-muted-foreground">
+                               Download all your conversations as a JSON file. This includes all messages, attachments, and
+                               conversation history.
+                       </p>
+
+                       <Button
+                               class="w-full justify-start justify-self-start md:w-auto"
+                               onclick={handleExportClick}
+                               variant="outline"
+                       >
+                               <Download class="mr-2 h-4 w-4" />
+
+                               Export conversations
+                       </Button>
+
+                       {#if showExportSummary && exportedConversations.length > 0}
+                               <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+                                       <h5 class="mb-2 text-sm font-medium">
+                                               Exported {exportedConversations.length} conversation{exportedConversations.length === 1
+                                                       ? ''
+                                                       : 's'}
+                                       </h5>
+
+                                       <ul class="space-y-1 text-sm text-muted-foreground">
+                                               {#each exportedConversations.slice(0, 10) as conv (conv.id)}
+                                                       <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+                                               {/each}
+
+                                               {#if exportedConversations.length > 10}
+                                                       <li class="italic">
+                                                               ... and {exportedConversations.length - 10} more
+                                                       </li>
+                                               {/if}
+                                       </ul>
+                               </div>
+                       {/if}
+               </div>
+
+               <div class="grid border-t border-border/30 pt-4">
+                       <h4 class="mb-2 text-sm font-medium">Import Conversations</h4>
+
+                       <p class="mb-4 text-sm text-muted-foreground">
+                               Import one or more conversations from a previously exported JSON file. This will merge with
+                               your existing conversations.
+                       </p>
+
+                       <Button
+                               class="w-full justify-start justify-self-start md:w-auto"
+                               onclick={handleImportClick}
+                               variant="outline"
+                       >
+                               <Upload class="mr-2 h-4 w-4" />
+                               Import conversations
+                       </Button>
+
+                       {#if showImportSummary && importedConversations.length > 0}
+                               <div class="mt-4 grid overflow-x-auto rounded-lg border border-border/50 bg-muted/30 p-4">
+                                       <h5 class="mb-2 text-sm font-medium">
+                                               Imported {importedConversations.length} conversation{importedConversations.length === 1
+                                                       ? ''
+                                                       : 's'}
+                                       </h5>
+
+                                       <ul class="space-y-1 text-sm text-muted-foreground">
+                                               {#each importedConversations.slice(0, 10) as conv (conv.id)}
+                                                       <li class="truncate">• {conv.name || 'Untitled conversation'}</li>
+                                               {/each}
+
+                                               {#if importedConversations.length > 10}
+                                                       <li class="italic">
+                                                               ... and {importedConversations.length - 10} more
+                                                       </li>
+                                               {/if}
+                                       </ul>
+                               </div>
+                       {/if}
+               </div>
+       </div>
+</div>
+
+<ConversationSelectionDialog
+       conversations={availableConversations}
+       {messageCountMap}
+       mode="export"
+       bind:open={showExportDialog}
+       onCancel={() => (showExportDialog = false)}
+       onConfirm={handleExportConfirm}
+/>
+
+<ConversationSelectionDialog
+       conversations={availableConversations}
+       {messageCountMap}
+       mode="import"
+       bind:open={showImportDialog}
+       onCancel={() => (showImportDialog = false)}
+       onConfirm={handleImportConfirm}
+/>
index e91673e98b036ecfe633b983efdc086ab4b656d4..30d1f9d4b7e98a3f8cf846d8d73bb3e25660e9ae 100644 (file)
@@ -1,9 +1,8 @@
 <script lang="ts">
-       import { Search, SquarePen, X, Download, Upload } from '@lucide/svelte';
+       import { Search, SquarePen, X } 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 4c2cbdebe16eb24f35e8d09c9d293f80e3713138..7b85db93db3f5735291ee8c49c5f94bf2b9ea865 100644 (file)
@@ -25,6 +25,8 @@ export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
 export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte';
 export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
 export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
+export { default as ImportExportTab } from './chat/ChatSettings/ImportExportTab.svelte';
+export { default as ConversationSelectionDialog } from './chat/ChatSettings/ConversationSelectionDialog.svelte';
 export { default as ParameterSourceIndicator } from './chat/ChatSettings/ParameterSourceIndicator.svelte';
 
 export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
index 96187e005a26c168880db5187b43b4024899e6f6..ccc67c7294263ccdeae3a48a1b3c385e20aa28ed 100644 (file)
@@ -1040,8 +1040,9 @@ class ChatStore {
 
        /**
         * Exports all conversations with their messages as a JSON file
+        * Returns the list of exported conversations
         */
-       async exportAllConversations(): Promise<void> {
+       async exportAllConversations(): Promise<DatabaseConversation[]> {
                try {
                        const allConversations = await DatabaseStore.getAllConversations();
                        if (allConversations.length === 0) {
@@ -1068,6 +1069,7 @@ class ChatStore {
                        URL.revokeObjectURL(url);
 
                        toast.success(`All conversations (${allConversations.length}) prepared for download`);
+                       return allConversations;
                } catch (err) {
                        console.error('Failed to export conversations:', err);
                        throw err;
@@ -1078,8 +1080,9 @@ class ChatStore {
         * Imports conversations from a JSON file.
         * Supports both single conversation (object) and multiple conversations (array).
         * Uses DatabaseStore for safe, encapsulated data access
+        * Returns the list of imported conversations
         */
-       async importConversations(): Promise<void> {
+       async importConversations(): Promise<DatabaseConversation[]> {
                return new Promise((resolve, reject) => {
                        const input = document.createElement('input');
                        input.type = 'file';
@@ -1120,7 +1123,9 @@ class ChatStore {
 
                                        toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
 
-                                       resolve(undefined);
+                                       // Extract the conversation objects from imported data
+                                       const importedConversations = importedData.map((item) => item.conv);
+                                       resolve(importedConversations);
                                } catch (err: unknown) {
                                        const message = err instanceof Error ? err.message : 'Unknown error';
                                        console.error('Failed to import conversations:', err);
diff --git a/tools/server/webui/src/lib/utils/conversation-utils.ts b/tools/server/webui/src/lib/utils/conversation-utils.ts
new file mode 100644 (file)
index 0000000..aee244a
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Utility functions for conversation data manipulation
+ */
+
+/**
+ * Creates a map of conversation IDs to their message counts from exported conversation data
+ * @param exportedData - Array of exported conversations with their messages
+ * @returns Map of conversation ID to message count
+ */
+export function createMessageCountMap(
+       exportedData: Array<{ conv: DatabaseConversation; messages: DatabaseMessage[] }>
+): Map<string, number> {
+       const countMap = new Map<string, number>();
+
+       for (const item of exportedData) {
+               countMap.set(item.conv.id, item.messages.length);
+       }
+
+       return countMap;
+}
+
+/**
+ * Gets the message count for a specific conversation from the count map
+ * @param conversationId - The ID of the conversation
+ * @param countMap - Map of conversation IDs to message counts
+ * @returns The message count, or 0 if not found
+ */
+export function getMessageCount(conversationId: string, countMap: Map<string, number>): number {
+       return countMap.get(conversationId) ?? 0;
+}