summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/services/database.ts
diff options
context:
space:
mode:
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.ts400
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 @@
1import Dexie, { type EntityTable } from 'dexie';
2import { findDescendantMessages } from '$lib/utils';
3
4class 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
18const db = new LlamacppDatabase();
19import { 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 */
68export 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}