Skip to content

Commit 4e0388a

Browse files
srogmannallozaur
andauthored
webui : added download action (#13552) (#16282)
* 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 * webui : Updated static build output --------- Co-authored-by: Aleksander Grygier <[email protected]>
1 parent ef4c5b8 commit 4e0388a

File tree

6 files changed

+257
-2
lines changed

6 files changed

+257
-2
lines changed

tools/server/public/index.html.gz

1.14 KB
Binary file not shown.

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

Lines changed: 31 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,34 @@
7778

7879
<KeyboardShortcutInfo keys={['cmd', 'k']} />
7980
</Button>
81+
82+
<Button
83+
class="w-full justify-start text-sm"
84+
onclick={() => {
85+
importConversations().catch((err) => {
86+
console.error('Import failed:', err);
87+
// Optional: show toast or dialog
88+
});
89+
}}
90+
variant="ghost"
91+
>
92+
<div class="flex items-center gap-2">
93+
<Upload class="h-4 w-4" />
94+
Import conversations
95+
</div>
96+
</Button>
97+
98+
<Button
99+
class="w-full justify-start text-sm"
100+
onclick={() => {
101+
exportAllConversations();
102+
}}
103+
variant="ghost"
104+
>
105+
<div class="flex items-center gap-2">
106+
<Download class="h-4 w-4" />
107+
Export all conversations
108+
</div>
109+
</Button>
80110
{/if}
81111
</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: 'Export',
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: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/u
66
import { browser } from '$app/environment';
77
import { goto } from '$app/navigation';
88
import { extractPartialThinking } from '$lib/utils/thinking';
9+
import { toast } from 'svelte-sonner';
10+
import type { ExportedConversations } from '$lib/types/database';
911

1012
/**
1113
* ChatStore - Central state management for chat conversations and AI interactions
@@ -951,6 +953,166 @@ class ChatStore {
951953
}
952954
}
953955

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

14291591
export const createConversation = chatStore.createConversation.bind(chatStore);
1592+
export const downloadConversation = chatStore.downloadConversation.bind(chatStore);
1593+
export const exportAllConversations = chatStore.exportAllConversations.bind(chatStore);
1594+
export const importConversations = chatStore.importConversations.bind(chatStore);
14301595
export const deleteConversation = chatStore.deleteConversation.bind(chatStore);
14311596
export const sendMessage = chatStore.sendMessage.bind(chatStore);
14321597
export const gracefulStop = chatStore.gracefulStop.bind(chatStore);

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,39 @@ 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+
}
349384
}

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)