diff options
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/services/database.ts')
| -rw-r--r-- | llama.cpp/tools/server/webui/src/lib/services/database.ts | 400 |
1 files changed, 400 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/services/database.ts b/llama.cpp/tools/server/webui/src/lib/services/database.ts new file mode 100644 index 0000000..3b24628 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/services/database.ts | |||
| @@ -0,0 +1,400 @@ | |||
| 1 | import Dexie, { type EntityTable } from 'dexie'; | ||
| 2 | import { findDescendantMessages } from '$lib/utils'; | ||
| 3 | |||
| 4 | class LlamacppDatabase extends Dexie { | ||
| 5 | conversations!: EntityTable<DatabaseConversation, string>; | ||
| 6 | messages!: EntityTable<DatabaseMessage, string>; | ||
| 7 | |||
| 8 | constructor() { | ||
| 9 | super('LlamacppWebui'); | ||
| 10 | |||
| 11 | this.version(1).stores({ | ||
| 12 | conversations: 'id, lastModified, currNode, name', | ||
| 13 | messages: 'id, convId, type, role, timestamp, parent, children' | ||
| 14 | }); | ||
| 15 | } | ||
| 16 | } | ||
| 17 | |||
| 18 | const db = new LlamacppDatabase(); | ||
| 19 | import { v4 as uuid } from 'uuid'; | ||
| 20 | |||
| 21 | /** | ||
| 22 | * DatabaseService - Stateless IndexedDB communication layer | ||
| 23 | * | ||
| 24 | * **Terminology - Chat vs Conversation:** | ||
| 25 | * - **Chat**: The active interaction space with the Chat Completions API (ephemeral, runtime). | ||
| 26 | * - **Conversation**: The persistent database entity storing all messages and metadata. | ||
| 27 | * This service handles raw database operations for conversations - the lowest layer | ||
| 28 | * in the persistence stack. | ||
| 29 | * | ||
| 30 | * This service provides a stateless data access layer built on IndexedDB using Dexie ORM. | ||
| 31 | * It handles all low-level storage operations for conversations and messages with support | ||
| 32 | * for complex branching and message threading. All methods are static - no instance state. | ||
| 33 | * | ||
| 34 | * **Architecture & Relationships (bottom to top):** | ||
| 35 | * - **DatabaseService** (this class): Stateless IndexedDB operations | ||
| 36 | * - Lowest layer - direct Dexie/IndexedDB communication | ||
| 37 | * - Pure CRUD operations without business logic | ||
| 38 | * - Handles branching tree structure (parent-child relationships) | ||
| 39 | * - Provides transaction safety for multi-table operations | ||
| 40 | * | ||
| 41 | * - **ConversationsService**: Stateless business logic layer | ||
| 42 | * - Uses DatabaseService for all persistence operations | ||
| 43 | * - Adds import/export, navigation, and higher-level operations | ||
| 44 | * | ||
| 45 | * - **conversationsStore**: Reactive state management for conversations | ||
| 46 | * - Uses ConversationsService for database operations | ||
| 47 | * - Manages conversation list, active conversation, and messages in memory | ||
| 48 | * | ||
| 49 | * - **chatStore**: Active AI interaction management | ||
| 50 | * - Uses conversationsStore for conversation context | ||
| 51 | * - Directly uses DatabaseService for message CRUD during streaming | ||
| 52 | * | ||
| 53 | * **Key Features:** | ||
| 54 | * - **Conversation CRUD**: Create, read, update, delete conversations | ||
| 55 | * - **Message CRUD**: Add, update, delete messages with branching support | ||
| 56 | * - **Branch Operations**: Create branches, find descendants, cascade deletions | ||
| 57 | * - **Transaction Safety**: Atomic operations for data consistency | ||
| 58 | * | ||
| 59 | * **Database Schema:** | ||
| 60 | * - `conversations`: id, lastModified, currNode, name | ||
| 61 | * - `messages`: id, convId, type, role, timestamp, parent, children | ||
| 62 | * | ||
| 63 | * **Branching Model:** | ||
| 64 | * Messages form a tree structure where each message can have multiple children, | ||
| 65 | * enabling conversation branching and alternative response paths. The conversation's | ||
| 66 | * `currNode` tracks the currently active branch endpoint. | ||
| 67 | */ | ||
| 68 | export class DatabaseService { | ||
| 69 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 70 | // Conversations | ||
| 71 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 72 | |||
| 73 | /** | ||
| 74 | * Creates a new conversation. | ||
| 75 | * | ||
| 76 | * @param name - Name of the conversation | ||
| 77 | * @returns The created conversation | ||
| 78 | */ | ||
| 79 | static async createConversation(name: string): Promise<DatabaseConversation> { | ||
| 80 | const conversation: DatabaseConversation = { | ||
| 81 | id: uuid(), | ||
| 82 | name, | ||
| 83 | lastModified: Date.now(), | ||
| 84 | currNode: '' | ||
| 85 | }; | ||
| 86 | |||
| 87 | await db.conversations.add(conversation); | ||
| 88 | return conversation; | ||
| 89 | } | ||
| 90 | |||
| 91 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 92 | // Messages | ||
| 93 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 94 | |||
| 95 | /** | ||
| 96 | * Creates a new message branch by adding a message and updating parent/child relationships. | ||
| 97 | * Also updates the conversation's currNode to point to the new message. | ||
| 98 | * | ||
| 99 | * @param message - Message to add (without id) | ||
| 100 | * @param parentId - Parent message ID to attach to | ||
| 101 | * @returns The created message | ||
| 102 | */ | ||
| 103 | static async createMessageBranch( | ||
| 104 | message: Omit<DatabaseMessage, 'id'>, | ||
| 105 | parentId: string | null | ||
| 106 | ): Promise<DatabaseMessage> { | ||
| 107 | return await db.transaction('rw', [db.conversations, db.messages], async () => { | ||
| 108 | // Handle null parent (root message case) | ||
| 109 | if (parentId !== null) { | ||
| 110 | const parentMessage = await db.messages.get(parentId); | ||
| 111 | if (!parentMessage) { | ||
| 112 | throw new Error(`Parent message ${parentId} not found`); | ||
| 113 | } | ||
| 114 | } | ||
| 115 | |||
| 116 | const newMessage: DatabaseMessage = { | ||
| 117 | ...message, | ||
| 118 | id: uuid(), | ||
| 119 | parent: parentId, | ||
| 120 | toolCalls: message.toolCalls ?? '', | ||
| 121 | children: [] | ||
| 122 | }; | ||
| 123 | |||
| 124 | await db.messages.add(newMessage); | ||
| 125 | |||
| 126 | // Update parent's children array if parent exists | ||
| 127 | if (parentId !== null) { | ||
| 128 | const parentMessage = await db.messages.get(parentId); | ||
| 129 | if (parentMessage) { | ||
| 130 | await db.messages.update(parentId, { | ||
| 131 | children: [...parentMessage.children, newMessage.id] | ||
| 132 | }); | ||
| 133 | } | ||
| 134 | } | ||
| 135 | |||
| 136 | await this.updateConversation(message.convId, { | ||
| 137 | currNode: newMessage.id | ||
| 138 | }); | ||
| 139 | |||
| 140 | return newMessage; | ||
| 141 | }); | ||
| 142 | } | ||
| 143 | |||
| 144 | /** | ||
| 145 | * Creates a root message for a new conversation. | ||
| 146 | * Root messages are not displayed but serve as the tree root for branching. | ||
| 147 | * | ||
| 148 | * @param convId - Conversation ID | ||
| 149 | * @returns The created root message | ||
| 150 | */ | ||
| 151 | static async createRootMessage(convId: string): Promise<string> { | ||
| 152 | const rootMessage: DatabaseMessage = { | ||
| 153 | id: uuid(), | ||
| 154 | convId, | ||
| 155 | type: 'root', | ||
| 156 | timestamp: Date.now(), | ||
| 157 | role: 'system', | ||
| 158 | content: '', | ||
| 159 | parent: null, | ||
| 160 | thinking: '', | ||
| 161 | toolCalls: '', | ||
| 162 | children: [] | ||
| 163 | }; | ||
| 164 | |||
| 165 | await db.messages.add(rootMessage); | ||
| 166 | return rootMessage.id; | ||
| 167 | } | ||
| 168 | |||
| 169 | /** | ||
| 170 | * Creates a system prompt message for a conversation. | ||
| 171 | * | ||
| 172 | * @param convId - Conversation ID | ||
| 173 | * @param systemPrompt - The system prompt content (must be non-empty) | ||
| 174 | * @param parentId - Parent message ID (typically the root message) | ||
| 175 | * @returns The created system message | ||
| 176 | * @throws Error if systemPrompt is empty | ||
| 177 | */ | ||
| 178 | static async createSystemMessage( | ||
| 179 | convId: string, | ||
| 180 | systemPrompt: string, | ||
| 181 | parentId: string | ||
| 182 | ): Promise<DatabaseMessage> { | ||
| 183 | const trimmedPrompt = systemPrompt.trim(); | ||
| 184 | if (!trimmedPrompt) { | ||
| 185 | throw new Error('Cannot create system message with empty content'); | ||
| 186 | } | ||
| 187 | |||
| 188 | const systemMessage: DatabaseMessage = { | ||
| 189 | id: uuid(), | ||
| 190 | convId, | ||
| 191 | type: 'system', | ||
| 192 | timestamp: Date.now(), | ||
| 193 | role: 'system', | ||
| 194 | content: trimmedPrompt, | ||
| 195 | parent: parentId, | ||
| 196 | thinking: '', | ||
| 197 | children: [] | ||
| 198 | }; | ||
| 199 | |||
| 200 | await db.messages.add(systemMessage); | ||
| 201 | |||
| 202 | const parentMessage = await db.messages.get(parentId); | ||
| 203 | if (parentMessage) { | ||
| 204 | await db.messages.update(parentId, { | ||
| 205 | children: [...parentMessage.children, systemMessage.id] | ||
| 206 | }); | ||
| 207 | } | ||
| 208 | |||
| 209 | return systemMessage; | ||
| 210 | } | ||
| 211 | |||
| 212 | /** | ||
| 213 | * Deletes a conversation and all its messages. | ||
| 214 | * | ||
| 215 | * @param id - Conversation ID | ||
| 216 | */ | ||
| 217 | static async deleteConversation(id: string): Promise<void> { | ||
| 218 | await db.transaction('rw', [db.conversations, db.messages], async () => { | ||
| 219 | await db.conversations.delete(id); | ||
| 220 | await db.messages.where('convId').equals(id).delete(); | ||
| 221 | }); | ||
| 222 | } | ||
| 223 | |||
| 224 | /** | ||
| 225 | * Deletes a message and removes it from its parent's children array. | ||
| 226 | * | ||
| 227 | * @param messageId - ID of the message to delete | ||
| 228 | */ | ||
| 229 | static async deleteMessage(messageId: string): Promise<void> { | ||
| 230 | await db.transaction('rw', db.messages, async () => { | ||
| 231 | const message = await db.messages.get(messageId); | ||
| 232 | if (!message) return; | ||
| 233 | |||
| 234 | // Remove this message from its parent's children array | ||
| 235 | if (message.parent) { | ||
| 236 | const parent = await db.messages.get(message.parent); | ||
| 237 | if (parent) { | ||
| 238 | parent.children = parent.children.filter((childId: string) => childId !== messageId); | ||
| 239 | await db.messages.put(parent); | ||
| 240 | } | ||
| 241 | } | ||
| 242 | |||
| 243 | // Delete the message | ||
| 244 | await db.messages.delete(messageId); | ||
| 245 | }); | ||
| 246 | } | ||
| 247 | |||
| 248 | /** | ||
| 249 | * Deletes a message and all its descendant messages (cascading deletion). | ||
| 250 | * This removes the entire branch starting from the specified message. | ||
| 251 | * | ||
| 252 | * @param conversationId - ID of the conversation containing the message | ||
| 253 | * @param messageId - ID of the root message to delete (along with all descendants) | ||
| 254 | * @returns Array of all deleted message IDs | ||
| 255 | */ | ||
| 256 | static async deleteMessageCascading( | ||
| 257 | conversationId: string, | ||
| 258 | messageId: string | ||
| 259 | ): Promise<string[]> { | ||
| 260 | return await db.transaction('rw', db.messages, async () => { | ||
| 261 | // Get all messages in the conversation to find descendants | ||
| 262 | const allMessages = await db.messages.where('convId').equals(conversationId).toArray(); | ||
| 263 | |||
| 264 | // Find all descendant messages | ||
| 265 | const descendants = findDescendantMessages(allMessages, messageId); | ||
| 266 | const allToDelete = [messageId, ...descendants]; | ||
| 267 | |||
| 268 | // Get the message to delete for parent cleanup | ||
| 269 | const message = await db.messages.get(messageId); | ||
| 270 | if (message && message.parent) { | ||
| 271 | const parent = await db.messages.get(message.parent); | ||
| 272 | if (parent) { | ||
| 273 | parent.children = parent.children.filter((childId: string) => childId !== messageId); | ||
| 274 | await db.messages.put(parent); | ||
| 275 | } | ||
| 276 | } | ||
| 277 | |||
| 278 | // Delete all messages in the branch | ||
| 279 | await db.messages.bulkDelete(allToDelete); | ||
| 280 | |||
| 281 | return allToDelete; | ||
| 282 | }); | ||
| 283 | } | ||
| 284 | |||
| 285 | /** | ||
| 286 | * Gets all conversations, sorted by last modified time (newest first). | ||
| 287 | * | ||
| 288 | * @returns Array of conversations | ||
| 289 | */ | ||
| 290 | static async getAllConversations(): Promise<DatabaseConversation[]> { | ||
| 291 | return await db.conversations.orderBy('lastModified').reverse().toArray(); | ||
| 292 | } | ||
| 293 | |||
| 294 | /** | ||
| 295 | * Gets a conversation by ID. | ||
| 296 | * | ||
| 297 | * @param id - Conversation ID | ||
| 298 | * @returns The conversation if found, otherwise undefined | ||
| 299 | */ | ||
| 300 | static async getConversation(id: string): Promise<DatabaseConversation | undefined> { | ||
| 301 | return await db.conversations.get(id); | ||
| 302 | } | ||
| 303 | |||
| 304 | /** | ||
| 305 | * Gets all messages in a conversation, sorted by timestamp (oldest first). | ||
| 306 | * | ||
| 307 | * @param convId - Conversation ID | ||
| 308 | * @returns Array of messages in the conversation | ||
| 309 | */ | ||
| 310 | static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> { | ||
| 311 | return await db.messages.where('convId').equals(convId).sortBy('timestamp'); | ||
| 312 | } | ||
| 313 | |||
| 314 | /** | ||
| 315 | * Updates a conversation. | ||
| 316 | * | ||
| 317 | * @param id - Conversation ID | ||
| 318 | * @param updates - Partial updates to apply | ||
| 319 | * @returns Promise that resolves when the conversation is updated | ||
| 320 | */ | ||
| 321 | static async updateConversation( | ||
| 322 | id: string, | ||
| 323 | updates: Partial<Omit<DatabaseConversation, 'id'>> | ||
| 324 | ): Promise<void> { | ||
| 325 | await db.conversations.update(id, { | ||
| 326 | ...updates, | ||
| 327 | lastModified: Date.now() | ||
| 328 | }); | ||
| 329 | } | ||
| 330 | |||
| 331 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 332 | // Navigation | ||
| 333 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 334 | |||
| 335 | /** | ||
| 336 | * Updates the conversation's current node (active branch). | ||
| 337 | * This determines which conversation path is currently being viewed. | ||
| 338 | * | ||
| 339 | * @param convId - Conversation ID | ||
| 340 | * @param nodeId - Message ID to set as current node | ||
| 341 | */ | ||
| 342 | static async updateCurrentNode(convId: string, nodeId: string): Promise<void> { | ||
| 343 | await this.updateConversation(convId, { | ||
| 344 | currNode: nodeId | ||
| 345 | }); | ||
| 346 | } | ||
| 347 | |||
| 348 | /** | ||
| 349 | * Updates a message. | ||
| 350 | * | ||
| 351 | * @param id - Message ID | ||
| 352 | * @param updates - Partial updates to apply | ||
| 353 | * @returns Promise that resolves when the message is updated | ||
| 354 | */ | ||
| 355 | static async updateMessage( | ||
| 356 | id: string, | ||
| 357 | updates: Partial<Omit<DatabaseMessage, 'id'>> | ||
| 358 | ): Promise<void> { | ||
| 359 | await db.messages.update(id, updates); | ||
| 360 | } | ||
| 361 | |||
| 362 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 363 | // Import | ||
| 364 | // ───────────────────────────────────────────────────────────────────────────── | ||
| 365 | |||
| 366 | /** | ||
| 367 | * Imports multiple conversations and their messages. | ||
| 368 | * Skips conversations that already exist. | ||
| 369 | * | ||
| 370 | * @param data - Array of { conv, messages } objects | ||
| 371 | */ | ||
| 372 | static async importConversations( | ||
| 373 | data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[] | ||
| 374 | ): Promise<{ imported: number; skipped: number }> { | ||
| 375 | let importedCount = 0; | ||
| 376 | let skippedCount = 0; | ||
| 377 | |||
| 378 | return await db.transaction('rw', [db.conversations, db.messages], async () => { | ||
| 379 | for (const item of data) { | ||
| 380 | const { conv, messages } = item; | ||
| 381 | |||
| 382 | const existing = await db.conversations.get(conv.id); | ||
| 383 | if (existing) { | ||
| 384 | console.warn(`Conversation "${conv.name}" already exists, skipping...`); | ||
| 385 | skippedCount++; | ||
| 386 | continue; | ||
| 387 | } | ||
| 388 | |||
| 389 | await db.conversations.add(conv); | ||
| 390 | for (const msg of messages) { | ||
| 391 | await db.messages.put(msg); | ||
| 392 | } | ||
| 393 | |||
| 394 | importedCount++; | ||
| 395 | } | ||
| 396 | |||
| 397 | return { imported: importedCount, skipped: skippedCount }; | ||
| 398 | }); | ||
| 399 | } | ||
| 400 | } | ||
