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
20.3 KiB705 linesraw
1/**
2 * DatabaseService - IndexedDB persistence for conversations and messages
3 *
4 * Thin Dexie layer over the conversations/messages tables: CRUD, tree
5 * navigation (descendants, reparenting) and cascading deletes. No reactive
6 * state; consumed by conversationsStore and the chat flows.
7 */
8
9import { IDXDB_STORES, IDXDB_TABLES, STORAGE_APP_NAME } from '$lib/constants';
10import { MessageRole } from '$lib/enums';
11import type { McpServerOverride } from '$lib/types/database';
12import type { ExportedConversation } from '$lib/types/database';
13import { filterByLeafNodeId, findDescendantMessages, uuid } from '$lib/utils';
14import Dexie, { type EntityTable } from 'dexie';
15
16class LlamaUiDatabase extends Dexie {
17	[IDXDB_TABLES.conversations]!: EntityTable<DatabaseConversation, string>;
18	[IDXDB_TABLES.messages]!: EntityTable<DatabaseMessage, string>;
19
20	constructor() {
21		super(STORAGE_APP_NAME);
22
23		this.version(1).stores(IDXDB_STORES);
24	}
25}
26
27const db = new LlamaUiDatabase();
28
29export class DatabaseService {
30	/**
31	 * Deletes multiple conversations in a single transaction. Each deleted
32	 * conversation has its direct children reparented to the nearest surviving
33	 * ancestor (or promoted to top-level). Children also in `ids` are dropped
34	 * entirely rather than reparented.
35	 *
36	 * @param ids - Conversation IDs to delete
37	 */
38	static async bulkDeleteConversations(ids: string[]): Promise<void> {
39		const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
40
41		if (cleanIds.length === 0) return;
42
43		const idSet = new Set(cleanIds);
44
45		await db.transaction(
46			'rw',
47			[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
48			async () => {
49				// Pre-load each to-delete conversation so the per-id reparent
50				// walk-up doesn't ping-pong the same ancestry chain.
51				const prefetched = new Map<string, DatabaseConversation>();
52
53				let frontier = [...cleanIds];
54
55				const requested = new Set<string>(frontier);
56
57				while (frontier.length > 0) {
58					const fetched = await db[IDXDB_TABLES.conversations].bulkGet(frontier);
59
60					frontier = [];
61					for (let i = 0; i < fetched.length; i++) {
62						const conv = fetched[i];
63
64						if (!conv || !conv.id) continue;
65
66						prefetched.set(conv.id, conv);
67						const ancestor = conv.forkedFromConversationId;
68
69						if (ancestor && !prefetched.has(ancestor) && !requested.has(ancestor)) {
70							frontier.push(ancestor);
71							requested.add(ancestor);
72						}
73					}
74				}
75
76				for (const id of cleanIds) {
77					await this.reparentDirectChildren(id, idSet, prefetched);
78				}
79
80				await db[IDXDB_TABLES.conversations].bulkDelete(cleanIds);
81				await db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).delete();
82			}
83		);
84	}
85
86	/**
87	 * Toggles the pinned status of each conversation in `ids` inside a single
88	 * transaction. Treats `pinned === undefined` as `false`, matching the
89	 * semantics of {@link toggleConversationPin} where `!undefined` evaluates
90	 * to `true`. Returns the resulting pinned state for every id that was
91	 * updated; missing ids are omitted from the map.
92	 *
93	 * @param ids - Conversation IDs to toggle
94	 * @returns Map of id -> new pinned state
95	 */
96	static async bulkToggleConversationPins(ids: string[]): Promise<Map<string, boolean>> {
97		const cleanIds = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
98		const result = new Map<string, boolean>();
99
100		if (cleanIds.length === 0) return result;
101
102		await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
103			const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
104			const updates: DatabaseConversation[] = [];
105
106			for (let i = 0; i < cleanIds.length; i++) {
107				const conv = convs[i];
108
109				if (!conv) continue;
110
111				const newPinned = !conv.pinned;
112
113				updates.push({ ...conv, pinned: newPinned });
114				result.set(cleanIds[i], newPinned);
115			}
116
117			if (updates.length === 0) return;
118
119			await db[IDXDB_TABLES.conversations].bulkPut(updates);
120		});
121
122		return result;
123	}
124
125	/**
126	 * Creates a new conversation.
127	 *
128	 * @param name - Name of the conversation
129	 * @param fields - Optional extra fields (e.g. reasoningEffort)
130	 * @returns The created conversation
131	 */
132	static async createConversation(
133		name: string,
134		fields?: Partial<Omit<DatabaseConversation, 'id' | 'name' | 'lastModified'>>
135	): Promise<DatabaseConversation> {
136		const conversation: DatabaseConversation = {
137			currNode: '',
138			id: uuid(),
139			lastModified: Date.now(),
140			name,
141			...fields
142		};
143
144		await db[IDXDB_TABLES.conversations].add(conversation);
145
146		return conversation;
147	}
148
149	/**
150	 * Creates a new message branch by adding a message and updating parent/child relationships.
151	 * Also updates the conversation's currNode to point to the new message.
152	 *
153	 * @param message - Message to add (without id)
154	 * @param parentId - Parent message ID to attach to
155	 * @returns The created message
156	 */
157	static async createMessageBranch(
158		message: Omit<DatabaseMessage, 'id'>,
159		parentId: string | null
160	): Promise<DatabaseMessage> {
161		return await db.transaction(
162			'rw',
163			[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
164			async () => {
165				// Handle null parent (root message case)
166				if (parentId !== null) {
167					const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
168
169					if (!parentMessage) {
170						throw new Error(`Parent message ${parentId} not found`);
171					}
172				}
173
174				const newMessage: DatabaseMessage = {
175					...message,
176					children: [],
177					id: uuid(),
178					parent: parentId,
179					toolCalls: message.toolCalls ?? ''
180				};
181
182				await db[IDXDB_TABLES.messages].add(newMessage);
183
184				// Update parent's children array if parent exists
185				if (parentId !== null) {
186					await this.addChildToParent(parentId, newMessage.id);
187				}
188
189				await this.updateConversation(message.convId, {
190					currNode: newMessage.id
191				});
192
193				return newMessage;
194			}
195		);
196	}
197
198	/**
199	 * Creates a root message for a new conversation.
200	 * Root messages are not displayed but serve as the tree root for branching.
201	 *
202	 * @param convId - Conversation ID
203	 * @returns The created root message
204	 */
205	static async createRootMessage(convId: string): Promise<string> {
206		const rootMessage: DatabaseMessage = {
207			children: [],
208			content: '',
209			convId,
210			id: uuid(),
211			parent: null,
212			role: MessageRole.SYSTEM,
213			timestamp: Date.now(),
214			toolCalls: '',
215			type: 'root'
216		};
217
218		await db[IDXDB_TABLES.messages].add(rootMessage);
219
220		return rootMessage.id;
221	}
222
223	/**
224	 * Creates a system prompt message for a conversation.
225	 *
226	 * @param convId - Conversation ID
227	 * @param systemPrompt - The system prompt content (must be non-empty)
228	 * @param parentId - Parent message ID (typically the root message)
229	 * @returns The created system message
230	 * @throws Error if systemPrompt is empty or the parent message does not exist
231	 */
232	static async createSystemMessage(
233		convId: string,
234		systemPrompt: string,
235		parentId: string
236	): Promise<DatabaseMessage> {
237		const trimmedPrompt = systemPrompt.trim();
238
239		if (!trimmedPrompt) {
240			throw new Error('Cannot create system message with empty content');
241		}
242
243		return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
244			const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
245
246			if (!parentMessage) {
247				throw new Error(`Parent message ${parentId} not found`);
248			}
249
250			const systemMessage: DatabaseMessage = {
251				children: [],
252				content: trimmedPrompt,
253				convId,
254				id: uuid(),
255				parent: parentId,
256				role: MessageRole.SYSTEM,
257				timestamp: Date.now(),
258				type: MessageRole.SYSTEM
259			};
260
261			await db[IDXDB_TABLES.messages].add(systemMessage);
262			await this.addChildToParent(parentId, systemMessage.id);
263
264			return systemMessage;
265		});
266	}
267
268	/**
269	 * Deletes a conversation and all its messages.
270	 *
271	 * @param id - Conversation ID
272	 */
273	static async deleteConversation(
274		id: string,
275		options?: { deleteWithForks?: boolean }
276	): Promise<void> {
277		await db.transaction(
278			'rw',
279			[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
280			async () => {
281				if (options?.deleteWithForks) {
282					// Recursively collect all descendant IDs
283					const idsToDelete: string[] = [];
284					const queue = [id];
285
286					while (queue.length > 0) {
287						const parentId = queue.pop()!;
288						const children = await db[IDXDB_TABLES.conversations]
289							.filter((c) => c.forkedFromConversationId === parentId)
290							.toArray();
291
292						for (const child of children) {
293							idsToDelete.push(child.id);
294							queue.push(child.id);
295						}
296					}
297
298					for (const forkId of idsToDelete) {
299						await db[IDXDB_TABLES.conversations].delete(forkId);
300						await db[IDXDB_TABLES.messages].where('convId').equals(forkId).delete();
301					}
302				} else {
303					await this.reparentDirectChildren(id);
304				}
305
306				await db[IDXDB_TABLES.conversations].delete(id);
307				await db[IDXDB_TABLES.messages].where('convId').equals(id).delete();
308			}
309		);
310	}
311
312	/**
313	 * Deletes a message and removes it from its parent's children array.
314	 *
315	 * @param messageId - ID of the message to delete
316	 */
317	static async deleteMessage(messageId: string): Promise<void> {
318		await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
319			const message = await db[IDXDB_TABLES.messages].get(messageId);
320
321			if (!message) return;
322
323			await this.removeChildFromParent(messageId);
324
325			await db[IDXDB_TABLES.messages].delete(messageId);
326		});
327	}
328
329	/**
330	 * Deletes a message and all its descendant messages (cascading deletion).
331	 * This removes the entire branch starting from the specified message.
332	 *
333	 * @param conversationId - ID of the conversation containing the message
334	 * @param messageId - ID of the root message to delete (along with all descendants)
335	 * @returns Array of all deleted message IDs
336	 */
337	static async deleteMessageCascading(
338		conversationId: string,
339		messageId: string
340	): Promise<string[]> {
341		return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
342			// Get all messages in the conversation to find descendants
343			const allMessages = await db[IDXDB_TABLES.messages]
344				.where('convId')
345				.equals(conversationId)
346				.toArray();
347			const descendants = findDescendantMessages(allMessages, messageId);
348			const allToDelete = [messageId, ...descendants];
349
350			await this.removeChildFromParent(messageId);
351
352			// Delete all messages in the branch
353			await db[IDXDB_TABLES.messages].bulkDelete(allToDelete);
354
355			return allToDelete;
356		});
357	}
358
359	/**
360	 * Forks a conversation at a specific message, creating a new conversation
361	 * containing all messages from the root up to (and including) the target message.
362	 *
363	 * @param sourceConvId - The source conversation ID
364	 * @param atMessageId - The message ID to fork at (the new conversation ends here)
365	 * @param options - Fork options (name and whether to include attachments)
366	 * @returns The newly created conversation
367	 */
368	static async forkConversation(
369		sourceConvId: string,
370		atMessageId: string,
371		options: { name: string; includeAttachments: boolean }
372	): Promise<DatabaseConversation> {
373		return await db.transaction(
374			'rw',
375			[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
376			async () => {
377				const sourceConv = await db[IDXDB_TABLES.conversations].get(sourceConvId);
378
379				if (!sourceConv) {
380					throw new Error(`Source conversation ${sourceConvId} not found`);
381				}
382
383				const allMessages = await db[IDXDB_TABLES.messages]
384					.where('convId')
385					.equals(sourceConvId)
386					.toArray();
387				const pathMessages = filterByLeafNodeId(
388					allMessages,
389					atMessageId,
390					true
391				) as DatabaseMessage[];
392
393				if (pathMessages.length === 0) {
394					throw new Error(`Could not resolve message path to ${atMessageId}`);
395				}
396
397				const idMap = new Map<string, string>();
398
399				for (const msg of pathMessages) {
400					idMap.set(msg.id, uuid());
401				}
402
403				const newConvId = uuid();
404				const clonedMessages: DatabaseMessage[] = pathMessages.map((msg) => {
405					const newId = idMap.get(msg.id)!;
406					const newParent = msg.parent ? (idMap.get(msg.parent) ?? null) : null;
407					const newChildren = msg.children
408						.filter((childId: string) => idMap.has(childId))
409						.map((childId: string) => idMap.get(childId)!);
410
411					return {
412						...msg,
413						children: newChildren,
414						convId: newConvId,
415						extra: options.includeAttachments ? msg.extra : undefined,
416						id: newId,
417						parent: newParent
418					};
419				});
420				const lastClonedMessage = clonedMessages[clonedMessages.length - 1];
421				const newConv: DatabaseConversation = {
422					currNode: lastClonedMessage.id,
423					cwd: sourceConv.cwd,
424					forkedFromConversationId: sourceConvId,
425					id: newConvId,
426					lastModified: Date.now(),
427					mcpServerOverrides: sourceConv.mcpServerOverrides
428						? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({
429								enabled: o.enabled,
430								serverId: o.serverId
431							}))
432						: undefined,
433					name: options.name
434				};
435
436				await db[IDXDB_TABLES.conversations].add(newConv);
437				await db[IDXDB_TABLES.messages].bulkAdd(clonedMessages);
438
439				return newConv;
440			}
441		);
442	}
443
444	/**
445	 * Gets all conversations, sorted by last modified time (newest first).
446	 *
447	 * @returns Array of conversations
448	 */
449	static async getAllConversations(): Promise<DatabaseConversation[]> {
450		return await db[IDXDB_TABLES.conversations].orderBy('lastModified').reverse().toArray();
451	}
452
453	/**
454	 * Gets a conversation by ID.
455	 *
456	 * @param id - Conversation ID
457	 * @returns The conversation if found, otherwise undefined
458	 */
459	static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
460		return await db[IDXDB_TABLES.conversations].get(id);
461	}
462
463	/**
464	 * Gets all messages in a conversation, sorted by timestamp (oldest first).
465	 *
466	 * @param convId - Conversation ID
467	 * @returns Array of messages in the conversation
468	 */
469	static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
470		return await db[IDXDB_TABLES.messages].where('convId').equals(convId).sortBy('timestamp');
471	}
472
473	/**
474	 * Loads multiple conversations with all of their messages in two bulk
475	 * reads. Missing conversations are silently omitted from the result.
476	 *
477	 * @param convIds - Conversation IDs to load
478	 * @returns Map of id -> { conv, messages }. Messages are sorted ascending by timestamp.
479	 */
480	static async getConversationsWithMessages(
481		convIds: string[]
482	): Promise<Map<string, ExportedConversation>> {
483		const result = new Map<string, ExportedConversation>();
484		const cleanIds = convIds.filter((id): id is string => typeof id === 'string' && id.length > 0);
485
486		if (cleanIds.length === 0) return result;
487
488		const [convs, allMessages] = await Promise.all([
489			db[IDXDB_TABLES.conversations].bulkGet(cleanIds),
490			db[IDXDB_TABLES.messages].where('convId').anyOf(cleanIds).toArray()
491		]);
492		const messagesByConv = new Map<string, DatabaseMessage[]>();
493
494		for (const msg of allMessages) {
495			const bucket = messagesByConv.get(msg.convId);
496
497			if (bucket) bucket.push(msg);
498			else messagesByConv.set(msg.convId, [msg]);
499		}
500
501		for (let i = 0; i < cleanIds.length; i++) {
502			const conv = convs[i];
503
504			if (!conv) continue;
505
506			const messages = (messagesByConv.get(conv.id) ?? []).sort(
507				(a, b) => a.timestamp - b.timestamp
508			);
509
510			result.set(conv.id, { conv, messages });
511		}
512
513		return result;
514	}
515
516	/**
517	 * Imports multiple conversations and their messages.
518	 * Skips conversations that already exist.
519	 *
520	 * @param data - Array of { conv, messages } objects
521	 * @returns The conversations written to the database and the ones skipped
522	 */
523	static async importConversations(
524		data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
525	): Promise<{ imported: DatabaseConversation[]; skipped: DatabaseConversation[] }> {
526		const imported: DatabaseConversation[] = [];
527		const skipped: DatabaseConversation[] = [];
528
529		return await db.transaction(
530			'rw',
531			[db[IDXDB_TABLES.conversations], db[IDXDB_TABLES.messages]],
532			async () => {
533				for (const item of data) {
534					const { conv, messages } = item;
535					const existing = await db[IDXDB_TABLES.conversations].get(conv.id);
536
537					if (existing) {
538						skipped.push(conv);
539
540						continue;
541					}
542
543					await db[IDXDB_TABLES.conversations].add(conv);
544					for (const msg of messages) {
545						await db[IDXDB_TABLES.messages].put(msg);
546					}
547
548					imported.push(conv);
549				}
550
551				return { imported, skipped };
552			}
553		);
554	}
555
556	/**
557	 * Toggles the pinned status of a conversation.
558	 *
559	 * @param id - Conversation ID
560	 * @returns The new pinned status
561	 */
562	static async toggleConversationPin(id: string): Promise<boolean> {
563		const conversation = await db[IDXDB_TABLES.conversations].get(id);
564
565		if (!conversation) {
566			throw new Error(`Conversation ${id} not found`);
567		}
568
569		const newPinnedState = !conversation.pinned;
570
571		await this.updateConversation(id, { pinned: newPinnedState });
572
573		return newPinnedState;
574	}
575
576	/**
577	 * Updates a conversation. `lastModified` is never stamped implicitly;
578	 * pass it in `updates` to bump the conversation in recency ordering.
579	 *
580	 * @param id - Conversation ID
581	 * @param updates - Partial updates to apply
582	 * @returns Promise that resolves when the conversation is updated
583	 */
584	static async updateConversation(
585		id: string,
586		updates: Partial<Omit<DatabaseConversation, 'id'>>
587	): Promise<void> {
588		await db[IDXDB_TABLES.conversations].update(id, updates);
589	}
590
591	/**
592	 * Updates the conversation's current node (active branch).
593	 * This determines which conversation path is currently being viewed.
594	 *
595	 * @param convId - Conversation ID
596	 * @param nodeId - Message ID to set as current node
597	 */
598	static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
599		await this.updateConversation(convId, {
600			currNode: nodeId
601		});
602	}
603
604	/**
605	 * Updates a message.
606	 *
607	 * @param id - Message ID
608	 * @param updates - Partial updates to apply
609	 * @returns Promise that resolves when the message is updated
610	 */
611	static async updateMessage(
612		id: string,
613		updates: Partial<Omit<DatabaseMessage, 'id'>>
614	): Promise<void> {
615		await db[IDXDB_TABLES.messages].update(id, updates);
616	}
617
618	/**
619	 * Appends a child id to a parent message's children array.
620	 */
621	private static async addChildToParent(parentId: string, childId: string): Promise<void> {
622		const parent = await db[IDXDB_TABLES.messages].get(parentId);
623
624		if (!parent) return;
625
626		await db[IDXDB_TABLES.messages].update(parentId, {
627			children: [...parent.children, childId]
628		});
629	}
630
631	/**
632	 * Removes a child id from its parent message's children array.
633	 */
634	private static async removeChildFromParent(messageId: string): Promise<void> {
635		const message = await db[IDXDB_TABLES.messages].get(messageId);
636
637		if (!message?.parent) return;
638
639		const parent = await db[IDXDB_TABLES.messages].get(message.parent);
640
641		if (!parent) return;
642
643		parent.children = parent.children.filter((childId: string) => childId !== messageId);
644		await db[IDXDB_TABLES.messages].put(parent);
645	}
646
647	/**
648	 * Reparents direct children of `parentId` to the nearest surviving
649	 * ancestor (or promotes them to top-level when the immediate parent was
650	 * top-level). Walking skips any ancestor listed in `excludeIds`, since
651	 * those will be deleted in the same batch — leaving a grandchild pointing
652	 * at an `excludeIds` entry would orphan it. Children whose own id is in
653	 * `excludeIds` are dropped from the updates (the bulk-delete pass will
654	 * remove them). `prefetched` may carry a pre-fetched ancestor map to
655	 * avoid repeat reads inside a bulk transaction.
656	 */
657	private static async reparentDirectChildren(
658		parentId: string,
659		excludeIds: ReadonlySet<string> = new Set(),
660		prefetched?: ReadonlyMap<string, DatabaseConversation>
661	): Promise<void> {
662		const conv = prefetched?.get(parentId) ?? (await db[IDXDB_TABLES.conversations].get(parentId));
663
664		if (!conv) return;
665
666		let newParent = conv.forkedFromConversationId;
667
668		const visited = new Set<string>([parentId]);
669
670		while (newParent && excludeIds.has(newParent)) {
671			if (visited.has(newParent)) {
672				newParent = undefined;
673
674				break;
675			}
676
677			visited.add(newParent);
678			const next =
679				prefetched?.get(newParent) ?? (await db[IDXDB_TABLES.conversations].get(newParent));
680
681			if (!next) {
682				newParent = undefined;
683
684				break;
685			}
686
687			newParent = next.forkedFromConversationId;
688		}
689
690		const directChildren = await db[IDXDB_TABLES.conversations]
691			.filter((c) => c.forkedFromConversationId === parentId)
692			.toArray();
693		const updates: DatabaseConversation[] = [];
694
695		for (const child of directChildren) {
696			if (excludeIds.has(child.id)) continue;
697
698			updates.push({ ...child, forkedFromConversationId: newParent });
699		}
700
701		if (updates.length === 0) return;
702
703		await db[IDXDB_TABLES.conversations].bulkPut(updates);
704	}
705}