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