diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index 493058aa01..98e2e49431 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte index 68839438f6..537c839f58 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte @@ -3,6 +3,7 @@ import { Button } from '$lib/components/ui/button'; import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app'; import { createMessageCountMap } from '$lib/utils'; + import { ISO_DATE_TIME_SEPARATOR } from '$lib/constants'; import { conversationsStore, conversations } from '$lib/stores/conversations.svelte'; import { toast } from 'svelte-sonner'; @@ -55,18 +56,10 @@ }) ); - 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 = `conversations_${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + conversationsStore.downloadConversationFile( + allData, + `${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json` + ); exportedConversations = selectedConversations; showExportSummary = true; diff --git a/tools/server/webui/src/lib/constants/index.ts b/tools/server/webui/src/lib/constants/index.ts index 41c117df54..f3593c03b1 100644 --- a/tools/server/webui/src/lib/constants/index.ts +++ b/tools/server/webui/src/lib/constants/index.ts @@ -24,6 +24,7 @@ export * from './max-bundle-size'; export * from './mcp'; export * from './mcp-form'; export * from './mcp-resource'; +export * from './message-export'; export * from './model-id'; export * from './precision'; export * from './processing-info'; diff --git a/tools/server/webui/src/lib/constants/message-export.ts b/tools/server/webui/src/lib/constants/message-export.ts new file mode 100644 index 0000000000..79fa36f914 --- /dev/null +++ b/tools/server/webui/src/lib/constants/message-export.ts @@ -0,0 +1,20 @@ +// Conversation filename constants + +// Length of the trimmed conversation ID in the filename +export const EXPORT_CONV_ID_TRIM_LENGTH = 8; +// Maximum length of the sanitized conversation name snippet +export const EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH = 20; +// Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00 +export const ISO_TIMESTAMP_SLICE_LENGTH = 19; + +// Replacements for making the conversation title filename-friendly +export const NON_ALPHANUMERIC_REGEX = /[^a-z0-9]/gi; +export const EXPORT_CONV_NONALNUM_REPLACEMENT = '_'; +export const MULTIPLE_UNDERSCORE_REGEX = /_+/g; + +// Replacements to the ISO date for use in the export filename +export const ISO_DATE_TIME_SEPARATOR = 'T'; +export const ISO_DATE_TIME_SEPARATOR_REPLACEMENT = '_'; + +export const ISO_TIME_SEPARATOR = ':'; +export const ISO_TIME_SEPARATOR_REPLACEMENT = '-'; diff --git a/tools/server/webui/src/lib/stores/conversations.svelte.ts b/tools/server/webui/src/lib/stores/conversations.svelte.ts index ec1daa90d9..39f206479f 100644 --- a/tools/server/webui/src/lib/stores/conversations.svelte.ts +++ b/tools/server/webui/src/lib/stores/conversations.svelte.ts @@ -26,6 +26,18 @@ import { config } from '$lib/stores/settings.svelte'; import { filterByLeafNodeId, findLeafNode } from '$lib/utils'; import type { McpServerOverride } from '$lib/types/database'; import { MessageRole } from '$lib/enums'; +import { + ISO_DATE_TIME_SEPARATOR, + ISO_DATE_TIME_SEPARATOR_REPLACEMENT, + ISO_TIMESTAMP_SLICE_LENGTH, + EXPORT_CONV_ID_TRIM_LENGTH, + EXPORT_CONV_NONALNUM_REPLACEMENT, + EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH, + ISO_TIME_SEPARATOR, + ISO_TIME_SEPARATOR_REPLACEMENT, + NON_ALPHANUMERIC_REGEX, + MULTIPLE_UNDERSCORE_REGEX +} from '$lib/constants'; class ConversationsStore { /** @@ -619,6 +631,66 @@ class ConversationsStore { * */ + /** + * Generates a sanitized filename for a conversation export + * @param conversation - The conversation metadata + * @param msgs - Optional array of messages belonging to the conversation + * @returns The generated filename string + */ + generateConversationFilename( + conversation: { id?: string; name?: string }, + msgs?: DatabaseMessage[] + ): string { + const conversationName = (conversation.name ?? '').trim().toLowerCase(); + + const sanitizedName = conversationName + .replace(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT) + .replace(MULTIPLE_UNDERSCORE_REGEX, '_') + .substring(0, EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH); + + // If we have messages, use the timestamp of the newest message + const referenceDate = msgs?.length + ? new Date(Math.max(...msgs.map((m) => m.timestamp))) + : new Date(); + + const iso = referenceDate.toISOString().slice(0, ISO_TIMESTAMP_SLICE_LENGTH); + const formattedDate = iso + .replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT) + .replaceAll(ISO_TIME_SEPARATOR, ISO_TIME_SEPARATOR_REPLACEMENT); + const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV_ID_TRIM_LENGTH) ?? ''; + return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}.json`; + } + + /** + * Triggers a browser download of the provided exported conversation data + * @param data - The exported conversation payload (either a single conversation or array of them) + * @param filename - Filename; if omitted, a deterministic name is generated + */ + downloadConversationFile(data: ExportedConversations, filename?: string): void { + // Choose the first conversation or message + const conversation = + 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined; + const msgs = + 'messages' in data ? data.messages : Array.isArray(data) ? data[0]?.messages : undefined; + + if (!conversation) { + console.error('Invalid data: missing conversation'); + return; + } + + const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs); + + const blob = new Blob([JSON.stringify(data, null, 2)], { 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); + } + /** * Downloads a conversation as JSON file. * @param convId - The conversation ID to download @@ -636,40 +708,7 @@ class ConversationsStore { messages = await DatabaseService.getConversationMessages(convId); } - this.triggerDownload({ conv: conversation, messages }); - } - - /** - * Exports all conversations with their messages as a JSON file - * @returns The list of exported conversations - */ - async exportAllConversations(): Promise { - const allConversations = await DatabaseService.getAllConversations(); - - if (allConversations.length === 0) { - throw new Error('No conversations to export'); - } - - const allData = await Promise.all( - allConversations.map(async (conv) => { - const messages = await DatabaseService.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`); - - return allConversations; + this.downloadConversationFile({ conv: conversation, messages }); } /** @@ -743,37 +782,6 @@ class ConversationsStore { await this.loadConversations(); return result; } - - /** - * Triggers file download in browser - */ - 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?.trim() || ''; - const truncatedSuffix = conversationName - .toLowerCase() - .replace(/[^a-z0-9]/gi, '_') - .replace(/_+/g, '_') - .substring(0, 20); - const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`; - - const blob = new Blob([JSON.stringify(data, null, 2)], { 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); - } } export const conversationsStore = new ConversationsStore(); diff --git a/tools/server/webui/src/lib/utils/conversation-utils.ts b/tools/server/webui/src/lib/utils/conversation-utils.ts index aee244a080..2c3d838999 100644 --- a/tools/server/webui/src/lib/utils/conversation-utils.ts +++ b/tools/server/webui/src/lib/utils/conversation-utils.ts @@ -1,6 +1,7 @@ /** * Utility functions for conversation data manipulation */ +import type { DatabaseMessage } from '$lib/types'; /** * Creates a map of conversation IDs to their message counts from exported conversation data