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">
--- /dev/null
+<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>
--- /dev/null
+<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}
+/>
<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>
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';
/**
* 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) {
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;
* 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';
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);
--- /dev/null
+/**
+ * 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;
+}