/** * ConversationsClient - Business Logic Facade for Conversation Operations * * Coordinates conversation lifecycle, persistence, and navigation. * * **Architecture & Relationships:** * - **ConversationsClient** (this class): Business logic facade * - Uses DatabaseService for IndexedDB operations * - Updates conversationsStore with reactive state * - Handles CRUD, import/export, branch navigation * * - **DatabaseService**: Stateless IndexedDB layer * - **conversationsStore**: Reactive state only ($state) * * **Key Responsibilities:** * - Conversation lifecycle (create, load, delete) * - Message management and tree navigation * - MCP server per-chat overrides * - Import/Export functionality * - Title management with confirmation */ import { goto } from '$app/navigation'; import { toast } from 'svelte-sonner'; import { DatabaseService } from '$lib/services/database.service'; import { config } from '$lib/stores/settings.svelte'; import { filterByLeafNodeId, findLeafNode } from '$lib/utils'; import type { McpServerOverride } from '$lib/types/database'; interface ConversationsStoreStateCallbacks { getConversations: () => DatabaseConversation[]; setConversations: (conversations: DatabaseConversation[]) => void; getActiveConversation: () => DatabaseConversation | null; setActiveConversation: (conversation: DatabaseConversation | null) => void; getActiveMessages: () => DatabaseMessage[]; setActiveMessages: (messages: DatabaseMessage[]) => void; updateActiveMessages: (updater: (messages: DatabaseMessage[]) => DatabaseMessage[]) => void; setInitialized: (initialized: boolean) => void; getPendingMcpServerOverrides: () => McpServerOverride[]; setPendingMcpServerOverrides: (overrides: McpServerOverride[]) => void; getTitleUpdateConfirmationCallback: () => | ((currentTitle: string, newTitle: string) => Promise) | undefined; } export class ConversationsClient { private storeCallbacks: ConversationsStoreStateCallbacks | null = null; /** * * * Store Integration * * */ /** * Sets callbacks for store state updates. * Called by conversationsStore during initialization. */ setStoreCallbacks(callbacks: ConversationsStoreStateCallbacks): void { this.storeCallbacks = callbacks; } private get store(): ConversationsStoreStateCallbacks { if (!this.storeCallbacks) { throw new Error('ConversationsClient: Store callbacks not initialized'); } return this.storeCallbacks; } /** * * * Lifecycle * * */ /** * Initializes the conversations by loading from the database. */ async initialize(): Promise { try { await this.loadConversations(); this.store.setInitialized(true); } catch (error) { console.error('Failed to initialize conversations:', error); } } /** * Loads all conversations from the database */ async loadConversations(): Promise { const conversations = await DatabaseService.getAllConversations(); this.store.setConversations(conversations); } /** * Creates a new conversation and navigates to it * @param name - Optional name for the conversation * @returns The ID of the created conversation */ async createConversation(name?: string): Promise { const conversationName = name || `Chat ${new Date().toLocaleString()}`; const conversation = await DatabaseService.createConversation(conversationName); const pendingOverrides = this.store.getPendingMcpServerOverrides(); if (pendingOverrides.length > 0) { // Deep clone to plain objects (Svelte 5 $state uses Proxies which can't be cloned to IndexedDB) const plainOverrides = pendingOverrides.map((o) => ({ serverId: o.serverId, enabled: o.enabled })); conversation.mcpServerOverrides = plainOverrides; await DatabaseService.updateConversation(conversation.id, { mcpServerOverrides: plainOverrides }); this.store.setPendingMcpServerOverrides([]); } const conversations = this.store.getConversations(); this.store.setConversations([conversation, ...conversations]); this.store.setActiveConversation(conversation); this.store.setActiveMessages([]); await goto(`#/chat/${conversation.id}`); return conversation.id; } /** * Loads a specific conversation and its messages * @param convId - The conversation ID to load * @returns True if conversation was loaded successfully */ async loadConversation(convId: string): Promise { try { const conversation = await DatabaseService.getConversation(convId); if (!conversation) { return false; } this.store.setPendingMcpServerOverrides([]); this.store.setActiveConversation(conversation); if (conversation.currNode) { const allMessages = await DatabaseService.getConversationMessages(convId); const filteredMessages = filterByLeafNodeId( allMessages, conversation.currNode, false ) as DatabaseMessage[]; this.store.setActiveMessages(filteredMessages); } else { const messages = await DatabaseService.getConversationMessages(convId); this.store.setActiveMessages(messages); } return true; } catch (error) { console.error('Failed to load conversation:', error); return false; } } /** * * * Conversation CRUD * * */ /** * Clears the active conversation and messages. */ clearActiveConversation(): void { this.store.setActiveConversation(null); this.store.setActiveMessages([]); } /** * Deletes a conversation and all its messages * @param convId - The conversation ID to delete */ async deleteConversation(convId: string): Promise { try { await DatabaseService.deleteConversation(convId); const conversations = this.store.getConversations(); this.store.setConversations(conversations.filter((c) => c.id !== convId)); const activeConv = this.store.getActiveConversation(); if (activeConv?.id === convId) { this.clearActiveConversation(); await goto(`?new_chat=true#/`); } } catch (error) { console.error('Failed to delete conversation:', error); } } /** * Deletes all conversations and their messages */ async deleteAll(): Promise { try { const allConversations = await DatabaseService.getAllConversations(); for (const conv of allConversations) { await DatabaseService.deleteConversation(conv.id); } this.clearActiveConversation(); this.store.setConversations([]); toast.success('All conversations deleted'); await goto(`?new_chat=true#/`); } catch (error) { console.error('Failed to delete all conversations:', error); toast.error('Failed to delete conversations'); } } /** * * * Message Management * * */ /** * Refreshes active messages based on currNode after branch navigation. */ async refreshActiveMessages(): Promise { const activeConv = this.store.getActiveConversation(); if (!activeConv) return; const allMessages = await DatabaseService.getConversationMessages(activeConv.id); if (allMessages.length === 0) { this.store.setActiveMessages([]); return; } const leafNodeId = activeConv.currNode || allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id; const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[]; this.store.setActiveMessages(currentPath); } /** * Gets all messages for a specific conversation * @param convId - The conversation ID * @returns Array of messages */ async getConversationMessages(convId: string): Promise { return await DatabaseService.getConversationMessages(convId); } /** * * * Title Management * * */ /** * Updates the name of a conversation. * @param convId - The conversation ID to update * @param name - The new name for the conversation */ async updateConversationName(convId: string, name: string): Promise { try { await DatabaseService.updateConversation(convId, { name }); const conversations = this.store.getConversations(); const convIndex = conversations.findIndex((c) => c.id === convId); if (convIndex !== -1) { conversations[convIndex].name = name; this.store.setConversations([...conversations]); } const activeConv = this.store.getActiveConversation(); if (activeConv?.id === convId) { this.store.setActiveConversation({ ...activeConv, name }); } } catch (error) { console.error('Failed to update conversation name:', error); } } /** * Updates conversation title with optional confirmation dialog based on settings * @param convId - The conversation ID to update * @param newTitle - The new title content * @returns True if title was updated, false if cancelled */ async updateConversationTitleWithConfirmation( convId: string, newTitle: string ): Promise { try { const currentConfig = config(); const onConfirmationNeeded = this.store.getTitleUpdateConfirmationCallback(); if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) { const conversation = await DatabaseService.getConversation(convId); if (!conversation) return false; const shouldUpdate = await onConfirmationNeeded(conversation.name, newTitle); if (!shouldUpdate) return false; } await this.updateConversationName(convId, newTitle); return true; } catch (error) { console.error('Failed to update conversation title with confirmation:', error); return false; } } /** * Updates conversation lastModified timestamp and moves it to top of list */ updateConversationTimestamp(): void { const activeConv = this.store.getActiveConversation(); if (!activeConv) return; const conversations = this.store.getConversations(); const chatIndex = conversations.findIndex((c) => c.id === activeConv.id); if (chatIndex !== -1) { conversations[chatIndex].lastModified = Date.now(); const updatedConv = conversations.splice(chatIndex, 1)[0]; this.store.setConversations([updatedConv, ...conversations]); } } /** * Updates the current node of the active conversation * @param nodeId - The new current node ID */ async updateCurrentNode(nodeId: string): Promise { const activeConv = this.store.getActiveConversation(); if (!activeConv) return; await DatabaseService.updateCurrentNode(activeConv.id, nodeId); this.store.setActiveConversation({ ...activeConv, currNode: nodeId }); } /** * * * Branch Navigation * * */ /** * Navigates to a specific sibling branch by updating currNode and refreshing messages. * @param siblingId - The sibling message ID to navigate to */ async navigateToSibling(siblingId: string): Promise { const activeConv = this.store.getActiveConversation(); if (!activeConv) return; const allMessages = await DatabaseService.getConversationMessages(activeConv.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const activeMessages = this.store.getActiveMessages(); const currentFirstUserMessage = activeMessages.find( (m) => m.role === 'user' && m.parent === rootMessage?.id ); const currentLeafNodeId = findLeafNode(allMessages, siblingId); await DatabaseService.updateCurrentNode(activeConv.id, currentLeafNodeId); this.store.setActiveConversation({ ...activeConv, currNode: currentLeafNodeId }); await this.refreshActiveMessages(); const updatedActiveMessages = this.store.getActiveMessages(); if (rootMessage && updatedActiveMessages.length > 0) { const newFirstUserMessage = updatedActiveMessages.find( (m) => m.role === 'user' && m.parent === rootMessage.id ); if ( newFirstUserMessage && newFirstUserMessage.content.trim() && (!currentFirstUserMessage || newFirstUserMessage.id !== currentFirstUserMessage.id || newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim()) ) { await this.updateConversationTitleWithConfirmation( activeConv.id, newFirstUserMessage.content.trim() ); } } } /** * * * MCP Server Overrides * * */ /** * Gets MCP server override for a specific server in the active conversation. * Falls back to pending overrides if no active conversation exists. * @param serverId - The server ID to check * @returns The override if set, undefined if using global setting */ getMcpServerOverride(serverId: string): McpServerOverride | undefined { const activeConv = this.store.getActiveConversation(); if (activeConv) { return activeConv.mcpServerOverrides?.find((o: McpServerOverride) => o.serverId === serverId); } return this.store.getPendingMcpServerOverrides().find((o) => o.serverId === serverId); } /** * Checks if an MCP server is enabled for the active conversation. * @param serverId - The server ID to check * @returns True if server is enabled for this conversation */ isMcpServerEnabledForChat(serverId: string): boolean { const override = this.getMcpServerOverride(serverId); return override?.enabled ?? false; } /** * Sets or removes MCP server override for the active conversation. * If no conversation exists, stores as pending override. * @param serverId - The server ID to override * @param enabled - The enabled state, or undefined to remove override */ async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise { const activeConv = this.store.getActiveConversation(); if (!activeConv) { this.setPendingMcpServerOverride(serverId, enabled); return; } // Clone to plain objects to avoid Proxy serialization issues with IndexedDB const currentOverrides = (activeConv.mcpServerOverrides || []).map((o: McpServerOverride) => ({ serverId: o.serverId, enabled: o.enabled })); let newOverrides: McpServerOverride[]; if (enabled === undefined) { newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId); } else { const existingIndex = currentOverrides.findIndex( (o: McpServerOverride) => o.serverId === serverId ); if (existingIndex >= 0) { newOverrides = [...currentOverrides]; newOverrides[existingIndex] = { serverId, enabled }; } else { newOverrides = [...currentOverrides, { serverId, enabled }]; } } await DatabaseService.updateConversation(activeConv.id, { mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined }); const updatedConv = { ...activeConv, mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined }; this.store.setActiveConversation(updatedConv); const conversations = this.store.getConversations(); const convIndex = conversations.findIndex((c) => c.id === activeConv.id); if (convIndex !== -1) { conversations[convIndex].mcpServerOverrides = newOverrides.length > 0 ? newOverrides : undefined; this.store.setConversations([...conversations]); } } /** * Toggles MCP server enabled state for the active conversation. * @param serverId - The server ID to toggle */ async toggleMcpServerForChat(serverId: string): Promise { const currentEnabled = this.isMcpServerEnabledForChat(serverId); await this.setMcpServerOverride(serverId, !currentEnabled); } /** * Removes MCP server override for the active conversation. * @param serverId - The server ID to remove override for */ async removeMcpServerOverride(serverId: string): Promise { await this.setMcpServerOverride(serverId, undefined); } /** * Sets or removes a pending MCP server override (for new conversations). */ private setPendingMcpServerOverride(serverId: string, enabled: boolean | undefined): void { const pendingOverrides = this.store.getPendingMcpServerOverrides(); if (enabled === undefined) { this.store.setPendingMcpServerOverrides( pendingOverrides.filter((o) => o.serverId !== serverId) ); } else { const existingIndex = pendingOverrides.findIndex((o) => o.serverId === serverId); if (existingIndex >= 0) { const newOverrides = [...pendingOverrides]; newOverrides[existingIndex] = { serverId, enabled }; this.store.setPendingMcpServerOverrides(newOverrides); } else { this.store.setPendingMcpServerOverrides([...pendingOverrides, { serverId, enabled }]); } } } /** * Clears all pending MCP server overrides. */ clearPendingMcpServerOverrides(): void { this.store.setPendingMcpServerOverrides([]); } /** * * * Import & Export * * */ /** * Downloads a conversation as JSON file. * @param convId - The conversation ID to download */ async downloadConversation(convId: string): Promise { let conversation: DatabaseConversation | null; let messages: DatabaseMessage[]; const activeConv = this.store.getActiveConversation(); if (activeConv?.id === convId) { conversation = activeConv; messages = this.store.getActiveMessages(); } else { conversation = await DatabaseService.getConversation(convId); if (!conversation) return; messages = await DatabaseService.getConversationMessages(convId); } this.triggerDownload({ conv: conversation, messages }); } /** * Exports all conversations with their messages as a JSON file * @returns The list of exported conversations */ async exportAllConversations(): Promise { const allConversations = await DatabaseService.getAllConversations(); if (allConversations.length === 0) { throw new Error('No conversations to export'); } const allData = await Promise.all( allConversations.map(async (conv) => { const messages = await DatabaseService.getConversationMessages(conv.id); return { conv, messages }; }) ); const blob = new Blob([JSON.stringify(allData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `all_conversations_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success(`All conversations (${allConversations.length}) prepared for download`); return allConversations; } /** * Imports conversations from a JSON file * Opens file picker and processes the selected file * @returns The list of imported conversations */ async importConversations(): Promise { return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = async (e) => { const file = (e.target as HTMLInputElement)?.files?.[0]; if (!file) { reject(new Error('No file selected')); return; } try { const text = await file.text(); const parsedData = JSON.parse(text); let importedData: ExportedConversations; if (Array.isArray(parsedData)) { importedData = parsedData; } else if ( parsedData && typeof parsedData === 'object' && 'conv' in parsedData && 'messages' in parsedData ) { importedData = [parsedData]; } else { throw new Error('Invalid file format'); } const result = await DatabaseService.importConversations(importedData); toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`); await this.loadConversations(); const importedConversations = ( Array.isArray(importedData) ? importedData : [importedData] ).map((item) => item.conv); resolve(importedConversations); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Unknown error'; console.error('Failed to import conversations:', err); toast.error('Import failed', { description: message }); reject(new Error(`Import failed: ${message}`)); } }; input.click(); }); } /** * Imports conversations from provided data (without file picker) * @param data - Array of conversation data with messages * @returns Import result with counts */ async importConversationsData( data: ExportedConversations ): Promise<{ imported: number; skipped: number }> { const result = await DatabaseService.importConversations(data); await this.loadConversations(); return result; } /** * Triggers file download in browser */ private triggerDownload(data: ExportedConversations, filename?: string): void { const conversation = 'conv' in data ? data.conv : Array.isArray(data) ? data[0]?.conv : undefined; if (!conversation) { console.error('Invalid data: missing conversation'); return; } const conversationName = conversation.name?.trim() || ''; const truncatedSuffix = conversationName .toLowerCase() .replace(/[^a-z0-9]/gi, '_') .replace(/_+/g, '_') .substring(0, 20); const downloadFilename = filename || `conversation_${conversation.id}_${truncatedSuffix}.json`; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = downloadFilename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } } export const conversationsClient = new ConversationsClient();