mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2025-10-28 08:31:25 +00:00
Import/Export UX improvements (#16619)
* 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 <github@rogmann.org>
This commit is contained in:
committed by
GitHub
parent
13f2cfad41
commit
0e4a0cf2fa
Binary file not shown.
@@ -9,9 +9,11 @@
|
|||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight
|
ChevronRight,
|
||||||
|
Database
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
|
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
|
||||||
|
import ImportExportTab from './ImportExportTab.svelte';
|
||||||
import * as Dialog from '$lib/components/ui/dialog';
|
import * as Dialog from '$lib/components/ui/dialog';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
import { ScrollArea } from '$lib/components/ui/scroll-area';
|
||||||
import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
|
import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
|
||||||
@@ -205,6 +207,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Import/Export',
|
||||||
|
icon: Database,
|
||||||
|
fields: []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Developer',
|
title: 'Developer',
|
||||||
icon: Code,
|
icon: Code,
|
||||||
@@ -455,21 +462,25 @@
|
|||||||
|
|
||||||
<ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
|
<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 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">
|
<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" />
|
<currentSection.icon class="h-5 w-5" />
|
||||||
|
|
||||||
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
{#if currentSection.title === 'Import/Export'}
|
||||||
<ChatSettingsFields
|
<ImportExportTab />
|
||||||
fields={currentSection.fields}
|
{:else}
|
||||||
{localConfig}
|
<div class="space-y-6">
|
||||||
onConfigChange={handleConfigChange}
|
<ChatSettingsFields
|
||||||
onThemeChange={handleThemeChange}
|
fields={currentSection.fields}
|
||||||
/>
|
{localConfig}
|
||||||
</div>
|
onConfigChange={handleConfigChange}
|
||||||
|
onThemeChange={handleThemeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 border-t pt-6">
|
<div class="mt-8 border-t pt-6">
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<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 { KeyboardShortcutInfo } from '$lib/components/app';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { exportAllConversations, importConversations } from '$lib/stores/chat.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
handleMobileSidebarItemClick: () => void;
|
handleMobileSidebarItemClick: () => void;
|
||||||
@@ -78,34 +77,5 @@
|
|||||||
|
|
||||||
<KeyboardShortcutInfo keys={['cmd', 'k']} />
|
<KeyboardShortcutInfo keys={['cmd', 'k']} />
|
||||||
</Button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte';
|
||||||
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
|
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
|
||||||
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.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 ParameterSourceIndicator } from './chat/ChatSettings/ParameterSourceIndicator.svelte';
|
||||||
|
|
||||||
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
|
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
|
||||||
|
|||||||
@@ -1040,8 +1040,9 @@ class ChatStore {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports all conversations with their messages as a JSON file
|
* 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 {
|
try {
|
||||||
const allConversations = await DatabaseStore.getAllConversations();
|
const allConversations = await DatabaseStore.getAllConversations();
|
||||||
if (allConversations.length === 0) {
|
if (allConversations.length === 0) {
|
||||||
@@ -1068,6 +1069,7 @@ class ChatStore {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
||||||
|
return allConversations;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to export conversations:', err);
|
console.error('Failed to export conversations:', err);
|
||||||
throw err;
|
throw err;
|
||||||
@@ -1078,8 +1080,9 @@ class ChatStore {
|
|||||||
* Imports conversations from a JSON file.
|
* Imports conversations from a JSON file.
|
||||||
* Supports both single conversation (object) and multiple conversations (array).
|
* Supports both single conversation (object) and multiple conversations (array).
|
||||||
* Uses DatabaseStore for safe, encapsulated data access
|
* 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
@@ -1120,7 +1123,9 @@ class ChatStore {
|
|||||||
|
|
||||||
toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
|
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) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||||
console.error('Failed to import conversations:', err);
|
console.error('Failed to import conversations:', err);
|
||||||
|
|||||||
30
tools/server/webui/src/lib/utils/conversation-utils.ts
Normal file
30
tools/server/webui/src/lib/utils/conversation-utils.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user