mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2025-10-27 08:21:30 +00:00
* 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 <aleksander.grygier@gmail.com>
This commit is contained in:
Binary file not shown.
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { Search, SquarePen, X } from '@lucide/svelte';
|
||||
import { Search, SquarePen, X, Download, Upload } 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;
|
||||
@@ -77,5 +78,34 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Trash2, Pencil, MoreHorizontal } from '@lucide/svelte';
|
||||
import { Trash2, Pencil, MoreHorizontal, Download } from '@lucide/svelte';
|
||||
import { ActionDropdown } from '$lib/components/app';
|
||||
import { downloadConversation } from '$lib/stores/chat.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -101,6 +102,15 @@
|
||||
onclick: handleEdit,
|
||||
shortcut: ['shift', 'cmd', 'e']
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
label: 'Export',
|
||||
onclick: (e) => {
|
||||
e.stopPropagation();
|
||||
downloadConversation(conversation.id);
|
||||
},
|
||||
shortcut: ['shift', 'cmd', 's']
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
|
||||
@@ -6,6 +6,8 @@ import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/u
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { extractPartialThinking } from '$lib/utils/thinking';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { ExportedConversations } from '$lib/types/database';
|
||||
|
||||
/**
|
||||
* ChatStore - Central state management for chat conversations and AI interactions
|
||||
@@ -951,6 +953,166 @@ class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a conversation as JSON file
|
||||
* @param convId - The conversation ID to download
|
||||
*/
|
||||
async downloadConversation(convId: string): Promise<void> {
|
||||
if (!this.activeConversation || this.activeConversation.id !== convId) {
|
||||
// Load the conversation if not currently active
|
||||
const conversation = await DatabaseStore.getConversation(convId);
|
||||
if (!conversation) return;
|
||||
|
||||
const messages = await DatabaseStore.getConversationMessages(convId);
|
||||
const conversationData = {
|
||||
conv: conversation,
|
||||
messages
|
||||
};
|
||||
|
||||
this.triggerDownload(conversationData);
|
||||
} else {
|
||||
// Use current active conversation data
|
||||
const conversationData: ExportedConversations = {
|
||||
conv: this.activeConversation!,
|
||||
messages: this.activeMessages
|
||||
};
|
||||
|
||||
this.triggerDownload(conversationData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers file download in browser
|
||||
* @param data - Data to download (expected: { conv: DatabaseConversation, messages: DatabaseMessage[] })
|
||||
* @param filename - Optional filename
|
||||
*/
|
||||
private triggerDownload(data: ExportedConversations, filename?: string): void {
|
||||
const conversation =
|
||||
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
|
||||
if (!conversation) {
|
||||
console.error('Invalid data: missing conversation');
|
||||
return;
|
||||
}
|
||||
const conversationName = conversation.name ? conversation.name.trim() : '';
|
||||
const convId = conversation.id || 'unknown';
|
||||
const truncatedSuffix = conversationName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/gi, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.substring(0, 20);
|
||||
const downloadFilename = filename || `conversation_${convId}_${truncatedSuffix}.json`;
|
||||
|
||||
const conversationJson = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([conversationJson], {
|
||||
type: 'application/json'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all conversations with their messages as a JSON file
|
||||
*/
|
||||
async exportAllConversations(): Promise<void> {
|
||||
try {
|
||||
const allConversations = await DatabaseStore.getAllConversations();
|
||||
if (allConversations.length === 0) {
|
||||
throw new Error('No conversations to export');
|
||||
}
|
||||
|
||||
const allData: ExportedConversations = await Promise.all(
|
||||
allConversations.map(async (conv) => {
|
||||
const messages = await DatabaseStore.getConversationMessages(conv.id);
|
||||
return { conv, 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 = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`All conversations (${allConversations.length}) prepared for download`);
|
||||
} 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
|
||||
*/
|
||||
async importConversations(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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) {
|
||||
reject(new Error('No file selected'));
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
const result = await DatabaseStore.importConversations(importedData);
|
||||
|
||||
// Refresh UI
|
||||
await this.loadConversations();
|
||||
|
||||
toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
|
||||
|
||||
resolve(undefined);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
console.error('Failed to import conversations:', err);
|
||||
toast.error('Import failed', {
|
||||
description: message
|
||||
});
|
||||
reject(new Error(`Import failed: ${message}`));
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a conversation and all its messages
|
||||
* @param convId - The conversation ID to delete
|
||||
@@ -1427,6 +1589,9 @@ export const isInitialized = () => chatStore.isInitialized;
|
||||
export const maxContextError = () => chatStore.maxContextError;
|
||||
|
||||
export const createConversation = chatStore.createConversation.bind(chatStore);
|
||||
export const downloadConversation = chatStore.downloadConversation.bind(chatStore);
|
||||
export const exportAllConversations = chatStore.exportAllConversations.bind(chatStore);
|
||||
export const importConversations = chatStore.importConversations.bind(chatStore);
|
||||
export const deleteConversation = chatStore.deleteConversation.bind(chatStore);
|
||||
export const sendMessage = chatStore.sendMessage.bind(chatStore);
|
||||
export const gracefulStop = chatStore.gracefulStop.bind(chatStore);
|
||||
|
||||
@@ -346,4 +346,39 @@ export class DatabaseStore {
|
||||
): Promise<void> {
|
||||
await db.messages.update(id, updates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports multiple conversations and their messages.
|
||||
* Skips conversations that already exist.
|
||||
*
|
||||
* @param data - Array of { conv, messages } objects
|
||||
*/
|
||||
static async importConversations(
|
||||
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
let importedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
return await db.transaction('rw', [db.conversations, db.messages], async () => {
|
||||
for (const item of data) {
|
||||
const { conv, messages } = item;
|
||||
|
||||
const existing = await db.conversations.get(conv.id);
|
||||
if (existing) {
|
||||
console.warn(`Conversation "${conv.name}" already exists, skipping...`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db.conversations.add(conv);
|
||||
for (const msg of messages) {
|
||||
await db.messages.put(msg);
|
||||
}
|
||||
|
||||
importedCount++;
|
||||
}
|
||||
|
||||
return { imported: importedCount, skipped: skippedCount };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
15
tools/server/webui/src/lib/types/database.d.ts
vendored
15
tools/server/webui/src/lib/types/database.d.ts
vendored
@@ -54,3 +54,18 @@ export interface DatabaseMessage {
|
||||
timings?: ChatMessageTimings;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single conversation with its associated messages,
|
||||
* typically used for import/export operations.
|
||||
*/
|
||||
export type ExportedConversation = {
|
||||
conv: DatabaseConversation;
|
||||
messages: DatabaseMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Type representing one or more exported conversations.
|
||||
* Can be a single conversation object or an array of them.
|
||||
*/
|
||||
export type ExportedConversations = ExportedConversation | ExportedConversation[];
|
||||
|
||||
Reference in New Issue
Block a user