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}