Felipe97/llama-cpp-compiled
01.2k
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 