mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2025-11-01 09:01:57 +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">
|
<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 { 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;
|
||||||
@@ -77,5 +78,34 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<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 { ActionDropdown } from '$lib/components/app';
|
||||||
|
import { downloadConversation } from '$lib/stores/chat.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -101,6 +102,15 @@
|
|||||||
onclick: handleEdit,
|
onclick: handleEdit,
|
||||||
shortcut: ['shift', 'cmd', 'e']
|
shortcut: ['shift', 'cmd', 'e']
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Download,
|
||||||
|
label: 'Export',
|
||||||
|
onclick: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadConversation(conversation.id);
|
||||||
|
},
|
||||||
|
shortcut: ['shift', 'cmd', 's']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/u
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { extractPartialThinking } from '$lib/utils/thinking';
|
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
|
* 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
|
* Deletes a conversation and all its messages
|
||||||
* @param convId - The conversation ID to delete
|
* @param convId - The conversation ID to delete
|
||||||
@@ -1427,6 +1589,9 @@ export const isInitialized = () => chatStore.isInitialized;
|
|||||||
export const maxContextError = () => chatStore.maxContextError;
|
export const maxContextError = () => chatStore.maxContextError;
|
||||||
|
|
||||||
export const createConversation = chatStore.createConversation.bind(chatStore);
|
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 deleteConversation = chatStore.deleteConversation.bind(chatStore);
|
||||||
export const sendMessage = chatStore.sendMessage.bind(chatStore);
|
export const sendMessage = chatStore.sendMessage.bind(chatStore);
|
||||||
export const gracefulStop = chatStore.gracefulStop.bind(chatStore);
|
export const gracefulStop = chatStore.gracefulStop.bind(chatStore);
|
||||||
|
|||||||
@@ -346,4 +346,39 @@ export class DatabaseStore {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db.messages.update(id, updates);
|
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;
|
timings?: ChatMessageTimings;
|
||||||
model?: string;
|
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