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;