char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
521a64cd0
char/aliceai-t5-moe
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 20constructor () { 21super ( STORAGE_APP_NAME ); 22 23this . 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*/ 38static async bulkDeleteConversations ( ids :string []) :Promise < void > { 39const cleanIds = ids . filter (( id ) :id isstring => typeof id === 'string' && id . length > 0 ); 40 41if ( cleanIds . length === 0 ) return ; 42 43const idSet = new Set ( cleanIds ); 44 45await db . transaction ( 46'rw' , 47[ db [ IDXDB_TABLES . conversations ], db [ IDXDB_TABLES . messages ]], 48async () => { 49// Pre-load each to-delete conversation so the per-id reparent 50// walk-up doesn't ping-pong the same ancestry chain. 51const prefetched = new Map < string , DatabaseConversation >(); 52 53let frontier = [ ...cleanIds ]; 54 55const requested = new Set < string >( frontier ); 56 57while ( frontier . length > 0 ) { 58const fetched = await db [ IDXDB_TABLES . conversations ]. bulkGet ( frontier ); 59 60frontier = []; 61for ( let i = 0 ; i < fetched . length ; i ++ ) { 62const conv = fetched [ i ]; 63 64if ( ! conv || ! conv . id ) continue ; 65 66prefetched . set ( conv . id , conv ); 67const ancestor = conv . forkedFromConversationId ; 68 69if ( ancestor && ! prefetched . has ( ancestor ) && ! requested . has ( ancestor )) { 70frontier . push ( ancestor ); 71requested . add ( ancestor ); 72} 73} 74} 75 76for ( const id of cleanIds ) { 77await this . reparentDirectChildren ( id , idSet , prefetched ); 78} 79 80await db [ IDXDB_TABLES . conversations ]. bulkDelete ( cleanIds ); 81await 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*/ 96static async bulkToggleConversationPins ( ids :string []) :Promise < Map < string , boolean >> { 97const cleanIds = ids . filter (( id ) :id isstring => typeof id === 'string' && id . length > 0 ); 98const result = new Map < string , boolean >(); 99 100if ( cleanIds . length === 0 ) return result ; 101 102await db . transaction ( 'rw' , db [ IDXDB_TABLES . conversations ], async () => { 103const convs = await db [ IDXDB_TABLES . conversations ]. bulkGet ( cleanIds ); 104const updates :DatabaseConversation [] = []; 105 106for ( let i = 0 ; i < cleanIds . length ; i ++ ) { 107const conv = convs [ i ]; 108 109if ( ! conv ) continue ; 110 111const newPinned = ! conv . pinned ; 112 113updates . push ({ ...conv , pinned :newPinned }); 114result . set ( cleanIds [ i ], newPinned ); 115} 116 117if ( updates . length === 0 ) return ; 118 119await db [ IDXDB_TABLES . conversations ]. bulkPut ( updates ); 120}); 121 122return 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*/ 132static async createConversation ( 133name :string , 134fields ?:Partial < Omit < DatabaseConversation , 'id' | 'name' | 'lastModified' >> 135) :Promise < DatabaseConversation > { 136const conversation :DatabaseConversation = { 137currNode :'' , 138id :uuid (), 139lastModified :Date . now (), 140 name, 141 ...fields 142}; 143 144await db [ IDXDB_TABLES . conversations ]. add ( conversation ); 145 146return 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*/ 157static async createMessageBranch ( 158message :Omit < DatabaseMessage , 'id' >, 159parentId :string | null 160) :Promise < DatabaseMessage > { 161return await db . transaction ( 162'rw' , 163[ db [ IDXDB_TABLES . conversations ], db [ IDXDB_TABLES . messages ]], 164async () => { 165// Handle null parent (root message case) 166if ( parentId !== null ) { 167const parentMessage = await db [ IDXDB_TABLES . messages ]. get ( parentId ); 168 169if ( ! parentMessage ) { 170throw new Error ( `Parent message ${ parentId } not found` ); 171} 172} 173 174const newMessage :DatabaseMessage = { 175 ...message , 176children :[], 177id :uuid (), 178parent :parentId , 179toolCalls :message . toolCalls ?? '' 180}; 181 182await db [ IDXDB_TABLES . messages ]. add ( newMessage ); 183 184// Update parent's children array if parent exists 185if ( parentId !== null ) { 186await this . addChildToParent ( parentId , newMessage . id ); 187} 188 189await this . updateConversation ( message . convId , { 190currNode :newMessage . id 191}); 192 193return 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*/ 205static async createRootMessage ( convId :string ) :Promise < string > { 206const rootMessage :DatabaseMessage = { 207children :[], 208content :'' , 209 convId, 210id :uuid (), 211parent :null , 212role :MessageRole . SYSTEM , 213timestamp :Date . now (), 214toolCalls :'' , 215type :'root' 216}; 217 218await db [ IDXDB_TABLES . messages ]. add ( rootMessage ); 219 220return 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*/ 232static async createSystemMessage ( 233convId :string , 234systemPrompt :string , 235parentId :string 236) :Promise < DatabaseMessage > { 237const trimmedPrompt = systemPrompt . trim (); 238 239if ( ! trimmedPrompt ) { 240throw new Error ( 'Cannot create system message with empty content' ); 241} 242 243return await db . transaction ( 'rw' , db [ IDXDB_TABLES . messages ], async () => { 244const parentMessage = await db [ IDXDB_TABLES . messages ]. get ( parentId ); 245 246if ( ! parentMessage ) { 247throw new Error ( `Parent message ${ parentId } not found` ); 248} 249 250const systemMessage :DatabaseMessage = { 251children :[], 252content :trimmedPrompt , 253 convId, 254id :uuid (), 255parent :parentId , 256role :MessageRole . SYSTEM , 257timestamp :Date . now (), 258type :MessageRole . SYSTEM 259}; 260 261await db [ IDXDB_TABLES . messages ]. add ( systemMessage ); 262await this . addChildToParent ( parentId , systemMessage . id ); 263 264return systemMessage ; 265}); 266} 267 268/** 269* Deletes a conversation and all its messages. 270* 271* @param id - Conversation ID 272*/ 273static async deleteConversation ( 274id :string , 275options ?:{ deleteWithForks ?:boolean } 276) :Promise < void > { 277await db . transaction ( 278'rw' , 279[ db [ IDXDB_TABLES . conversations ], db [ IDXDB_TABLES . messages ]], 280async () => { 281if ( options ?. deleteWithForks ) { 282// Recursively collect all descendant IDs 283const idsToDelete :string [] = []; 284const queue = [ id ]; 285 286while ( queue . length > 0 ) { 287const parentId = queue . pop () ! ; 288const children = await db [ IDXDB_TABLES . conversations ] 289. filter (( c ) => c . forkedFromConversationId === parentId ) 290. toArray (); 291 292for ( const child of children ) { 293idsToDelete . push ( child . id ); 294queue . push ( child . id ); 295} 296} 297 298for ( const forkId of idsToDelete ) { 299await db [ IDXDB_TABLES . conversations ]. delete ( forkId ); 300await db [ IDXDB_TABLES . messages ]. where ( 'convId' ). equals ( forkId ). delete (); 301} 302} else { 303await this . reparentDirectChildren ( id ); 304} 305 306await db [ IDXDB_TABLES . conversations ]. delete ( id ); 307await 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*/ 317static async deleteMessage ( messageId :string ) :Promise < void > { 318await db . transaction ( 'rw' , db [ IDXDB_TABLES . messages ], async () => { 319const message = await db [ IDXDB_TABLES . messages ]. get ( messageId ); 320 321if ( ! message ) return ; 322 323await this . removeChildFromParent ( messageId ); 324 325await 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*/ 337static async deleteMessageCascading ( 338conversationId :string , 339messageId :string 340) :Promise < string []> { 341return await db . transaction ( 'rw' , db [ IDXDB_TABLES . messages ], async () => { 342// Get all messages in the conversation to find descendants 343const allMessages = await db [ IDXDB_TABLES . messages ] 344. where ( 'convId' ) 345. equals ( conversationId ) 346. toArray (); 347const descendants = findDescendantMessages ( allMessages , messageId ); 348const allToDelete = [ messageId , ...descendants ]; 349 350await this . removeChildFromParent ( messageId ); 351 352// Delete all messages in the branch 353await db [ IDXDB_TABLES . messages ]. bulkDelete ( allToDelete ); 354 355return 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*/ 368static async forkConversation ( 369sourceConvId :string , 370atMessageId :string , 371options :{ name :string ; includeAttachments :boolean } 372) :Promise < DatabaseConversation > { 373return await db . transaction ( 374'rw' , 375[ db [ IDXDB_TABLES . conversations ], db [ IDXDB_TABLES . messages ]], 376async () => { 377const sourceConv = await db [ IDXDB_TABLES . conversations ]. get ( sourceConvId ); 378 379if ( ! sourceConv ) { 380throw new Error ( `Source conversation ${ sourceConvId } not found` ); 381} 382 383const allMessages = await db [ IDXDB_TABLES . messages ] 384. where ( 'convId' ) 385. equals ( sourceConvId ) 386. toArray (); 387const pathMessages = filterByLeafNodeId ( 388allMessages , 389atMessageId , 390true 391) as DatabaseMessage []; 392 393if ( pathMessages . length === 0 ) { 394throw new Error ( `Could not resolve message path to ${ atMessageId } ` ); 395} 396 397const idMap = new Map < string , string >(); 398 399for ( const msg of pathMessages ) { 400idMap . set ( msg . id , uuid ()); 401} 402 403const newConvId = uuid (); 404const clonedMessages :DatabaseMessage [] = pathMessages . map (( msg ) => { 405const newId = idMap . get ( msg . id ) ! ; 406const newParent = msg . parent ?( idMap . get ( msg . parent ) ?? null ) :null ; 407const newChildren = msg . children 408. filter (( childId :string ) => idMap . has ( childId )) 409. map (( childId :string ) => idMap . get ( childId ) ! ); 410 411return { 412 ...msg , 413children :newChildren , 414convId :newConvId , 415extra :options . includeAttachments ?msg . extra :undefined , 416id :newId , 417parent :newParent 418}; 419}); 420const lastClonedMessage = clonedMessages [ clonedMessages . length - 1 ]; 421const newConv :DatabaseConversation = { 422currNode :lastClonedMessage . id , 423cwd :sourceConv . cwd , 424forkedFromConversationId :sourceConvId , 425id :newConvId , 426lastModified :Date . now (), 427mcpServerOverrides :sourceConv . mcpServerOverrides 428 ?sourceConv . mcpServerOverrides . map (( o :McpServerOverride ) => ({ 429enabled :o . enabled , 430serverId :o . serverId 431})) 432 :undefined , 433name :options . name 434}; 435 436await db [ IDXDB_TABLES . conversations ]. add ( newConv ); 437await db [ IDXDB_TABLES . messages ]. bulkAdd ( clonedMessages ); 438 439return newConv ; 440} 441); 442} 443 444/** 445* Gets all conversations, sorted by last modified time (newest first). 446* 447* @returns Array of conversations 448*/ 449static async getAllConversations () :Promise < DatabaseConversation []> { 450return 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*/ 459static async getConversation ( id :string ) :Promise < DatabaseConversation | undefined > { 460return 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*/ 469static async getConversationMessages ( convId :string ) :Promise < DatabaseMessage []> { 470return 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*/ 480static async getConversationsWithMessages ( 481convIds :string [] 482) :Promise < Map < string , ExportedConversation >> { 483const result = new Map < string , ExportedConversation >(); 484const cleanIds = convIds . filter (( id ) :id isstring => typeof id === 'string' && id . length > 0 ); 485 486if ( cleanIds . length === 0 ) return result ; 487 488const [ convs , allMessages ] = await Promise . all ([ 489db [ IDXDB_TABLES . conversations ]. bulkGet ( cleanIds ), 490db [ IDXDB_TABLES . messages ]. where ( 'convId' ). anyOf ( cleanIds ). toArray () 491]); 492const messagesByConv = new Map < string , DatabaseMessage []>(); 493 494for ( const msg of allMessages ) { 495const bucket = messagesByConv . get ( msg . convId ); 496 497if ( bucket ) bucket . push ( msg ); 498else messagesByConv . set ( msg . convId , [ msg ]); 499} 500 501for ( let i = 0 ; i < cleanIds . length ; i ++ ) { 502const conv = convs [ i ]; 503 504if ( ! conv ) continue ; 505 506const messages = ( messagesByConv . get ( conv . id ) ?? []). sort ( 507( a , b ) => a . timestamp - b . timestamp 508); 509 510result . set ( conv . id , { conv, messages}); 511} 512 513return 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*/ 523static async importConversations ( 524data :{ conv :DatabaseConversation ; messages :DatabaseMessage [] }[] 525) :Promise <{ imported :DatabaseConversation []; skipped :DatabaseConversation [] }> { 526const imported :DatabaseConversation [] = []; 527const skipped :DatabaseConversation [] = []; 528 529return await db . transaction ( 530'rw' , 531[ db [ IDXDB_TABLES . conversations ], db [ IDXDB_TABLES . messages ]], 532async () => { 533for ( const item of data ) { 534const { conv, messages} = item ; 535const existing = await db [ IDXDB_TABLES . conversations ]. get ( conv . id ); 536 537if ( existing ) { 538skipped . push ( conv ); 539 540continue ; 541} 542 543await db [ IDXDB_TABLES . conversations ]. add ( conv ); 544for ( const msg of messages ) { 545await db [ IDXDB_TABLES . messages ]. put ( msg ); 546} 547 548imported . push ( conv ); 549} 550 551return { 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*/ 562static async toggleConversationPin ( id :string ) :Promise < boolean > { 563const conversation = await db [ IDXDB_TABLES . conversations ]. get ( id ); 564 565if ( ! conversation ) { 566throw new Error ( `Conversation ${ id } not found` ); 567} 568 569const newPinnedState = ! conversation . pinned ; 570 571await this . updateConversation ( id , { pinned :newPinnedState }); 572 573return 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*/ 584static async updateConversation ( 585id :string , 586updates :Partial < Omit < DatabaseConversation , 'id' >> 587) :Promise < void > { 588await 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*/ 598static async updateCurrentNode ( convId :string , nodeId :string ) :Promise < void > { 599await this . updateConversation ( convId , { 600currNode :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*/ 611static async updateMessage ( 612id :string , 613updates :Partial < Omit < DatabaseMessage , 'id' >> 614) :Promise < void > { 615await db [ IDXDB_TABLES . messages ]. update ( id , updates ); 616} 617 618/** 619* Appends a child id to a parent message's children array. 620*/ 621private static async addChildToParent ( parentId :string , childId :string ) :Promise < void > { 622const parent = await db [ IDXDB_TABLES . messages ]. get ( parentId ); 623 624if ( ! parent ) return ; 625 626await db [ IDXDB_TABLES . messages ]. update ( parentId , { 627children :[ ...parent . children , childId ] 628}); 629} 630 631/** 632* Removes a child id from its parent message's children array. 633*/ 634private static async removeChildFromParent ( messageId :string ) :Promise < void > { 635const message = await db [ IDXDB_TABLES . messages ]. get ( messageId ); 636 637if ( ! message ?. parent ) return ; 638 639const parent = await db [ IDXDB_TABLES . messages ]. get ( message . parent ); 640 641if ( ! parent ) return ; 642 643parent . children = parent . children . filter (( childId :string ) => childId !== messageId ); 644await 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*/ 657private static async reparentDirectChildren ( 658parentId :string , 659excludeIds :ReadonlySet < string > = new Set (), 660prefetched ?:ReadonlyMap < string , DatabaseConversation > 661) :Promise < void > { 662const conv = prefetched ?. get ( parentId ) ?? ( await db [ IDXDB_TABLES . conversations ]. get ( parentId )); 663 664if ( ! conv ) return ; 665 666let newParent = conv . forkedFromConversationId ; 667 668const visited = new Set < string >([ parentId ]); 669 670while ( newParent && excludeIds . has ( newParent )) { 671if ( visited . has ( newParent )) { 672newParent = undefined ; 673 674break ; 675} 676 677visited . add ( newParent ); 678const next = 679prefetched ?. get ( newParent ) ?? ( await db [ IDXDB_TABLES . conversations ]. get ( newParent )); 680 681if ( ! next ) { 682newParent = undefined ; 683 684break ; 685} 686 687newParent = next . forkedFromConversationId ; 688} 689 690const directChildren = await db [ IDXDB_TABLES . conversations ] 691. filter (( c ) => c . forkedFromConversationId === parentId ) 692. toArray (); 693const updates :DatabaseConversation [] = []; 694 695for ( const child of directChildren ) { 696if ( excludeIds . has ( child . id )) continue ; 697 698updates . push ({ ...child , forkedFromConversationId :newParent }); 699} 700 701if ( updates . length === 0 ) return ; 702 703await db [ IDXDB_TABLES . conversations ]. bulkPut ( updates ); 704} 705}