1import { browser } from '$app/environment';
2import { goto } from '$app/navigation';
3import { toast } from 'svelte-sonner';
4import { DatabaseService } from '$lib/services/database';
5import { config } from '$lib/stores/settings.svelte';
6import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
7import { AttachmentType } from '$lib/enums';
8
9/**
10 * conversationsStore - Persistent conversation data and lifecycle management
11 *
12 * **Terminology - Chat vs Conversation:**
13 * - **Chat**: The active interaction space with the Chat Completions API. Represents the
14 * real-time streaming session, loading states, and UI visualization of AI communication.
15 * Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
16 * - **Conversation**: The persistent database entity storing all messages and metadata.
17 * A "conversation" survives across sessions, page reloads, and browser restarts.
18 * It contains the complete message history, branching structure, and conversation metadata.
19 *
20 * This store manages all conversation-level data and operations including creation, loading,
21 * deletion, and navigation. It maintains the list of conversations and the currently active
22 * conversation with its message history, providing reactive state for UI components.
23 *
24 * **Architecture & Relationships:**
25 * - **conversationsStore** (this class): Persistent conversation data management
26 * - Manages conversation list and active conversation state
27 * - Handles conversation CRUD operations via DatabaseService
28 * - Maintains active message array for current conversation
29 * - Coordinates branching navigation (currNode tracking)
30 *
31 * - **chatStore**: Uses conversation data as context for active AI streaming
32 * - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
33 *
34 * **Key Features:**
35 * - **Conversation Lifecycle**: Create, load, update, delete conversations
36 * - **Message Management**: Active message array with branching support
37 * - **Import/Export**: JSON-based conversation backup and restore
38 * - **Branch Navigation**: Navigate between message tree branches
39 * - **Title Management**: Auto-update titles with confirmation dialogs
40 * - **Reactive State**: Svelte 5 runes for automatic UI updates
41 *
42 * **State Properties:**
43 * - `conversations`: All conversations sorted by last modified
44 * - `activeConversation`: Currently viewed conversation
45 * - `activeMessages`: Messages in current conversation path
46 * - `isInitialized`: Store initialization status
47 */
48class ConversationsStore {
49 // ─────────────────────────────────────────────────────────────────────────────
50 // State
51 // ─────────────────────────────────────────────────────────────────────────────
52
53 /** List of all conversations */
54 conversations = $state<DatabaseConversation[]>([]);
55
56 /** Currently active conversation */
57 activeConversation = $state<DatabaseConversation | null>(null);
58
59 /** Messages in the active conversation (filtered by currNode path) */
60 activeMessages = $state<DatabaseMessage[]>([]);
61
62 /** Whether the store has been initialized */
63 isInitialized = $state(false);
64
65 /** Callback for title update confirmation dialog */
66 titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
67
68 // ─────────────────────────────────────────────────────────────────────────────
69 // Modalities
70 // ─────────────────────────────────────────────────────────────────────────────
71
72 /**
73 * Modalities used in the active conversation.
74 * Computed from attachments in activeMessages.
75 * Used to filter available models - models must support all used modalities.
76 */
77 usedModalities: ModelModalities = $derived.by(() => {
78 return this.calculateModalitiesFromMessages(this.activeMessages);
79 });
80
81 /**
82 * Calculate modalities from a list of messages.
83 * Helper method used by both usedModalities and getModalitiesUpToMessage.
84 */
85 private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
86 const modalities: ModelModalities = { vision: false, audio: false };
87
88 for (const message of messages) {
89 if (!message.extra) continue;
90
91 for (const extra of message.extra) {
92 if (extra.type === AttachmentType.IMAGE) {
93 modalities.vision = true;
94 }
95
96 // PDF only requires vision if processed as images
97 if (extra.type === AttachmentType.PDF) {
98 const pdfExtra = extra as DatabaseMessageExtraPdfFile;
99
100 if (pdfExtra.processedAsImages) {
101 modalities.vision = true;
102 }
103 }
104
105 if (extra.type === AttachmentType.AUDIO) {
106 modalities.audio = true;
107 }
108 }
109
110 if (modalities.vision && modalities.audio) break;
111 }
112
113 return modalities;
114 }
115
116 /**
117 * Get modalities used in messages BEFORE the specified message.
118 * Used for regeneration - only consider context that was available when generating this message.
119 */
120 getModalitiesUpToMessage(messageId: string): ModelModalities {
121 const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
122
123 if (messageIndex === -1) {
124 return this.usedModalities;
125 }
126
127 const messagesBefore = this.activeMessages.slice(0, messageIndex);
128 return this.calculateModalitiesFromMessages(messagesBefore);
129 }
130
131 constructor() {
132 if (browser) {
133 this.initialize();
134 }
135 }
136
137 // ─────────────────────────────────────────────────────────────────────────────
138 // Lifecycle
139 // ─────────────────────────────────────────────────────────────────────────────
140
141 /**
142 * Initializes the conversations store by loading conversations from the database
143 */
144 async initialize(): Promise<void> {
145 try {
146 await this.loadConversations();
147 this.isInitialized = true;
148 } catch (error) {
149 console.error('Failed to initialize conversations store:', error);
150 }
151 }
152
153 /**
154 * Loads all conversations from the database
155 */
156 async loadConversations(): Promise<void> {
157 this.conversations = await DatabaseService.getAllConversations();
158 }
159
160 // ─────────────────────────────────────────────────────────────────────────────
161 // Conversation CRUD
162 // ─────────────────────────────────────────────────────────────────────────────
163
164 /**
165 * Creates a new conversation and navigates to it
166 * @param name - Optional name for the conversation
167 * @returns The ID of the created conversation
168 */
169 async createConversation(name?: string): Promise<string> {
170 const conversationName = name || `Chat ${new Date().toLocaleString()}`;
171 const conversation = await DatabaseService.createConversation(conversationName);
172
173 this.conversations.unshift(conversation);
174 this.activeConversation = conversation;
175 this.activeMessages = [];
176
177 await goto(`#/chat/${conversation.id}`);
178
179 return conversation.id;
180 }
181
182 /**
183 * Loads a specific conversation and its messages
184 * @param convId - The conversation ID to load
185 * @returns True if conversation was loaded successfully
186 */
187 async loadConversation(convId: string): Promise<boolean> {
188 try {
189 const conversation = await DatabaseService.getConversation(convId);
190
191 if (!conversation) {
192 return false;
193 }
194
195 this.activeConversation = conversation;
196
197 if (conversation.currNode) {
198 const allMessages = await DatabaseService.getConversationMessages(convId);
199 this.activeMessages = filterByLeafNodeId(
200 allMessages,
201 conversation.currNode,
202 false
203 ) as DatabaseMessage[];
204 } else {
205 this.activeMessages = await DatabaseService.getConversationMessages(convId);
206 }
207
208 return true;
209 } catch (error) {
210 console.error('Failed to load conversation:', error);
211 return false;
212 }
213 }
214
215 /**
216 * Clears the active conversation and messages
217 * Used when navigating away from chat or starting fresh
218 */
219 clearActiveConversation(): void {
220 this.activeConversation = null;
221 this.activeMessages = [];
222 // Active processing conversation is now managed by chatStore
223 }
224
225 // ─────────────────────────────────────────────────────────────────────────────
226 // Message Management
227 // ─────────────────────────────────────────────────────────────────────────────
228
229 /**
230 * Refreshes active messages based on currNode after branch navigation
231 */
232 async refreshActiveMessages(): Promise<void> {
233 if (!this.activeConversation) return;
234
235 const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
236
237 if (allMessages.length === 0) {
238 this.activeMessages = [];
239 return;
240 }
241
242 const leafNodeId =
243 this.activeConversation.currNode ||
244 allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
245
246 const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
247
248 this.activeMessages.length = 0;
249 this.activeMessages.push(...currentPath);
250 }
251
252 /**
253 * Updates the name of a conversation
254 * @param convId - The conversation ID to update
255 * @param name - The new name for the conversation
256 */
257 async updateConversationName(convId: string, name: string): Promise<void> {
258 try {
259 await DatabaseService.updateConversation(convId, { name });
260
261 const convIndex = this.conversations.findIndex((c) => c.id === convId);
262
263 if (convIndex !== -1) {
264 this.conversations[convIndex].name = name;
265 }
266
267 if (this.activeConversation?.id === convId) {
268 this.activeConversation.name = name;
269 }
270 } catch (error) {
271 console.error('Failed to update conversation name:', error);
272 }
273 }
274
275 /**
276 * Updates conversation title with optional confirmation dialog based on settings
277 * @param convId - The conversation ID to update
278 * @param newTitle - The new title content
279 * @param onConfirmationNeeded - Callback when user confirmation is needed
280 * @returns True if title was updated, false if cancelled
281 */
282 async updateConversationTitleWithConfirmation(
283 convId: string,
284 newTitle: string,
285 onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
286 ): Promise<boolean> {
287 try {
288 const currentConfig = config();
289
290 if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) {
291 const conversation = await DatabaseService.getConversation(convId);
292 if (!conversation) return false;
293
294 const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle);
295 if (!shouldUpdate) return false;
296 }
297
298 await this.updateConversationName(convId, newTitle);
299 return true;
300 } catch (error) {
301 console.error('Failed to update conversation title with confirmation:', error);
302 return false;
303 }
304 }
305
306 // ─────────────────────────────────────────────────────────────────────────────
307 // Navigation
308 // ─────────────────────────────────────────────────────────────────────────────
309
310 /**
311 * Updates the current node of the active conversation
312 * @param nodeId - The new current node ID
313 */
314 async updateCurrentNode(nodeId: string): Promise<void> {
315 if (!this.activeConversation) return;
316
317 await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
318 this.activeConversation.currNode = nodeId;
319 }
320
321 /**
322 * Updates conversation lastModified timestamp and moves it to top of list
323 */
324 updateConversationTimestamp(): void {
325 if (!this.activeConversation) return;
326
327 const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
328
329 if (chatIndex !== -1) {
330 this.conversations[chatIndex].lastModified = Date.now();
331 const updatedConv = this.conversations.splice(chatIndex, 1)[0];
332 this.conversations.unshift(updatedConv);
333 }
334 }
335
336 /**
337 * Navigates to a specific sibling branch by updating currNode and refreshing messages
338 * @param siblingId - The sibling message ID to navigate to
339 */
340 async navigateToSibling(siblingId: string): Promise<void> {
341 if (!this.activeConversation) return;
342
343 const allMessages = await DatabaseService.getConversationMessages(this.activeConversation.id);
344 const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
345 const currentFirstUserMessage = this.activeMessages.find(
346 (m) => m.role === 'user' && m.parent === rootMessage?.id
347 );
348
349 const currentLeafNodeId = findLeafNode(allMessages, siblingId);
350
351 await DatabaseService.updateCurrentNode(this.activeConversation.id, currentLeafNodeId);
352 this.activeConversation.currNode = currentLeafNodeId;
353 await this.refreshActiveMessages();
354
355 // Only show title dialog if we're navigating between different first user message siblings
356 if (rootMessage && this.activeMessages.length > 0) {
357 const newFirstUserMessage = this.activeMessages.find(
358 (m) => m.role === 'user' && m.parent === rootMessage.id
359 );
360
361 if (
362 newFirstUserMessage &&
363 newFirstUserMessage.content.trim() &&
364 (!currentFirstUserMessage ||
365 newFirstUserMessage.id !== currentFirstUserMessage.id ||
366 newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim())
367 ) {
368 await this.updateConversationTitleWithConfirmation(
369 this.activeConversation.id,
370 newFirstUserMessage.content.trim(),
371 this.titleUpdateConfirmationCallback
372 );
373 }
374 }
375 }
376
377 /**
378 * Deletes a conversation and all its messages
379 * @param convId - The conversation ID to delete
380 */
381 async deleteConversation(convId: string): Promise<void> {
382 try {
383 await DatabaseService.deleteConversation(convId);
384
385 this.conversations = this.conversations.filter((c) => c.id !== convId);
386
387 if (this.activeConversation?.id === convId) {
388 this.clearActiveConversation();
389 await goto(`?new_chat=true#/`);
390 }
391 } catch (error) {
392 console.error('Failed to delete conversation:', error);
393 }
394 }
395
396 /**
397 * Deletes all conversations and their messages
398 */
399 async deleteAll(): Promise<void> {
400 try {
401 const allConversations = await DatabaseService.getAllConversations();
402
403 for (const conv of allConversations) {
404 await DatabaseService.deleteConversation(conv.id);
405 }
406
407 this.clearActiveConversation();
408 this.conversations = [];
409
410 toast.success('All conversations deleted');
411
412 await goto(`?new_chat=true#/`);
413 } catch (error) {
414 console.error('Failed to delete all conversations:', error);
415 toast.error('Failed to delete conversations');
416 }
417 }
418
419 // ─────────────────────────────────────────────────────────────────────────────
420 // Import/Export
421 // ─────────────────────────────────────────────────────────────────────────────
422
423 /**
424 * Downloads a conversation as JSON file
425 * @param convId - The conversation ID to download
426 */
427 async downloadConversation(convId: string): Promise<void> {
428 let conversation: DatabaseConversation | null;
429 let messages: DatabaseMessage[];
430
431 if (this.activeConversation?.id === convId) {
432 conversation = this.activeConversation;
433 messages = this.activeMessages;
434 } else {
435 conversation = await DatabaseService.getConversation(convId);
436 if (!conversation) return;
437 messages = await DatabaseService.getConversationMessages(convId);
438 }
439
440 this.triggerDownload({ conv: conversation, messages });
441 }
442
443 /**
444 * Exports all conversations with their messages as a JSON file
445 * @returns The list of exported conversations
446 */
447 async exportAllConversations(): Promise<DatabaseConversation[]> {
448 const allConversations = await DatabaseService.getAllConversations();
449
450 if (allConversations.length === 0) {
451 throw new Error('No conversations to export');
452 }
453
454 const allData = await Promise.all(
455 allConversations.map(async (conv) => {
456 const messages = await DatabaseService.getConversationMessages(conv.id);
457 return { conv, messages };
458 })
459 );
460
461 const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' });
462 const url = URL.createObjectURL(blob);
463 const a = document.createElement('a');
464 a.href = url;
465 a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`;
466 document.body.appendChild(a);
467 a.click();
468 document.body.removeChild(a);
469 URL.revokeObjectURL(url);
470
471 toast.success(`All conversations (${allConversations.length}) prepared for download`);
472
473 return allConversations;
474 }
475
476 /**
477 * Imports conversations from a JSON file
478 * Opens file picker and processes the selected file
479 * @returns The list of imported conversations
480 */
481 async importConversations(): Promise<DatabaseConversation[]> {
482 return new Promise((resolve, reject) => {
483 const input = document.createElement('input');
484 input.type = 'file';
485 input.accept = '.json';
486
487 input.onchange = async (e) => {
488 const file = (e.target as HTMLInputElement)?.files?.[0];
489
490 if (!file) {
491 reject(new Error('No file selected'));
492 return;
493 }
494
495 try {
496 const text = await file.text();
497 const parsedData = JSON.parse(text);
498 let importedData: ExportedConversations;
499
500 if (Array.isArray(parsedData)) {
501 importedData = parsedData;
502 } else if (
503 parsedData &&
504 typeof parsedData === 'object' &&
505 'conv' in parsedData &&
506 'messages' in parsedData
507 ) {
508 importedData = [parsedData];
509 } else {
510 throw new Error('Invalid file format');
511 }
512
513 const result = await DatabaseService.importConversations(importedData);
514 toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`);
515
516 await this.loadConversations();
517
518 const importedConversations = (
519 Array.isArray(importedData) ? importedData : [importedData]
520 ).map((item) => item.conv);
521
522 resolve(importedConversations);
523 } catch (err: unknown) {
524 const message = err instanceof Error ? err.message : 'Unknown error';
525 console.error('Failed to import conversations:', err);
526 toast.error('Import failed', { description: message });
527 reject(new Error(`Import failed: ${message}`));
528 }
529 };
530
531 input.click();
532 });
533 }
534
535 /**
536 * Gets all messages for a specific conversation
537 * @param convId - The conversation ID
538 * @returns Array of messages
539 */
540 async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
541 return await DatabaseService.getConversationMessages(convId);
542 }
543
544 /**
545 * Imports conversations from provided data (without file picker)
546 * @param data - Array of conversation data with messages
547 * @returns Import result with counts
548 */
549 async importConversationsData(
550 data: ExportedConversations
551 ): Promise<{ imported: number; skipped: number }> {
552 const result = await DatabaseService.importConversations(data);
553 await this.loadConversations();
554 return result;
555 }
556
557 /**
558 * Adds a message to the active messages array
559 * Used by chatStore when creating new messages
560 * @param message - The message to add
561 */
562 addMessageToActive(message: DatabaseMessage): void {
563 this.activeMessages.push(message);
564 }
565
566 /**
567 * Updates a message at a specific index in active messages
568 * Creates a new object to trigger Svelte 5 reactivity
569 * @param index - The index of the message to update
570 * @param updates - Partial message data to update
571 */
572 updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
573 if (index !== -1 && this.activeMessages[index]) {
574 // Create new object to trigger Svelte 5 reactivity
575 this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
576 }
577 }
578
579 /**
580 * Finds the index of a message in active messages
581 * @param messageId - The message ID to find
582 * @returns The index of the message, or -1 if not found
583 */
584 findMessageIndex(messageId: string): number {
585 return this.activeMessages.findIndex((m) => m.id === messageId);
586 }
587
588 /**
589 * Removes messages from active messages starting at an index
590 * @param startIndex - The index to start removing from
591 */
592 sliceActiveMessages(startIndex: number): void {
593 this.activeMessages = this.activeMessages.slice(0, startIndex);
594 }
595
596 /**
597 * Removes a message from active messages by index
598 * @param index - The index to remove
599 * @returns The removed message or undefined
600 */
601 removeMessageAtIndex(index: number): DatabaseMessage | undefined {
602 if (index !== -1) {
603 return this.activeMessages.splice(index, 1)[0];
604 }
605 return undefined;
606 }
607
608 /**
609 * Triggers file download in browser
610 * @param data - The data to download
611 * @param filename - Optional filename for the download
612 */
613 private triggerDownload(data: ExportedConversations, filename?: string): void {
614 const conversation =
615 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined;
616
617 if (!conversation) {
618 console.error('Invalid data: missing conversation');
619 return;
620 }
621
622 const conversationName = conversation.name?.trim() || '';
623 const truncatedSuffix = conversationName
624 .toLowerCase()
625 .replace(/[^a-z0-9]/gi, '_')
626 .replace(/_+/g, '_')
627 .substring(0, 20);
628 const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`;
629
630 const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
631 const url = URL.createObjectURL(blob);
632 const a = document.createElement('a');
633 a.href = url;
634 a.download = downloadFilename;
635 document.body.appendChild(a);
636 a.click();
637 document.body.removeChild(a);
638 URL.revokeObjectURL(url);
639 }
640
641 // ─────────────────────────────────────────────────────────────────────────────
642 // Utilities
643 // ─────────────────────────────────────────────────────────────────────────────
644
645 /**
646 * Sets the callback function for title update confirmations
647 * @param callback - Function to call when confirmation is needed
648 */
649 setTitleUpdateConfirmationCallback(
650 callback: (currentTitle: string, newTitle: string) => Promise<boolean>
651 ): void {
652 this.titleUpdateConfirmationCallback = callback;
653 }
654}
655
656export const conversationsStore = new ConversationsStore();
657
658export const conversations = () => conversationsStore.conversations;
659export const activeConversation = () => conversationsStore.activeConversation;
660export const activeMessages = () => conversationsStore.activeMessages;
661export const isConversationsInitialized = () => conversationsStore.isInitialized;
662export const usedModalities = () => conversationsStore.usedModalities;