char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Stores split refactor (#27240)521a64cd0

char/aliceai-t5-moe
8.9 KiB263 linesraw
1/**
2 * ConversationTransferService - Stateless conversation import/export layer
3 *
4 * Owns the session file format (one JSONL record per line: a SESSION header
5 * followed by MESSAGE records), ZIP archiving and browser downloads.
6 * DB access and store refreshes stay in conversationsStore.
7 */
8
9import { EXPORT_CONV, NEWLINE, ZIP_MAGIC } from '$lib/constants';
10import {
11	FileExtensionText,
12	MimeTypeApplication,
13	MimeTypeText,
14	SessionRecordType
15} from '$lib/enums';
16import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
17
18export class ConversationTransferService {
19	/**
20	 * Triggers a browser download of the provided exported conversation data
21	 * @param data - The exported conversation payload (a single conversation with its messages)
22	 * @param filename - Filename; if omitted, a deterministic name is generated
23	 */
24	static downloadConversationFile(data: ExportedConversation, filename?: string): void {
25		const { conv: conversation, messages: msgs } = data;
26
27		if (!conversation) {
28			console.error('Invalid data: missing conversation');
29
30			return;
31		}
32
33		const downloadFilename =
34			filename ?? ConversationTransferService.generateConversationFilename(conversation, msgs);
35		const jsonl = ConversationTransferService.serializeSessionToJsonl(data);
36		const blob = new Blob([jsonl], { type: MimeTypeText.JSONL });
37
38		ConversationTransferService.triggerDownload(blob, downloadFilename);
39	}
40
41	/**
42	 * Triggers a browser download of multiple conversations as a `.zip`, one
43	 * `.jsonl` file per conversation.
44	 * @param data - The conversations to export
45	 */
46	static downloadConversationsArchive(data: ExportedConversation[]): void {
47		if (data.length === 0) {
48			console.error('Invalid data: no conversations to export');
49
50			return;
51		}
52
53		const usedNames = new Set<string>();
54		const files: Record<string, Uint8Array> = {};
55
56		for (const session of data) {
57			const baseName = ConversationTransferService.generateConversationFilename(
58				session.conv,
59				session.messages
60			);
61
62			// Disambiguate any duplicate filenames within the archive.
63			let entryName = baseName;
64			let suffix = 1;
65
66			while (usedNames.has(entryName)) {
67				entryName = baseName.replace(
68					new RegExp(`${FileExtensionText.JSONL}$`),
69					`_${suffix++}${FileExtensionText.JSONL}`
70				);
71			}
72			usedNames.add(entryName);
73
74			files[entryName] = strToU8(ConversationTransferService.serializeSessionToJsonl(session));
75		}
76
77		const archiveName = `${new Date().toISOString().split(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR)[0]}_conversations${FileExtensionText.ZIP}`;
78		const zipped = zipSync(files);
79		const blob = new Blob([zipped], { type: MimeTypeApplication.ZIP });
80
81		ConversationTransferService.triggerDownload(blob, archiveName);
82	}
83
84	/**
85	 * Generates a sanitized filename for a conversation export
86	 * @param conversation - The conversation metadata
87	 * @param msgs - Optional array of messages belonging to the conversation
88	 * @returns The generated filename string
89	 */
90	static generateConversationFilename(
91		conversation: { id?: string; name?: string },
92		msgs?: DatabaseMessage[]
93	): string {
94		const conversationName = (conversation.name ?? '').trim().toLowerCase();
95		const sanitizedName = conversationName
96			.replace(EXPORT_CONV.NON_ALPHANUMERIC_REGEX, EXPORT_CONV.NONALNUM_REPLACEMENT)
97			.replace(EXPORT_CONV.MULTIPLE_UNDERSCORE_REGEX, '_')
98			.substring(0, EXPORT_CONV.NAME_SUFFIX_MAX_LENGTH);
99		// If we have messages, use the timestamp of the newest message
100		const referenceDate = msgs?.length
101			? new Date(Math.max(...msgs.map((m) => m.timestamp)))
102			: new Date();
103		const iso = referenceDate.toISOString().slice(0, EXPORT_CONV.ISO_TIMESTAMP_SLICE);
104		const formattedDate = iso
105			.replace(EXPORT_CONV.ISO_DATE_TIME_SEPARATOR, EXPORT_CONV.ISO_DATE_TIME_SEPARATOR_REPLACEMENT)
106			.replaceAll(EXPORT_CONV.ISO_TIME_SEPARATOR, EXPORT_CONV.ISO_TIME_SEPARATOR_REPLACEMENT);
107		const trimmedConvId = conversation.id?.slice(0, EXPORT_CONV.ID_TRIM_LENGTH) ?? '';
108
109		return `${formattedDate}_conv_${trimmedConvId}_${sanitizedName}${FileExtensionText.JSONL}`;
110	}
111
112	/**
113	 * Parses an import file into conversations, accepting the current JSONL and
114	 * ZIP formats as well as the legacy JSON format. The format comes from the
115	 * contents, so an import works whatever the file is named.
116	 * @param file - The user-selected file
117	 * @returns The parsed conversations with their messages
118	 */
119	static async parseImportFile(file: File): Promise<ExportedConversation[]> {
120		const bytes = new Uint8Array(await file.arrayBuffer());
121
122		if (ZIP_MAGIC.every((byte, index) => bytes[index] === byte)) {
123			const entries = unzipSync(bytes);
124			const sessions: ExportedConversation[] = [];
125
126			for (const [entryName, entryBytes] of Object.entries(entries)) {
127				if (!entryName.toLowerCase().endsWith(FileExtensionText.JSONL)) continue;
128
129				sessions.push(...ConversationTransferService.parseSessionsJsonl(strFromU8(entryBytes)));
130			}
131
132			return sessions;
133		}
134
135		const text = strFromU8(bytes);
136
137		if (ConversationTransferService.isSessionsJsonl(text)) {
138			return ConversationTransferService.parseSessionsJsonl(text);
139		}
140
141		// Legacy JSON format: an array of conversations or a single conversation object.
142		const parsed = JSON.parse(text);
143
144		if (Array.isArray(parsed)) {
145			return parsed;
146		}
147
148		if (parsed && typeof parsed === 'object' && 'conv' in parsed && 'messages' in parsed) {
149			return [parsed];
150		}
151
152		throw new Error(
153			'Invalid file format: expected array of conversations or single conversation object'
154		);
155	}
156
157	/**
158	 * Parses the JSONL session format produced by {@link serializeSessionToJsonl}.
159	 * A `SessionRecordType.SESSION` line starts a new session; following
160	 * `SessionRecordType.MESSAGE` lines are appended to it. Supports multiple
161	 * sessions in a single file.
162	 * @param text - The JSONL file contents
163	 * @returns The parsed conversations with their messages
164	 */
165	static parseSessionsJsonl(text: string): ExportedConversation[] {
166		const sessions: ExportedConversation[] = [];
167
168		let current: ExportedConversation | null = null;
169
170		for (const line of text.split(NEWLINE)) {
171			const trimmed = line.trim();
172
173			if (!trimmed) continue;
174
175			const record = JSON.parse(trimmed);
176
177			if (record.type === SessionRecordType.SESSION) {
178				// Drop the discriminator and harness marker; the rest is the conversation.
179				const conv = { ...record };
180
181				delete conv.type;
182				delete conv.harness;
183				current = { conv: conv as DatabaseConversation, messages: [] };
184				sessions.push(current);
185			} else if (record.type === SessionRecordType.MESSAGE) {
186				if (!current) {
187					throw new Error('Invalid JSONL: message record before any session record');
188				}
189
190				const message = record.message as DatabaseMessage;
191
192				// `toolCalls` is parsed to an array on export; the DB stores it as a string.
193				if (message.toolCalls !== undefined && typeof message.toolCalls !== 'string') {
194					message.toolCalls = JSON.stringify(message.toolCalls);
195				}
196
197				current.messages.push(message);
198			}
199			// Ignore unknown record types for forward compatibility.
200		}
201
202		return sessions;
203	}
204
205	/**
206	 * Serializes a session (a conversation with its messages) as JSONL.
207	 * The first line is the session header (a `SessionRecordType.SESSION` record
208	 * carrying the conversation properties); each subsequent line is a single message.
209	 * @param data - The exported conversation payload
210	 * @returns The JSONL string (one record per line)
211	 */
212	static serializeSessionToJsonl(data: ExportedConversation): string {
213		const { conv, messages } = data;
214		const sessionLine = JSON.stringify({
215			harness: EXPORT_CONV.HARNESS,
216			type: SessionRecordType.SESSION,
217			...conv
218		});
219		const messageLines = messages.map((message: DatabaseMessage) => {
220			// `toolCalls` is stored as a JSON string; drop it when empty, otherwise parse it.
221			const { toolCalls, ...rest } = message;
222			const normalized = toolCalls ? { ...rest, toolCalls: JSON.parse(toolCalls) } : rest;
223
224			return JSON.stringify({ message: normalized, type: SessionRecordType.MESSAGE });
225		});
226
227		return [sessionLine, ...messageLines].join(NEWLINE);
228	}
229
230	/**
231	 * Reports whether the text is the JSONL session format, whose first non-empty
232	 * line is a `SessionRecordType.SESSION` record. A legacy JSON export starts
233	 * with an array or an object that has no such discriminator.
234	 * @param text - The file contents
235	 */
236	private static isSessionsJsonl(text: string): boolean {
237		const trimmed = text.trimStart();
238		const lineEnd = trimmed.indexOf(NEWLINE);
239		const firstLine = lineEnd === -1 ? trimmed : trimmed.slice(0, lineEnd);
240
241		try {
242			return JSON.parse(firstLine).type === SessionRecordType.SESSION;
243		} catch {
244			// Not a standalone JSON record, so not the JSONL format.
245			return false;
246		}
247	}
248
249	/**
250	 * Triggers a browser download of a blob under the given filename.
251	 */
252	private static triggerDownload(blob: Blob, filename: string): void {
253		const url = URL.createObjectURL(blob);
254		const a = document.createElement('a');
255
256		a.href = url;
257		a.download = filename;
258		document.body.appendChild(a);
259		a.click();
260		document.body.removeChild(a);
261		URL.revokeObjectURL(url);
262	}
263}