Skip to content

Commit c59cb47

Browse files
Merge PR ggml-org#16282 (srogmann: feature/svelte_conv_download) into testing-branch7
2 parents 3fd608c + 9a28dc1 commit c59cb47

File tree

5 files changed

+251
-2
lines changed

5 files changed

+251
-2
lines changed

tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
<script lang="ts">
2-
import { Search, SquarePen, X } from '@lucide/svelte';
2+
import { Search, SquarePen, X, Download, Upload } from '@lucide/svelte';
33
import { KeyboardShortcutInfo } from '$lib/components/app';
44
import { Button } from '$lib/components/ui/button';
55
import { Input } from '$lib/components/ui/input';
6+
import { exportAllConversations, importConversations } from '$lib/stores/chat.svelte';
67
78
interface Props {
89
handleMobileSidebarItemClick: () => void;
@@ -77,5 +78,37 @@
7778

7879
<KeyboardShortcutInfo keys={['cmd', 'k']} />
7980
</Button>
81+
82+
<!-- Export All Conversations -->
83+
<Button
84+
class="w-full justify-start text-sm"
85+
onclick={() => {
86+
exportAllConversations();
87+
}}
88+
variant="ghost"
89+
>
90+
<div class="flex items-center gap-2">
91+
<Download class="h-4 w-4" />
92+
Export all
93+
</div>
94+
</Button>
95+
96+
<!-- Import Conversations -->
97+
<Button
98+
class="w-full justify-start text-sm"
99+
onclick={() => {
100+
importConversations().catch(err => {
101+
console.error('Import failed:', err);
102+
// Optional: show toast or dialog
103+
});
104+
}}
105+
variant="ghost"
106+
>
107+
108+
<div class="flex items-center gap-2">
109+
<Upload class="h-4 w-4" />
110+
Import all
111+
</div>
112+
</Button>
80113
{/if}
81114
</div>

tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
2-
import { Trash2, Pencil, MoreHorizontal } from '@lucide/svelte';
2+
import { Trash2, Pencil, MoreHorizontal, Download } from '@lucide/svelte';
33
import { ActionDropdown } from '$lib/components/app';
4+
import { downloadConversation } from '$lib/stores/chat.svelte';
45
import { onMount } from 'svelte';
56
67
interface Props {
@@ -101,6 +102,15 @@
101102
onclick: handleEdit,
102103
shortcut: ['shift', 'cmd', 'e']
103104
},
105+
{
106+
icon: Download,
107+
label: 'Download',
108+
onclick: (e) => {
109+
e.stopPropagation();
110+
downloadConversation(conversation.id);
111+
},
112+
shortcut: ['shift', 'cmd', 's']
113+
},
104114
{
105115
icon: Trash2,
106116
label: 'Delete',

tools/server/webui/src/lib/stores/chat.svelte.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { config } from '$lib/stores/settings.svelte';
66
import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching';
77
import { browser } from '$app/environment';
88
import { goto } from '$app/navigation';
9+
import { extractPartialThinking } from '$lib/utils/thinking';
10+
import { toast } from 'svelte-sonner';
11+
import type { ExportedConversations } from '$lib/types/database';
912

1013
/**
1114
* ChatStore - Central state management for chat conversations and AI interactions
@@ -959,6 +962,155 @@ class ChatStore {
959962
}
960963
}
961964

965+
/**
966+
* Downloads a conversation as JSON file
967+
* @param convId - The conversation ID to download
968+
*/
969+
async downloadConversation(convId: string): Promise<void> {
970+
if (!this.activeConversation || this.activeConversation.id !== convId) {
971+
// Load the conversation if not currently active
972+
const conversation = await DatabaseStore.getConversation(convId);
973+
if (!conversation) return;
974+
975+
const messages = await DatabaseStore.getConversationMessages(convId);
976+
const conversationData = {
977+
conv: conversation,
978+
messages
979+
};
980+
981+
this.triggerDownload(conversationData);
982+
} else {
983+
// Use current active conversation data
984+
const conversationData: ExportedConversations = {
985+
conv: this.activeConversation!,
986+
messages: this.activeMessages
987+
};
988+
989+
this.triggerDownload(conversationData);
990+
}
991+
}
992+
993+
/**
994+
* Triggers file download in browser
995+
* @param data - Data to download (expected: { conv: DatabaseConversation, messages: DatabaseMessage[] })
996+
* @param filename - Optional filename
997+
*/
998+
private triggerDownload(data: ExportedConversations, filename?: string): void {
999+
const conversation = 'conv' in data ? data.conv : (Array.isArray(data) ? data[0]?.conv : undefined);
1000+
if (!conversation) {
1001+
console.error('Invalid data: missing conversation');
1002+
return;
1003+
}
1004+
const conversationName = conversation.name ? conversation.name.trim() : '';
1005+
const convId = conversation.id || 'unknown';
1006+
const truncatedSuffix = conversationName.toLowerCase()
1007+
.replace(/[^a-z0-9]/gi, '_').replace(/_+/g, '_').substring(0, 20);
1008+
const downloadFilename = filename || `conversation_${convId}_${truncatedSuffix}.json`;
1009+
1010+
const conversationJson = JSON.stringify(data, null, 2);
1011+
const blob = new Blob([conversationJson], {
1012+
type: 'application/json',
1013+
});
1014+
const url = URL.createObjectURL(blob);
1015+
const a = document.createElement('a');
1016+
a.href = url;
1017+
a.download = downloadFilename;
1018+
document.body.appendChild(a);
1019+
a.click();
1020+
document.body.removeChild(a);
1021+
URL.revokeObjectURL(url);
1022+
}
1023+
1024+
/**
1025+
* Exports all conversations with their messages as a JSON file
1026+
*/
1027+
async exportAllConversations(): Promise<void> {
1028+
try {
1029+
const allConversations = await DatabaseStore.getAllConversations();
1030+
if (allConversations.length === 0) {
1031+
throw new Error('No conversations to export');
1032+
}
1033+
1034+
const allData: ExportedConversations = await Promise.all(
1035+
allConversations.map(async (conv) => {
1036+
const messages = await DatabaseStore.getConversationMessages(conv.id);
1037+
return { conv, messages };
1038+
})
1039+
);
1040+
1041+
const blob = new Blob([JSON.stringify(allData, null, 2)], {
1042+
type: 'application/json'
1043+
});
1044+
const url = URL.createObjectURL(blob);
1045+
const a = document.createElement('a');
1046+
a.href = url;
1047+
a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
1048+
document.body.appendChild(a);
1049+
a.click();
1050+
document.body.removeChild(a);
1051+
URL.revokeObjectURL(url);
1052+
1053+
toast.success(`All conversations (${allConversations.length}) prepared for download`);
1054+
} catch (err) {
1055+
console.error('Failed to export conversations:', err);
1056+
throw err;
1057+
}
1058+
}
1059+
1060+
/**
1061+
* Imports conversations from a JSON file.
1062+
* Supports both single conversation (object) and multiple conversations (array).
1063+
* Uses DatabaseStore for safe, encapsulated data access
1064+
*/
1065+
async importConversations(): Promise<void> {
1066+
return new Promise((resolve, reject) => {
1067+
const input = document.createElement('input');
1068+
input.type = 'file';
1069+
input.accept = '.json';
1070+
1071+
input.onchange = async (e) => {
1072+
const file = (e.target as HTMLInputElement)?.files?.[0];
1073+
if (!file) {
1074+
reject(new Error('No file selected'));
1075+
return;
1076+
}
1077+
1078+
try {
1079+
const text = await file.text();
1080+
const parsedData = JSON.parse(text);
1081+
let importedData: ExportedConversations;
1082+
1083+
if (Array.isArray(parsedData)) {
1084+
importedData = parsedData;
1085+
} else if (parsedData && typeof parsedData === 'object' && 'conv' in parsedData && 'messages' in parsedData) {
1086+
// Single conversation object
1087+
importedData = [parsedData];
1088+
} else {
1089+
throw new Error('Invalid file format: expected array of conversations or single conversation object');
1090+
}
1091+
1092+
const result = await DatabaseStore.importConversations(importedData);
1093+
1094+
// Refresh UI
1095+
await this.loadConversations();
1096+
1097+
toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
1098+
1099+
resolve(undefined);
1100+
} catch (err: unknown) {
1101+
const message = err instanceof Error ? err.message : 'Unknown error';
1102+
console.error('Failed to import conversations:', err);
1103+
toast.error('Import failed', {
1104+
description: message
1105+
});
1106+
reject(new Error(`Import failed: ${message}`));
1107+
}
1108+
};
1109+
1110+
input.click();
1111+
});
1112+
}
1113+
9621114
/**
9631115
* Deletes a conversation and all its messages
9641116
* @param convId - The conversation ID to delete
@@ -1435,6 +1587,9 @@ export const isInitialized = () => chatStore.isInitialized;
14351587
export const maxContextError = () => chatStore.maxContextError;
14361588

14371589
export const createConversation = chatStore.createConversation.bind(chatStore);
1590+
export const downloadConversation = chatStore.downloadConversation.bind(chatStore);
1591+
export const exportAllConversations = chatStore.exportAllConversations.bind(chatStore);
1592+
export const importConversations = chatStore.importConversations.bind(chatStore);
14381593
export const deleteConversation = chatStore.deleteConversation.bind(chatStore);
14391594
export const sendMessage = chatStore.sendMessage.bind(chatStore);
14401595
export const gracefulStop = chatStore.gracefulStop.bind(chatStore);

tools/server/webui/src/lib/stores/database.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,40 @@ export class DatabaseStore {
346346
): Promise<void> {
347347
await db.messages.update(id, updates);
348348
}
349+
350+
/**
351+
* Imports multiple conversations and their messages.
352+
* Skips conversations that already exist.
353+
*
354+
* @param data - Array of { conv, messages } objects
355+
*/
356+
static async importConversations(
357+
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
358+
): Promise<{ imported: number; skipped: number }> {
359+
let importedCount = 0;
360+
let skippedCount = 0;
361+
362+
return await db.transaction('rw', [db.conversations, db.messages], async () => {
363+
for (const item of data) {
364+
const { conv, messages } = item;
365+
366+
const existing = await db.conversations.get(conv.id);
367+
if (existing) {
368+
console.warn(`Conversation "${conv.name}" already exists, skipping...`);
369+
skippedCount++;
370+
continue;
371+
}
372+
373+
await db.conversations.add(conv);
374+
for (const msg of messages) {
375+
await db.messages.put(msg);
376+
}
377+
378+
importedCount++;
379+
}
380+
381+
return { imported: importedCount, skipped: skippedCount };
382+
});
383+
}
384+
349385
}

tools/server/webui/src/lib/types/database.d.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,18 @@ export interface DatabaseMessage {
5454
timings?: ChatMessageTimings;
5555
model?: string;
5656
}
57+
58+
/**
59+
* Represents a single conversation with its associated messages,
60+
* typically used for import/export operations.
61+
*/
62+
export type ExportedConversation = {
63+
conv: DatabaseConversation;
64+
messages: DatabaseMessage[];
65+
};
66+
67+
/**
68+
* Type representing one or more exported conversations.
69+
* Can be a single conversation object or an array of them.
70+
*/
71+
export type ExportedConversations = ExportedConversation | ExportedConversation[];

0 commit comments

Comments
 (0)