Skip to content

Commit d8c331c

Browse files
authored
webui: use date in more human readable exported filename (ggml-org#19939)
* webui: use date in exported filename Move conversation naming and export to utils update index.html.gz * webui: move literals to message export constants file * webui: move export naming and download back to the conversation store * chore: update webui build output * webui: add comments to some constants * chore: update webui build output
1 parent 46dba9f commit d8c331c

6 files changed

Lines changed: 96 additions & 73 deletions

File tree

tools/server/public/index.html.gz

-43 Bytes
Binary file not shown.

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Button } from '$lib/components/ui/button';
44
import { DialogConversationSelection, DialogConfirmation } from '$lib/components/app';
55
import { createMessageCountMap } from '$lib/utils';
6+
import { ISO_DATE_TIME_SEPARATOR } from '$lib/constants';
67
import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
78
import { toast } from 'svelte-sonner';
89
@@ -55,18 +56,10 @@
5556
})
5657
);
5758
58-
const blob = new Blob([JSON.stringify(allData, null, 2)], {
59-
type: 'application/json'
60-
});
61-
const url = URL.createObjectURL(blob);
62-
const a = document.createElement('a');
63-
64-
a.href = url;
65-
a.download = `conversations_${new Date().toISOString().split('T')[0]}.json`;
66-
document.body.appendChild(a);
67-
a.click();
68-
document.body.removeChild(a);
69-
URL.revokeObjectURL(url);
59+
conversationsStore.downloadConversationFile(
60+
allData,
61+
`${new Date().toISOString().split(ISO_DATE_TIME_SEPARATOR)[0]}_conversations.json`
62+
);
7063
7164
exportedConversations = selectedConversations;
7265
showExportSummary = true;

tools/server/webui/src/lib/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export * from './max-bundle-size';
2424
export * from './mcp';
2525
export * from './mcp-form';
2626
export * from './mcp-resource';
27+
export * from './message-export';
2728
export * from './model-id';
2829
export * from './precision';
2930
export * from './processing-info';
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Conversation filename constants
2+
3+
// Length of the trimmed conversation ID in the filename
4+
export const EXPORT_CONV_ID_TRIM_LENGTH = 8;
5+
// Maximum length of the sanitized conversation name snippet
6+
export const EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH = 20;
7+
// Characters to keep in the ISO timestamp. 19 keeps 2026-01-01T00:00:00
8+
export const ISO_TIMESTAMP_SLICE_LENGTH = 19;
9+
10+
// Replacements for making the conversation title filename-friendly
11+
export const NON_ALPHANUMERIC_REGEX = /[^a-z0-9]/gi;
12+
export const EXPORT_CONV_NONALNUM_REPLACEMENT = '_';
13+
export const MULTIPLE_UNDERSCORE_REGEX = /_+/g;
14+
15+
// Replacements to the ISO date for use in the export filename
16+
export const ISO_DATE_TIME_SEPARATOR = 'T';
17+
export const ISO_DATE_TIME_SEPARATOR_REPLACEMENT = '_';
18+
19+
export const ISO_TIME_SEPARATOR = ':';
20+
export const ISO_TIME_SEPARATOR_REPLACEMENT = '-';

tools/server/webui/src/lib/stores/conversations.svelte.ts

Lines changed: 69 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ import { config } from '$lib/stores/settings.svelte';
2626
import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
2727
import type { McpServerOverride } from '$lib/types/database';
2828
import { MessageRole } from '$lib/enums';
29+
import {
30+
ISO_DATE_TIME_SEPARATOR,
31+
ISO_DATE_TIME_SEPARATOR_REPLACEMENT,
32+
ISO_TIMESTAMP_SLICE_LENGTH,
33+
EXPORT_CONV_ID_TRIM_LENGTH,
34+
EXPORT_CONV_NONALNUM_REPLACEMENT,
35+
EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH,
36+
ISO_TIME_SEPARATOR,
37+
ISO_TIME_SEPARATOR_REPLACEMENT,
38+
NON_ALPHANUMERIC_REGEX,
39+
MULTIPLE_UNDERSCORE_REGEX
40+
} from '$lib/constants';
2941

3042
class ConversationsStore {
3143
/**
@@ -620,56 +632,83 @@ class ConversationsStore {
620632
*/
621633

622634
/**
623-
* Downloads a conversation as JSON file.
624-
* @param convId - The conversation ID to download
635+
* Generates a sanitized filename for a conversation export
636+
* @param conversation - The conversation metadata
637+
* @param msgs - Optional array of messages belonging to the conversation
638+
* @returns The generated filename string
625639
*/
626-
async downloadConversation(convId: string): Promise<void> {
627-
let conversation: DatabaseConversation | null;
628-
let messages: DatabaseMessage[];
640+
generateConversationFilename(
641+
conversation: { id?: string; name?: string },
642+
msgs?: DatabaseMessage[]
643+
): string {
644+
const conversationName = (conversation.name ?? '').trim().toLowerCase();
629645

630-
if (this.activeConversation?.id === convId) {
631-
conversation = this.activeConversation;
632-
messages = this.activeMessages;
633-
} else {
634-
conversation = await DatabaseService.getConversation(convId);
635-
if (!conversation) return;
636-
messages = await DatabaseService.getConversationMessages(convId);
637-
}
646+
const sanitizedName = conversationName
647+
.replace(NON_ALPHANUMERIC_REGEX, EXPORT_CONV_NONALNUM_REPLACEMENT)
648+
.replace(MULTIPLE_UNDERSCORE_REGEX, '_')
649+
.substring(0, EXPORT_CONV_NAME_SUFFIX_MAX_LENGTH);
650+
651+
// If we have messages, use the timestamp of the newest message
652+
const referenceDate = msgs?.length
653+
? new Date(Math.max(...msgs.map((m) => m.timestamp)))
654+
: new Date();
638655

639-
this.triggerDownload({ conv: conversation, messages });
656+
const iso = referenceDate.toISOString().slice(0, ISO_TIMESTAMP_SLICE_LENGTH);
657+
const formattedDate = iso
658+
.replace(ISO_DATE_TIME_SEPARATOR, ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
659+
.replaceAll(ISO_TIME_SEPARATOR, ISO_TIME_SEPARATOR_REPLACEMENT);
660+
const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV_ID_TRIM_LENGTH) ?? '';
661+
return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}.json`;
640662
}
641663

642664
/**
643-
* Exports all conversations with their messages as a JSON file
644-
* @returns The list of exported conversations
665+
* Triggers a browser download of the provided exported conversation data
666+
* @param data - The exported conversation payload (either a single conversation or array of them)
667+
* @param filename - Filename; if omitted, a deterministic name is generated
645668
*/
646-
async exportAllConversations(): Promise<DatabaseConversation[]> {
647-
const allConversations = await DatabaseService.getAllConversations();
669+
downloadConversationFile(data: ExportedConversations, filename?: string): void {
670+
// Choose the first conversation or message
671+
const conversation =
672+
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
673+
const msgs =
674+
'messages' in data ? data.messages : Array.isArray(data) ? data[0]?.messages : undefined;
648675

649-
if (allConversations.length === 0) {
650-
throw new Error('No conversations to export');
676+
if (!conversation) {
677+
console.error('Invalid data: missing conversation');
678+
return;
651679
}
652680

653-
const allData = await Promise.all(
654-
allConversations.map(async (conv) => {
655-
const messages = await DatabaseService.getConversationMessages(conv.id);
656-
return { conv, messages };
657-
})
658-
);
681+
const downloadFilename = filename ?? this.generateConversationFilename(conversation, msgs);
659682

660-
const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
683+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
661684
const url = URL.createObjectURL(blob);
662685
const a = document.createElement('a');
663686
a.href = url;
664-
a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
687+
a.download = downloadFilename;
665688
document.body.appendChild(a);
666689
a.click();
667690
document.body.removeChild(a);
668691
URL.revokeObjectURL(url);
692+
}
693+
694+
/**
695+
* Downloads a conversation as JSON file.
696+
* @param convId - The conversation ID to download
697+
*/
698+
async downloadConversation(convId: string): Promise<void> {
699+
let conversation: DatabaseConversation | null;
700+
let messages: DatabaseMessage[];
669701

670-
toast.success(`All conversations (${allConversations.length}) prepared for download`);
702+
if (this.activeConversation?.id === convId) {
703+
conversation = this.activeConversation;
704+
messages = this.activeMessages;
705+
} else {
706+
conversation = await DatabaseService.getConversation(convId);
707+
if (!conversation) return;
708+
messages = await DatabaseService.getConversationMessages(convId);
709+
}
671710

672-
return allConversations;
711+
this.downloadConversationFile({ conv: conversation, messages });
673712
}
674713

675714
/**
@@ -743,37 +782,6 @@ class ConversationsStore {
743782
await this.loadConversations();
744783
return result;
745784
}
746-
747-
/**
748-
* Triggers file download in browser
749-
*/
750-
private triggerDownload(data: ExportedConversations, filename?: string): void {
751-
const conversation =
752-
'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
753-
754-
if (!conversation) {
755-
console.error('Invalid data: missing conversation');
756-
return;
757-
}
758-
759-
const conversationName = conversation.name?.trim() || '';
760-
const truncatedSuffix = conversationName
761-
.toLowerCase()
762-
.replace(/[^a-z0-9]/gi, '_')
763-
.replace(/_+/g, '_')
764-
.substring(0, 20);
765-
const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
766-
767-
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
768-
const url = URL.createObjectURL(blob);
769-
const a = document.createElement('a');
770-
a.href = url;
771-
a.download = downloadFilename;
772-
document.body.appendChild(a);
773-
a.click();
774-
document.body.removeChild(a);
775-
URL.revokeObjectURL(url);
776-
}
777785
}
778786

779787
export const conversationsStore = new ConversationsStore();

tools/server/webui/src/lib/utils/conversation-utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* Utility functions for conversation data manipulation
33
*/
4+
import type { DatabaseMessage } from '$lib/types';
45

56
/**
67
* Creates a map of conversation IDs to their message counts from exported conversation data

0 commit comments

Comments
 (0)