Team Ai
Modelpublic

Felipe97/llama-cpp-compiled

sourceHugging Faceupdated 21d agoView on Hugging Face
0likes1.2kdownloads
database.service.ts706 linesDownload Raw Back to services
1/**2 * DatabaseService - IndexedDB persistence for conversations and messages3 *4 * Thin Dexie layer over the conversations/messages tables: CRUD, tree5 * navigation (descendants, reparenting) and cascading deletes. No reactive6 * 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 deleted32	 * conversation has its direct children reparented to the nearest surviving33	 * ancestor (or promoted to top-level). Children also in `ids` are dropped34	 * entirely rather than reparented.35	 *36	 * @param ids - Conversation IDs to delete37	 */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 reparent50				// 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 single88	 * transaction. Treats `pinned === undefined` as `false`, matching the89	 * semantics of {@link toggleConversationPin} where `!undefined` evaluates90	 * to `true`. Returns the resulting pinned state for every id that was91	 * updated; missing ids are omitted from the map.92	 *93	 * @param ids - Conversation IDs to toggle94	 * @returns Map of id -> new pinned state95	 */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 conversation129	 * @param fields - Optional extra fields (e.g. reasoningEffort)130	 * @returns The created conversation131	 */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			...fields142		};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 to155	 * @returns The created message156	 */157	static async createMessageBranch(158		message: Omit<DatabaseMessage, 'id'>,159		parentId: string | null160	): 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 exists185				if (parentId !== null) {186					await this.addChildToParent(parentId, newMessage.id);187				}188 189				await this.updateConversation(message.convId, {190					currNode: newMessage.id191				});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 ID203	 * @returns The created root message204	 */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 ID227	 * @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 message230	 * @throws Error if systemPrompt is empty or the parent message does not exist231	 */232	static async createSystemMessage(233		convId: string,234		systemPrompt: string,235		parentId: string236	): 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.SYSTEM259			};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 ID272	 */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 IDs283					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 delete316	 */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 message334	 * @param messageId - ID of the root message to delete (along with all descendants)335	 * @returns Array of all deleted message IDs336	 */337	static async deleteMessageCascading(338		conversationId: string,339		messageId: string340	): Promise<string[]> {341		return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {342			// Get all messages in the conversation to find descendants343			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 branch353			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 conversation361	 * containing all messages from the root up to (and including) the target message.362	 *363	 * @param sourceConvId - The source conversation ID364	 * @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 conversation367	 */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					true391				) 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.children408						.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: newParent418					};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.mcpServerOverrides428						? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({429								enabled: o.enabled,430								serverId: o.serverId431							}))432						: undefined,433					name: options.name434				};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 conversations448	 */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 ID457	 * @returns The conversation if found, otherwise undefined458	 */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 ID467	 * @returns Array of messages in the conversation468	 */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 bulk475	 * reads. Missing conversations are silently omitted from the result.476	 *477	 * @param convIds - Conversation IDs to load478	 * @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.timestamp508			);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 } objects521	 * @returns The conversations written to the database and the ones skipped522	 */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 ID560	 * @returns The new pinned status561	 */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 ID581	 * @param updates - Partial updates to apply582	 * @returns Promise that resolves when the conversation is updated583	 */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 ID596	 * @param nodeId - Message ID to set as current node597	 */598	static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {599		await this.updateConversation(convId, {600			currNode: nodeId601		});602	}603 604	/**605	 * Updates a message.606	 *607	 * @param id - Message ID608	 * @param updates - Partial updates to apply609	 * @returns Promise that resolves when the message is updated610	 */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 surviving649	 * ancestor (or promotes them to top-level when the immediate parent was650	 * top-level). Walking skips any ancestor listed in `excludeIds`, since651	 * those will be deleted in the same batch — leaving a grandchild pointing652	 * at an `excludeIds` entry would orphan it. Children whose own id is in653	 * `excludeIds` are dropped from the updates (the bulk-delete pass will654	 * remove them). `prefetched` may carry a pre-fetched ancestor map to655	 * 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}706