Skip to content

Commit e4a83f0

Browse files
allozaursrogmann
authored andcommitted
Import/Export UX improvements (ggml-org#16619)
* webui : added download action (ggml-org#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>
1 parent 5290956 commit e4a83f0

File tree

8 files changed

+566
-44
lines changed

8 files changed

+566
-44
lines changed

tools/server/public/index.html.gz

3.53 KB
Binary file not shown.

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
Sun,
1010
Moon,
1111
ChevronLeft,
12-
ChevronRight
12+
ChevronRight,
13+
Database
1314
} from '@lucide/svelte';
1415
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
16+
import ImportExportTab from './ImportExportTab.svelte';
1517
import * as Dialog from '$lib/components/ui/dialog';
1618
import { ScrollArea } from '$lib/components/ui/scroll-area';
1719
import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
@@ -205,6 +207,11 @@
205207
}
206208
]
207209
},
210+
{
211+
title: 'Import/Export',
212+
icon: Database,
213+
fields: []
214+
},
208215
{
209216
title: 'Developer',
210217
icon: Code,
@@ -455,21 +462,25 @@
455462

456463
<ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
457464
<div class="space-y-6 p-4 md:p-6">
458-
<div>
465+
<div class="grid">
459466
<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
460467
<currentSection.icon class="h-5 w-5" />
461468

462469
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
463470
</div>
464471

465-
<div class="space-y-6">
466-
<ChatSettingsFields
467-
fields={currentSection.fields}
468-
{localConfig}
469-
onConfigChange={handleConfigChange}
470-
onThemeChange={handleThemeChange}
471-
/>
472-
</div>
472+
{#if currentSection.title === 'Import/Export'}
473+
<ImportExportTab />
474+
{:else}
475+
<div class="space-y-6">
476+
<ChatSettingsFields
477+
fields={currentSection.fields}
478+
{localConfig}
479+
onConfigChange={handleConfigChange}
480+
onThemeChange={handleThemeChange}
481+
/>
482+
</div>
483+
{/if}
473484
</div>
474485

475486
<div class="mt-8 border-t pt-6">
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
<script lang="ts">
2+
import { Search, X } from '@lucide/svelte';
3+
import * as Dialog from '$lib/components/ui/dialog';
4+
import { Button } from '$lib/components/ui/button';
5+
import { Input } from '$lib/components/ui/input';
6+
import { Checkbox } from '$lib/components/ui/checkbox';
7+
import { ScrollArea } from '$lib/components/ui/scroll-area';
8+
import { SvelteSet } from 'svelte/reactivity';
9+
10+
interface Props {
11+
conversations: DatabaseConversation[];
12+
messageCountMap?: Map<string, number>;
13+
mode: 'export' | 'import';
14+
onCancel: () => void;
15+
onConfirm: (selectedConversations: DatabaseConversation[]) => void;
16+
open?: boolean;
17+
}
18+
19+
let {
20+
conversations,
21+
messageCountMap = new Map(),
22+
mode,
23+
onCancel,
24+
onConfirm,
25+
open = $bindable(false)
26+
}: Props = $props();
27+
28+
let searchQuery = $state('');
29+
let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
30+
let lastClickedId = $state<string | null>(null);
31+
32+
let filteredConversations = $derived(
33+
conversations.filter((conv) => {
34+
const name = conv.name || 'Untitled conversation';
35+
return name.toLowerCase().includes(searchQuery.toLowerCase());
36+
})
37+
);
38+
39+
let allSelected = $derived(
40+
filteredConversations.length > 0 &&
41+
filteredConversations.every((conv) => selectedIds.has(conv.id))
42+
);
43+
44+
let someSelected = $derived(
45+
filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
46+
);
47+
48+
function toggleConversation(id: string, shiftKey: boolean = false) {
49+
const newSet = new SvelteSet(selectedIds);
50+
51+
if (shiftKey && lastClickedId !== null) {
52+
const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
53+
const currentIndex = filteredConversations.findIndex((c) => c.id === id);
54+
55+
if (lastIndex !== -1 && currentIndex !== -1) {
56+
const start = Math.min(lastIndex, currentIndex);
57+
const end = Math.max(lastIndex, currentIndex);
58+
59+
const shouldSelect = !newSet.has(id);
60+
61+
for (let i = start; i <= end; i++) {
62+
if (shouldSelect) {
63+
newSet.add(filteredConversations[i].id);
64+
} else {
65+
newSet.delete(filteredConversations[i].id);
66+
}
67+
}
68+
69+
selectedIds = newSet;
70+
return;
71+
}
72+
}
73+
74+
if (newSet.has(id)) {
75+
newSet.delete(id);
76+
} else {
77+
newSet.add(id);
78+
}
79+
80+
selectedIds = newSet;
81+
lastClickedId = id;
82+
}
83+
84+
function toggleAll() {
85+
if (allSelected) {
86+
const newSet = new SvelteSet(selectedIds);
87+
88+
filteredConversations.forEach((conv) => newSet.delete(conv.id));
89+
selectedIds = newSet;
90+
} else {
91+
const newSet = new SvelteSet(selectedIds);
92+
93+
filteredConversations.forEach((conv) => newSet.add(conv.id));
94+
selectedIds = newSet;
95+
}
96+
}
97+
98+
function handleConfirm() {
99+
const selected = conversations.filter((conv) => selectedIds.has(conv.id));
100+
onConfirm(selected);
101+
}
102+
103+
function handleCancel() {
104+
selectedIds = new SvelteSet(conversations.map((c) => c.id));
105+
searchQuery = '';
106+
lastClickedId = null;
107+
108+
onCancel();
109+
}
110+
111+
let previousOpen = $state(false);
112+
113+
$effect(() => {
114+
if (open && !previousOpen) {
115+
selectedIds = new SvelteSet(conversations.map((c) => c.id));
116+
searchQuery = '';
117+
lastClickedId = null;
118+
} else if (!open && previousOpen) {
119+
onCancel();
120+
}
121+
122+
previousOpen = open;
123+
});
124+
</script>
125+
126+
<Dialog.Root bind:open>
127+
<Dialog.Portal>
128+
<Dialog.Overlay class="z-[1000000]" />
129+
130+
<Dialog.Content class="z-[1000001] max-w-2xl">
131+
<Dialog.Header>
132+
<Dialog.Title>
133+
Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
134+
</Dialog.Title>
135+
136+
<Dialog.Description>
137+
{#if mode === 'export'}
138+
Choose which conversations you want to export. Selected conversations will be downloaded
139+
as a JSON file.
140+
{:else}
141+
Choose which conversations you want to import. Selected conversations will be merged
142+
with your existing conversations.
143+
{/if}
144+
</Dialog.Description>
145+
</Dialog.Header>
146+
147+
<div class="space-y-4">
148+
<div class="relative">
149+
<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
150+
151+
<Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
152+
153+
{#if searchQuery}
154+
<button
155+
class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
156+
onclick={() => (searchQuery = '')}
157+
type="button"
158+
>
159+
<X class="h-4 w-4" />
160+
</button>
161+
{/if}
162+
</div>
163+
164+
<div class="flex items-center justify-between text-sm text-muted-foreground">
165+
<span>
166+
{selectedIds.size} of {conversations.length} selected
167+
{#if searchQuery}
168+
({filteredConversations.length} shown)
169+
{/if}
170+
</span>
171+
</div>
172+
173+
<div class="overflow-hidden rounded-md border">
174+
<ScrollArea class="h-[400px]">
175+
<table class="w-full">
176+
<thead class="sticky top-0 z-10 bg-muted">
177+
<tr class="border-b">
178+
<th class="w-12 p-3 text-left">
179+
<Checkbox
180+
checked={allSelected}
181+
indeterminate={someSelected}
182+
onCheckedChange={toggleAll}
183+
/>
184+
</th>
185+
186+
<th class="p-3 text-left text-sm font-medium">Conversation Name</th>
187+
188+
<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
189+
</tr>
190+
</thead>
191+
<tbody>
192+
{#if filteredConversations.length === 0}
193+
<tr>
194+
<td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
195+
{#if searchQuery}
196+
No conversations found matching "{searchQuery}"
197+
{:else}
198+
No conversations available
199+
{/if}
200+
</td>
201+
</tr>
202+
{:else}
203+
{#each filteredConversations as conv (conv.id)}
204+
<tr
205+
class="cursor-pointer border-b transition-colors hover:bg-muted/50"
206+
onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
207+
>
208+
<td class="p-3">
209+
<Checkbox
210+
checked={selectedIds.has(conv.id)}
211+
onclick={(e) => {
212+
e.preventDefault();
213+
e.stopPropagation();
214+
toggleConversation(conv.id, e.shiftKey);
215+
}}
216+
/>
217+
</td>
218+
219+
<td class="p-3 text-sm">
220+
<div
221+
class="max-w-[17rem] truncate"
222+
title={conv.name || 'Untitled conversation'}
223+
>
224+
{conv.name || 'Untitled conversation'}
225+
</div>
226+
</td>
227+
228+
<td class="p-3 text-sm text-muted-foreground">
229+
{messageCountMap.get(conv.id) ?? 0}
230+
</td>
231+
</tr>
232+
{/each}
233+
{/if}
234+
</tbody>
235+
</table>
236+
</ScrollArea>
237+
</div>
238+
</div>
239+
240+
<Dialog.Footer>
241+
<Button variant="outline" onclick={handleCancel}>Cancel</Button>
242+
243+
<Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
244+
{mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
245+
</Button>
246+
</Dialog.Footer>
247+
</Dialog.Content>
248+
</Dialog.Portal>
249+
</Dialog.Root>

0 commit comments

Comments
 (0)