import { DatabaseStore } from '$lib/stores/database'; import { chatService, slotsService } from '$lib/services'; import { config } from '$lib/stores/settings.svelte'; import { serverStore } from '$lib/stores/server.svelte'; import { normalizeModelName } from '$lib/utils/model-names'; import { filterByLeafNodeId, findLeafNode, findDescendantMessages } from '$lib/utils/branching'; import { browser } from '$app/environment'; import { goto } from '$app/navigation'; import { toast } from 'svelte-sonner'; import { SvelteMap } from 'svelte/reactivity'; import type { ExportedConversations } from '$lib/types/database'; /** * ChatStore - Central state management for chat conversations and AI interactions * * This store manages the complete chat experience including: * - Conversation lifecycle (create, load, delete, update) * - Message management with branching support for conversation trees * - Real-time AI response streaming with reasoning content support * - File attachment handling and processing * - Context error management and recovery * - Database persistence through DatabaseStore integration * * **Architecture & Relationships:** * - **ChatService**: Handles low-level API communication with AI models * - ChatStore orchestrates ChatService for streaming responses * - ChatService provides abort capabilities and error handling * - ChatStore manages the UI state while ChatService handles network layer * * - **DatabaseStore**: Provides persistent storage for conversations and messages * - ChatStore uses DatabaseStore for all CRUD operations * - Maintains referential integrity for conversation trees * - Handles message branching and parent-child relationships * * - **SlotsService**: Monitors server resource usage during AI generation * - ChatStore coordinates slots polling during streaming * - Provides real-time feedback on server capacity * * **Key Features:** * - Reactive state management using Svelte 5 runes ($state) * - Conversation branching for exploring different response paths * - Streaming AI responses with real-time content updates * - File attachment support (images, PDFs, text files, audio) * - Partial response saving when generation is interrupted * - Message editing with automatic response regeneration */ class ChatStore { activeConversation = $state(null); activeMessages = $state([]); conversations = $state([]); currentResponse = $state(''); errorDialogState = $state<{ type: 'timeout' | 'server'; message: string } | null>(null); isInitialized = $state(false); isLoading = $state(false); conversationLoadingStates = new SvelteMap(); conversationStreamingStates = new SvelteMap(); titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise; constructor() { if (browser) { this.initialize(); } } /** * Initializes the chat store by loading conversations from the database * Sets up the initial state and loads existing conversations */ async initialize(): Promise { try { await this.loadConversations(); this.isInitialized = true; } catch (error) { console.error('Failed to initialize chat store:', error); } } /** * Loads all conversations from the database * Refreshes the conversations list from persistent storage */ async loadConversations(): Promise { this.conversations = await DatabaseStore.getAllConversations(); } /** * Creates a new conversation and navigates to it * @param name - Optional name for the conversation, defaults to timestamped name * @returns The ID of the created conversation */ async createConversation(name?: string): Promise { const conversationName = name || `Chat ${new Date().toLocaleString()}`; const conversation = await DatabaseStore.createConversation(conversationName); this.conversations.unshift(conversation); this.activeConversation = conversation; this.activeMessages = []; slotsService.setActiveConversation(conversation.id); const isConvLoading = this.isConversationLoading(conversation.id); this.isLoading = isConvLoading; this.currentResponse = ''; 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, false otherwise */ async loadConversation(convId: string): Promise { try { const conversation = await DatabaseStore.getConversation(convId); if (!conversation) { return false; } this.activeConversation = conversation; slotsService.setActiveConversation(convId); const isConvLoading = this.isConversationLoading(convId); this.isLoading = isConvLoading; const streamingState = this.getConversationStreaming(convId); this.currentResponse = streamingState?.response || ''; if (conversation.currNode) { const allMessages = await DatabaseStore.getConversationMessages(convId); this.activeMessages = filterByLeafNodeId( allMessages, conversation.currNode, false ) as DatabaseMessage[]; } else { // Load all messages for conversations without currNode (backward compatibility) this.activeMessages = await DatabaseStore.getConversationMessages(convId); } return true; } catch (error) { console.error('Failed to load conversation:', error); return false; } } /** * Adds a new message to the active conversation * @param role - The role of the message sender (user/assistant) * @param content - The message content * @param type - The message type, defaults to 'text' * @param parent - Parent message ID, defaults to '-1' for auto-detection * @param extras - Optional extra data (files, attachments, etc.) * @returns The created message or null if failed */ async addMessage( role: ChatRole, content: string, type: ChatMessageType = 'text', parent: string = '-1', extras?: DatabaseMessageExtra[] ): Promise { if (!this.activeConversation) { console.error('No active conversation when trying to add message'); return null; } try { let parentId: string | null = null; if (parent === '-1') { if (this.activeMessages.length > 0) { parentId = this.activeMessages[this.activeMessages.length - 1].id; } else { const allMessages = await DatabaseStore.getConversationMessages( this.activeConversation.id ); const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root'); if (!rootMessage) { const rootId = await DatabaseStore.createRootMessage(this.activeConversation.id); parentId = rootId; } else { parentId = rootMessage.id; } } } else { parentId = parent; } const message = await DatabaseStore.createMessageBranch( { convId: this.activeConversation.id, role, content, type, timestamp: Date.now(), thinking: '', toolCalls: '', children: [], extra: extras }, parentId ); this.activeMessages.push(message); await DatabaseStore.updateCurrentNode(this.activeConversation.id, message.id); this.activeConversation.currNode = message.id; this.updateConversationTimestamp(); return message; } catch (error) { console.error('Failed to add message:', error); return null; } } /** * Gets API options from current configuration settings * Converts settings store values to API-compatible format * @returns API options object for chat completion requests */ private getApiOptions(): Record { const currentConfig = config(); const hasValue = (value: unknown): boolean => value !== undefined && value !== null && value !== ''; const apiOptions: Record = { stream: true, timings_per_token: true }; if (hasValue(currentConfig.temperature)) { apiOptions.temperature = Number(currentConfig.temperature); } if (hasValue(currentConfig.max_tokens)) { apiOptions.max_tokens = Number(currentConfig.max_tokens); } if (hasValue(currentConfig.dynatemp_range)) { apiOptions.dynatemp_range = Number(currentConfig.dynatemp_range); } if (hasValue(currentConfig.dynatemp_exponent)) { apiOptions.dynatemp_exponent = Number(currentConfig.dynatemp_exponent); } if (hasValue(currentConfig.top_k)) { apiOptions.top_k = Number(currentConfig.top_k); } if (hasValue(currentConfig.top_p)) { apiOptions.top_p = Number(currentConfig.top_p); } if (hasValue(currentConfig.min_p)) { apiOptions.min_p = Number(currentConfig.min_p); } if (hasValue(currentConfig.xtc_probability)) { apiOptions.xtc_probability = Number(currentConfig.xtc_probability); } if (hasValue(currentConfig.xtc_threshold)) { apiOptions.xtc_threshold = Number(currentConfig.xtc_threshold); } if (hasValue(currentConfig.typ_p)) { apiOptions.typ_p = Number(currentConfig.typ_p); } if (hasValue(currentConfig.repeat_last_n)) { apiOptions.repeat_last_n = Number(currentConfig.repeat_last_n); } if (hasValue(currentConfig.repeat_penalty)) { apiOptions.repeat_penalty = Number(currentConfig.repeat_penalty); } if (hasValue(currentConfig.presence_penalty)) { apiOptions.presence_penalty = Number(currentConfig.presence_penalty); } if (hasValue(currentConfig.frequency_penalty)) { apiOptions.frequency_penalty = Number(currentConfig.frequency_penalty); } if (hasValue(currentConfig.dry_multiplier)) { apiOptions.dry_multiplier = Number(currentConfig.dry_multiplier); } if (hasValue(currentConfig.dry_base)) { apiOptions.dry_base = Number(currentConfig.dry_base); } if (hasValue(currentConfig.dry_allowed_length)) { apiOptions.dry_allowed_length = Number(currentConfig.dry_allowed_length); } if (hasValue(currentConfig.dry_penalty_last_n)) { apiOptions.dry_penalty_last_n = Number(currentConfig.dry_penalty_last_n); } if (currentConfig.samplers) { apiOptions.samplers = currentConfig.samplers; } if (currentConfig.backend_sampling !== undefined) { apiOptions.backend_sampling = Boolean(currentConfig.backend_sampling); } if (currentConfig.backend_dist !== undefined) { apiOptions.backend_dist = Boolean(currentConfig.backend_dist); } if (currentConfig.custom) { apiOptions.custom = currentConfig.custom; } return apiOptions; } /** * Helper methods for per-conversation loading state management */ private setConversationLoading(convId: string, loading: boolean): void { if (loading) { this.conversationLoadingStates.set(convId, true); if (this.activeConversation?.id === convId) { this.isLoading = true; } } else { this.conversationLoadingStates.delete(convId); if (this.activeConversation?.id === convId) { this.isLoading = false; } } } private isConversationLoading(convId: string): boolean { return this.conversationLoadingStates.get(convId) || false; } private setConversationStreaming(convId: string, response: string, messageId: string): void { this.conversationStreamingStates.set(convId, { response, messageId }); if (this.activeConversation?.id === convId) { this.currentResponse = response; } } private clearConversationStreaming(convId: string): void { this.conversationStreamingStates.delete(convId); if (this.activeConversation?.id === convId) { this.currentResponse = ''; } } private getConversationStreaming( convId: string ): { response: string; messageId: string } | undefined { return this.conversationStreamingStates.get(convId); } /** * Handles streaming chat completion with the AI model * @param allMessages - All messages in the conversation * @param assistantMessage - The assistant message to stream content into * @param onComplete - Optional callback when streaming completes * @param onError - Optional callback when an error occurs */ private async streamChatCompletion( allMessages: DatabaseMessage[], assistantMessage: DatabaseMessage, onComplete?: (content: string) => Promise, onError?: (error: Error) => void ): Promise { let streamedContent = ''; let streamedReasoningContent = ''; let streamedToolCallContent = ''; let resolvedModel: string | null = null; let modelPersisted = false; const currentConfig = config(); const preferServerPropsModel = !currentConfig.modelSelectorEnabled; let serverPropsRefreshed = false; let updateModelFromServerProps: ((persistImmediately?: boolean) => void) | null = null; const refreshServerPropsOnce = () => { if (serverPropsRefreshed) { return; } serverPropsRefreshed = true; const hasExistingProps = serverStore.serverProps !== null; serverStore .fetchServerProps({ silent: hasExistingProps }) .then(() => { updateModelFromServerProps?.(true); }) .catch((error) => { console.warn('Failed to refresh server props after streaming started:', error); }); }; const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => { const serverModelName = serverStore.modelName; const preferredModelSource = preferServerPropsModel ? (serverModelName ?? modelName ?? null) : (modelName ?? serverModelName ?? null); if (!preferredModelSource) { return; } const normalizedModel = normalizeModelName(preferredModelSource); if (!normalizedModel || normalizedModel === resolvedModel) { return; } resolvedModel = normalizedModel; const messageIndex = this.findMessageIndex(assistantMessage.id); this.updateMessageAtIndex(messageIndex, { model: normalizedModel }); if (persistImmediately && !modelPersisted) { modelPersisted = true; DatabaseStore.updateMessage(assistantMessage.id, { model: normalizedModel }).catch( (error) => { console.error('Failed to persist model name:', error); modelPersisted = false; resolvedModel = null; } ); } }; if (preferServerPropsModel) { updateModelFromServerProps = (persistImmediately = true) => { const currentServerModel = serverStore.modelName; if (!currentServerModel) { return; } recordModel(currentServerModel, persistImmediately); }; updateModelFromServerProps(false); } slotsService.startStreaming(); slotsService.setActiveConversation(assistantMessage.convId); await chatService.sendMessage( allMessages, { ...this.getApiOptions(), onFirstValidChunk: () => { refreshServerPropsOnce(); }, onChunk: (chunk: string) => { streamedContent += chunk; this.setConversationStreaming( assistantMessage.convId, streamedContent, assistantMessage.id ); const messageIndex = this.findMessageIndex(assistantMessage.id); this.updateMessageAtIndex(messageIndex, { content: streamedContent }); }, onReasoningChunk: (reasoningChunk: string) => { streamedReasoningContent += reasoningChunk; const messageIndex = this.findMessageIndex(assistantMessage.id); this.updateMessageAtIndex(messageIndex, { thinking: streamedReasoningContent }); }, onToolCallChunk: (toolCallChunk: string) => { const chunk = toolCallChunk.trim(); if (!chunk) { return; } streamedToolCallContent = chunk; const messageIndex = this.findMessageIndex(assistantMessage.id); this.updateMessageAtIndex(messageIndex, { toolCalls: streamedToolCallContent }); }, onModel: (modelName: string) => { recordModel(modelName); }, onComplete: async ( finalContent?: string, reasoningContent?: string, timings?: ChatMessageTimings, toolCallContent?: string ) => { slotsService.stopStreaming(); const updateData: { content: string; thinking: string; toolCalls: string; timings?: ChatMessageTimings; model?: string; } = { content: finalContent || streamedContent, thinking: reasoningContent || streamedReasoningContent, toolCalls: toolCallContent || streamedToolCallContent, timings: timings }; if (resolvedModel && !modelPersisted) { updateData.model = resolvedModel; modelPersisted = true; } await DatabaseStore.updateMessage(assistantMessage.id, updateData); const messageIndex = this.findMessageIndex(assistantMessage.id); const localUpdateData: { timings?: ChatMessageTimings; model?: string; toolCalls?: string; } = { timings: timings }; if (updateData.model) { localUpdateData.model = updateData.model; } if (updateData.toolCalls !== undefined) { localUpdateData.toolCalls = updateData.toolCalls; } this.updateMessageAtIndex(messageIndex, localUpdateData); await DatabaseStore.updateCurrentNode(assistantMessage.convId, assistantMessage.id); if (this.activeConversation?.id === assistantMessage.convId) { this.activeConversation.currNode = assistantMessage.id; await this.refreshActiveMessages(); } if (onComplete) { await onComplete(streamedContent); } this.setConversationLoading(assistantMessage.convId, false); this.clearConversationStreaming(assistantMessage.convId); slotsService.clearConversationState(assistantMessage.convId); }, onError: (error: Error) => { slotsService.stopStreaming(); if (this.isAbortError(error)) { this.setConversationLoading(assistantMessage.convId, false); this.clearConversationStreaming(assistantMessage.convId); slotsService.clearConversationState(assistantMessage.convId); return; } console.error('Streaming error:', error); this.setConversationLoading(assistantMessage.convId, false); this.clearConversationStreaming(assistantMessage.convId); slotsService.clearConversationState(assistantMessage.convId); const messageIndex = this.activeMessages.findIndex( (m: DatabaseMessage) => m.id === assistantMessage.id ); if (messageIndex !== -1) { const [failedMessage] = this.activeMessages.splice(messageIndex, 1); if (failedMessage) { DatabaseStore.deleteMessage(failedMessage.id).catch((cleanupError) => { console.error('Failed to remove assistant message after error:', cleanupError); }); } } const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server'; this.showErrorDialog(dialogType, error.message); if (onError) { onError(error); } } }, assistantMessage.convId ); } /** * Checks if an error is an abort error (user cancelled operation) * @param error - The error to check * @returns True if the error is an abort error */ private isAbortError(error: unknown): boolean { return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException); } private showErrorDialog(type: 'timeout' | 'server', message: string): void { this.errorDialogState = { type, message }; } dismissErrorDialog(): void { this.errorDialogState = null; } /** * Finds the index of a message in the active messages array * @param messageId - The message ID to find * @returns The index of the message, or -1 if not found */ private findMessageIndex(messageId: string): number { return this.activeMessages.findIndex((m) => m.id === messageId); } /** * Updates a message at a specific index with partial data * @param index - The index of the message to update * @param updates - Partial message data to update */ private updateMessageAtIndex(index: number, updates: Partial): void { if (index !== -1) { Object.assign(this.activeMessages[index], updates); } } /** * Creates a new assistant message in the database * @param parentId - Optional parent message ID, defaults to '-1' * @returns The created assistant message or null if failed */ private async createAssistantMessage(parentId?: string): Promise { if (!this.activeConversation) return null; return await DatabaseStore.createMessageBranch( { convId: this.activeConversation.id, type: 'text', role: 'assistant', content: '', timestamp: Date.now(), thinking: '', toolCalls: '', children: [], model: null }, parentId || null ); } /** * Updates conversation lastModified timestamp and moves it to top of list * Ensures recently active conversations appear first in the sidebar */ private updateConversationTimestamp(): void { if (!this.activeConversation) return; const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id); if (chatIndex !== -1) { this.conversations[chatIndex].lastModified = Date.now(); const updatedConv = this.conversations.splice(chatIndex, 1)[0]; this.conversations.unshift(updatedConv); } } /** * Sends a new message and generates AI response * @param content - The message content to send * @param extras - Optional extra data (files, attachments, etc.) */ async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise { if (!content.trim() && (!extras || extras.length === 0)) return; if (this.activeConversation && this.isConversationLoading(this.activeConversation.id)) { console.log('Cannot send message: current conversation is already processing a message'); return; } let isNewConversation = false; if (!this.activeConversation) { await this.createConversation(); isNewConversation = true; } if (!this.activeConversation) { console.error('No active conversation available for sending message'); return; } this.errorDialogState = null; this.setConversationLoading(this.activeConversation.id, true); this.clearConversationStreaming(this.activeConversation.id); let userMessage: DatabaseMessage | null = null; try { userMessage = await this.addMessage('user', content, 'text', '-1', extras); if (!userMessage) { throw new Error('Failed to add user message'); } if (isNewConversation && content) { const title = content.trim(); await this.updateConversationName(this.activeConversation.id, title); } const assistantMessage = await this.createAssistantMessage(userMessage.id); if (!assistantMessage) { throw new Error('Failed to create assistant message'); } this.activeMessages.push(assistantMessage); const conversationContext = this.activeMessages.slice(0, -1); await this.streamChatCompletion(conversationContext, assistantMessage); } catch (error) { if (this.isAbortError(error)) { this.setConversationLoading(this.activeConversation!.id, false); return; } console.error('Failed to send message:', error); this.setConversationLoading(this.activeConversation!.id, false); if (!this.errorDialogState) { if (error instanceof Error) { const dialogType = error.name === 'TimeoutError' ? 'timeout' : 'server'; this.showErrorDialog(dialogType, error.message); } else { this.showErrorDialog('server', 'Unknown error occurred while sending message'); } } } } /** * Stops the current message generation * Aborts ongoing requests and saves partial response if available */ async stopGeneration(): Promise { if (!this.activeConversation) return; const convId = this.activeConversation.id; await this.savePartialResponseIfNeeded(convId); slotsService.stopStreaming(); chatService.abort(convId); this.setConversationLoading(convId, false); this.clearConversationStreaming(convId); slotsService.clearConversationState(convId); } /** * Gracefully stops generation and saves partial response */ async gracefulStop(): Promise { if (!this.isLoading) return; slotsService.stopStreaming(); chatService.abort(); await this.savePartialResponseIfNeeded(); this.conversationLoadingStates.clear(); this.conversationStreamingStates.clear(); this.isLoading = false; this.currentResponse = ''; } /** * Saves partial response if generation was interrupted * Preserves user's partial content and timing data when generation is stopped early */ private async savePartialResponseIfNeeded(convId?: string): Promise { const conversationId = convId || this.activeConversation?.id; if (!conversationId) return; const streamingState = this.conversationStreamingStates.get(conversationId); if (!streamingState || !streamingState.response.trim()) { return; } const messages = conversationId === this.activeConversation?.id ? this.activeMessages : await DatabaseStore.getConversationMessages(conversationId); if (!messages.length) return; const lastMessage = messages[messages.length - 1]; if (lastMessage && lastMessage.role === 'assistant') { try { const updateData: { content: string; thinking?: string; timings?: ChatMessageTimings; } = { content: streamingState.response }; if (lastMessage.thinking?.trim()) { updateData.thinking = lastMessage.thinking; } const lastKnownState = await slotsService.getCurrentState(); if (lastKnownState) { updateData.timings = { prompt_n: lastKnownState.promptTokens || 0, predicted_n: lastKnownState.tokensDecoded || 0, cache_n: lastKnownState.cacheTokens || 0, predicted_ms: lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000 : undefined }; } await DatabaseStore.updateMessage(lastMessage.id, updateData); lastMessage.content = this.currentResponse; if (updateData.thinking !== undefined) { lastMessage.thinking = updateData.thinking; } if (updateData.timings) { lastMessage.timings = updateData.timings; } } catch (error) { lastMessage.content = this.currentResponse; console.error('Failed to save partial response:', error); } } else { console.error('Last message is not an assistant message'); } } /** * Updates a user message and regenerates the assistant response * @param messageId - The ID of the message to update * @param newContent - The new content for the message */ async updateMessage(messageId: string, newContent: string): Promise { if (!this.activeConversation) return; if (this.isLoading) { this.stopGeneration(); } try { const messageIndex = this.findMessageIndex(messageId); if (messageIndex === -1) { console.error('Message not found for update'); return; } const messageToUpdate = this.activeMessages[messageIndex]; const originalContent = messageToUpdate.content; if (messageToUpdate.role !== 'user') { console.error('Only user messages can be edited'); return; } const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id && messageToUpdate.role === 'user'; this.updateMessageAtIndex(messageIndex, { content: newContent }); await DatabaseStore.updateMessage(messageId, { content: newContent }); if (isFirstUserMessage && newContent.trim()) { await this.updateConversationTitleWithConfirmation( this.activeConversation.id, newContent.trim(), this.titleUpdateConfirmationCallback ); } const messagesToRemove = this.activeMessages.slice(messageIndex + 1); for (const message of messagesToRemove) { await DatabaseStore.deleteMessage(message.id); } this.activeMessages = this.activeMessages.slice(0, messageIndex + 1); this.updateConversationTimestamp(); this.setConversationLoading(this.activeConversation.id, true); this.clearConversationStreaming(this.activeConversation.id); try { const assistantMessage = await this.createAssistantMessage(); if (!assistantMessage) { throw new Error('Failed to create assistant message'); } this.activeMessages.push(assistantMessage); await DatabaseStore.updateCurrentNode(this.activeConversation.id, assistantMessage.id); this.activeConversation.currNode = assistantMessage.id; await this.streamChatCompletion( this.activeMessages.slice(0, -1), assistantMessage, undefined, () => { const editedMessageIndex = this.findMessageIndex(messageId); this.updateMessageAtIndex(editedMessageIndex, { content: originalContent }); } ); } catch (regenerateError) { console.error('Failed to regenerate response:', regenerateError); this.setConversationLoading(this.activeConversation!.id, false); const messageIndex = this.findMessageIndex(messageId); this.updateMessageAtIndex(messageIndex, { content: originalContent }); } } catch (error) { if (this.isAbortError(error)) { return; } console.error('Failed to update message:', error); } } /** * Regenerates an assistant message with a new response * @param messageId - The ID of the assistant message to regenerate */ async regenerateMessage(messageId: string): Promise { if (!this.activeConversation || this.isLoading) return; try { const messageIndex = this.findMessageIndex(messageId); if (messageIndex === -1) { console.error('Message not found for regeneration'); return; } const messageToRegenerate = this.activeMessages[messageIndex]; if (messageToRegenerate.role !== 'assistant') { console.error('Only assistant messages can be regenerated'); return; } const messagesToRemove = this.activeMessages.slice(messageIndex); for (const message of messagesToRemove) { await DatabaseStore.deleteMessage(message.id); } this.activeMessages = this.activeMessages.slice(0, messageIndex); this.updateConversationTimestamp(); this.setConversationLoading(this.activeConversation.id, true); this.clearConversationStreaming(this.activeConversation.id); try { const parentMessageId = this.activeMessages.length > 0 ? this.activeMessages[this.activeMessages.length - 1].id : null; const assistantMessage = await this.createAssistantMessage(parentMessageId); if (!assistantMessage) { throw new Error('Failed to create assistant message'); } this.activeMessages.push(assistantMessage); const conversationContext = this.activeMessages.slice(0, -1); await this.streamChatCompletion(conversationContext, assistantMessage); } catch (regenerateError) { console.error('Failed to regenerate response:', regenerateError); this.setConversationLoading(this.activeConversation!.id, false); } } catch (error) { if (this.isAbortError(error)) return; console.error('Failed to regenerate message:', error); } } /** * 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 DatabaseStore.updateConversation(convId, { name }); const convIndex = this.conversations.findIndex((c) => c.id === convId); if (convIndex !== -1) { this.conversations[convIndex].name = name; } if (this.activeConversation?.id === convId) { this.activeConversation.name = name; } } catch (error) { console.error('Failed to update conversation name:', error); } } /** * Sets the callback function for title update confirmations * @param callback - Function to call when confirmation is needed */ setTitleUpdateConfirmationCallback( callback: (currentTitle: string, newTitle: string) => Promise ): void { this.titleUpdateConfirmationCallback = callback; } /** * Updates conversation title with optional confirmation dialog based on settings * @param convId - The conversation ID to update * @param newTitle - The new title content * @param onConfirmationNeeded - Callback when user confirmation is needed * @returns Promise - True if title was updated, false if cancelled */ async updateConversationTitleWithConfirmation( convId: string, newTitle: string, onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise ): Promise { try { const currentConfig = config(); if (currentConfig.askForTitleConfirmation && onConfirmationNeeded) { const conversation = await DatabaseStore.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; } } /** * Downloads a conversation as JSON file * @param convId - The conversation ID to download */ async downloadConversation(convId: string): Promise { if (!this.activeConversation || this.activeConversation.id !== convId) { // Load the conversation if not currently active const conversation = await DatabaseStore.getConversation(convId); if (!conversation) return; const messages = await DatabaseStore.getConversationMessages(convId); const conversationData = { conv: conversation, messages }; this.triggerDownload(conversationData); } else { // Use current active conversation data const conversationData: ExportedConversations = { conv: this.activeConversation!, messages: this.activeMessages }; this.triggerDownload(conversationData); } } /** * Triggers file download in browser * @param data - Data to download (expected: { conv: DatabaseConversation, messages: DatabaseMessage[] }) * @param filename - Optional filename */ 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 ? conversation.name.trim() : ''; const convId = conversation.id || 'unknown'; const truncatedSuffix = conversationName .toLowerCase() .replace(/[^a-z0-9]/gi, '_') .replace(/_+/g, '_') .substring(0, 20); const downloadFilename = filename || `conversation_${convId}_${truncatedSuffix}.json`; const conversationJson = JSON.stringify(data, null, 2); const blob = new Blob([conversationJson], { 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); } /** * Exports all conversations with their messages as a JSON file * Returns the list of exported conversations */ async exportAllConversations(): Promise { try { const allConversations = await DatabaseStore.getAllConversations(); if (allConversations.length === 0) { throw new Error('No conversations to export'); } const allData: ExportedConversations = await Promise.all( allConversations.map(async (conv) => { const messages = await DatabaseStore.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; } catch (err) { console.error('Failed to export conversations:', err); throw err; } } /** * Imports conversations from a JSON file. * Supports both single conversation (object) and multiple conversations (array). * Uses DatabaseStore for safe, encapsulated data access * 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 ) { // Single conversation object importedData = [parsedData]; } else { throw new Error( 'Invalid file format: expected array of conversations or single conversation object' ); } const result = await DatabaseStore.importConversations(importedData); // Refresh UI await this.loadConversations(); toast.success(`Imported ${result.imported} conversation(s), skipped ${result.skipped}`); // Extract the conversation objects from imported data const importedConversations = 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(); }); } /** * Deletes a conversation and all its messages * @param convId - The conversation ID to delete */ async deleteConversation(convId: string): Promise { try { await DatabaseStore.deleteConversation(convId); this.conversations = this.conversations.filter((c) => c.id !== convId); if (this.activeConversation?.id === convId) { this.activeConversation = null; this.activeMessages = []; await goto(`?new_chat=true#/`); } } catch (error) { console.error('Failed to delete conversation:', error); } } /** * Gets information about what messages will be deleted when deleting a specific message * @param messageId - The ID of the message to be deleted * @returns Object with deletion info including count and types of messages */ async getDeletionInfo(messageId: string): Promise<{ totalCount: number; userMessages: number; assistantMessages: number; messageTypes: string[]; }> { if (!this.activeConversation) { return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] }; } const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); const descendants = findDescendantMessages(allMessages, messageId); const allToDelete = [messageId, ...descendants]; const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id)); let userMessages = 0; let assistantMessages = 0; const messageTypes: string[] = []; for (const msg of messagesToDelete) { if (msg.role === 'user') { userMessages++; if (!messageTypes.includes('user message')) messageTypes.push('user message'); } else if (msg.role === 'assistant') { assistantMessages++; if (!messageTypes.includes('assistant response')) messageTypes.push('assistant response'); } } return { totalCount: allToDelete.length, userMessages, assistantMessages, messageTypes }; } /** * Deletes a message and all its descendants, updating conversation path if needed * @param messageId - The ID of the message to delete */ async deleteMessage(messageId: string): Promise { try { if (!this.activeConversation) return; // Get all messages to find siblings before deletion const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); const messageToDelete = allMessages.find((m) => m.id === messageId); if (!messageToDelete) { console.error('Message to delete not found'); return; } // Check if the deleted message is in the current conversation path const currentPath = filterByLeafNodeId( allMessages, this.activeConversation.currNode || '', false ); const isInCurrentPath = currentPath.some((m) => m.id === messageId); // If the deleted message is in the current path, we need to update currNode if (isInCurrentPath && messageToDelete.parent) { // Find all siblings (messages with same parent) const siblings = allMessages.filter( (m) => m.parent === messageToDelete.parent && m.id !== messageId ); if (siblings.length > 0) { // Find the latest sibling (highest timestamp) const latestSibling = siblings.reduce((latest, sibling) => sibling.timestamp > latest.timestamp ? sibling : latest ); // Find the leaf node for this sibling branch to get the complete conversation path const leafNodeId = findLeafNode(allMessages, latestSibling.id); // Update conversation to use the leaf node of the latest remaining sibling await DatabaseStore.updateCurrentNode(this.activeConversation.id, leafNodeId); this.activeConversation.currNode = leafNodeId; } else { // No siblings left, navigate to parent if it exists if (messageToDelete.parent) { const parentLeafId = findLeafNode(allMessages, messageToDelete.parent); await DatabaseStore.updateCurrentNode(this.activeConversation.id, parentLeafId); this.activeConversation.currNode = parentLeafId; } } } // Use cascading deletion to remove the message and all its descendants await DatabaseStore.deleteMessageCascading(this.activeConversation.id, messageId); // Refresh active messages to show the updated branch await this.refreshActiveMessages(); // Update conversation timestamp this.updateConversationTimestamp(); } catch (error) { console.error('Failed to delete message:', error); } } /** * Clears the active conversation and messages * Used when navigating away from chat or starting fresh * Note: Does not stop ongoing streaming to allow background completion */ clearActiveConversation(): void { this.activeConversation = null; this.activeMessages = []; this.isLoading = false; this.currentResponse = ''; slotsService.setActiveConversation(null); } /** Refreshes active messages based on currNode after branch navigation */ async refreshActiveMessages(): Promise { if (!this.activeConversation) return; const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); if (allMessages.length === 0) { this.activeMessages = []; return; } const leafNodeId = this.activeConversation.currNode || allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id; const currentPath = filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[]; this.activeMessages.length = 0; this.activeMessages.push(...currentPath); } /** * 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 { if (!this.activeConversation) return; // Get the current first user message before navigation const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const currentFirstUserMessage = this.activeMessages.find( (m) => m.role === 'user' && m.parent === rootMessage?.id ); const currentLeafNodeId = findLeafNode(allMessages, siblingId); await DatabaseStore.updateCurrentNode(this.activeConversation.id, currentLeafNodeId); this.activeConversation.currNode = currentLeafNodeId; await this.refreshActiveMessages(); // Only show title dialog if we're navigating between different first user message siblings if (rootMessage && this.activeMessages.length > 0) { // Find the first user message in the new active path const newFirstUserMessage = this.activeMessages.find( (m) => m.role === 'user' && m.parent === rootMessage.id ); // Only show dialog if: // 1. We have a new first user message // 2. It's different from the previous one (different ID or content) // 3. The new message has content if ( newFirstUserMessage && newFirstUserMessage.content.trim() && (!currentFirstUserMessage || newFirstUserMessage.id !== currentFirstUserMessage.id || newFirstUserMessage.content.trim() !== currentFirstUserMessage.content.trim()) ) { await this.updateConversationTitleWithConfirmation( this.activeConversation.id, newFirstUserMessage.content.trim(), this.titleUpdateConfirmationCallback ); } } } /** * Edits an assistant message with optional branching * @param messageId - The ID of the assistant message to edit * @param newContent - The new content for the message * @param shouldBranch - Whether to create a branch or replace in-place */ async editAssistantMessage( messageId: string, newContent: string, shouldBranch: boolean ): Promise { if (!this.activeConversation || this.isLoading) return; try { const messageIndex = this.findMessageIndex(messageId); if (messageIndex === -1) { console.error('Message not found for editing'); return; } const messageToEdit = this.activeMessages[messageIndex]; if (messageToEdit.role !== 'assistant') { console.error('Only assistant messages can be edited with this method'); return; } if (shouldBranch) { const newMessage = await DatabaseStore.createMessageBranch( { convId: messageToEdit.convId, type: messageToEdit.type, timestamp: Date.now(), role: messageToEdit.role, content: newContent, thinking: messageToEdit.thinking || '', toolCalls: messageToEdit.toolCalls || '', children: [], model: messageToEdit.model // Preserve original model info when branching }, messageToEdit.parent! ); await DatabaseStore.updateCurrentNode(this.activeConversation.id, newMessage.id); this.activeConversation.currNode = newMessage.id; } else { await DatabaseStore.updateMessage(messageToEdit.id, { content: newContent, timestamp: Date.now() }); this.updateMessageAtIndex(messageIndex, { content: newContent, timestamp: Date.now() }); } this.updateConversationTimestamp(); await this.refreshActiveMessages(); } catch (error) { console.error('Failed to edit assistant message:', error); } } /** * Edits a message by creating a new branch with the edited content * @param messageId - The ID of the message to edit * @param newContent - The new content for the message */ async editMessageWithBranching(messageId: string, newContent: string): Promise { if (!this.activeConversation || this.isLoading) return; try { const messageIndex = this.findMessageIndex(messageId); if (messageIndex === -1) { console.error('Message not found for editing'); return; } const messageToEdit = this.activeMessages[messageIndex]; if (messageToEdit.role !== 'user') { console.error('Only user messages can be edited'); return; } // Check if this is the first user message in the conversation // First user message is one that has the root message as its parent const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); const isFirstUserMessage = rootMessage && messageToEdit.parent === rootMessage.id && messageToEdit.role === 'user'; let parentId = messageToEdit.parent; if (parentId === undefined || parentId === null) { const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null); if (rootMessage) { parentId = rootMessage.id; } else { console.error('No root message found for editing'); return; } } const newMessage = await DatabaseStore.createMessageBranch( { convId: messageToEdit.convId, type: messageToEdit.type, timestamp: Date.now(), role: messageToEdit.role, content: newContent, thinking: messageToEdit.thinking || '', toolCalls: messageToEdit.toolCalls || '', children: [], extra: messageToEdit.extra ? JSON.parse(JSON.stringify(messageToEdit.extra)) : undefined, model: messageToEdit.model // Preserve original model info when branching }, parentId ); await DatabaseStore.updateCurrentNode(this.activeConversation.id, newMessage.id); this.activeConversation.currNode = newMessage.id; this.updateConversationTimestamp(); // If this is the first user message, update the conversation title with confirmation if needed if (isFirstUserMessage && newContent.trim()) { await this.updateConversationTitleWithConfirmation( this.activeConversation.id, newContent.trim(), this.titleUpdateConfirmationCallback ); } await this.refreshActiveMessages(); if (messageToEdit.role === 'user') { await this.generateResponseForMessage(newMessage.id); } } catch (error) { console.error('Failed to edit message with branching:', error); } } /** * Regenerates an assistant message by creating a new branch with a new response * @param messageId - The ID of the assistant message to regenerate */ async regenerateMessageWithBranching(messageId: string): Promise { if (!this.activeConversation || this.isLoading) return; try { const messageIndex = this.findMessageIndex(messageId); if (messageIndex === -1) { console.error('Message not found for regeneration'); return; } const messageToRegenerate = this.activeMessages[messageIndex]; if (messageToRegenerate.role !== 'assistant') { console.error('Only assistant messages can be regenerated'); return; } // Find parent message in all conversation messages, not just active path const conversationMessages = await DatabaseStore.getConversationMessages( this.activeConversation.id ); const parentMessage = conversationMessages.find((m) => m.id === messageToRegenerate.parent); if (!parentMessage) { console.error('Parent message not found for regeneration'); return; } this.setConversationLoading(this.activeConversation.id, true); this.clearConversationStreaming(this.activeConversation.id); const newAssistantMessage = await DatabaseStore.createMessageBranch( { convId: this.activeConversation.id, type: 'text', timestamp: Date.now(), role: 'assistant', content: '', thinking: '', toolCalls: '', children: [], model: null }, parentMessage.id ); await DatabaseStore.updateCurrentNode(this.activeConversation.id, newAssistantMessage.id); this.activeConversation.currNode = newAssistantMessage.id; this.updateConversationTimestamp(); await this.refreshActiveMessages(); const allConversationMessages = await DatabaseStore.getConversationMessages( this.activeConversation.id ); const conversationPath = filterByLeafNodeId( allConversationMessages, parentMessage.id, false ) as DatabaseMessage[]; await this.streamChatCompletion(conversationPath, newAssistantMessage); } catch (error) { if (this.isAbortError(error)) return; console.error('Failed to regenerate message with branching:', error); this.setConversationLoading(this.activeConversation!.id, false); } } /** * Generates a new assistant response for a given user message * @param userMessageId - ID of user message to respond to */ private async generateResponseForMessage(userMessageId: string): Promise { if (!this.activeConversation) return; this.errorDialogState = null; this.setConversationLoading(this.activeConversation.id, true); this.clearConversationStreaming(this.activeConversation.id); try { // Get conversation path up to the user message const allMessages = await DatabaseStore.getConversationMessages(this.activeConversation.id); const conversationPath = filterByLeafNodeId( allMessages, userMessageId, false ) as DatabaseMessage[]; // Create new assistant message branch const assistantMessage = await DatabaseStore.createMessageBranch( { convId: this.activeConversation.id, type: 'text', timestamp: Date.now(), role: 'assistant', content: '', thinking: '', toolCalls: '', children: [], model: null }, userMessageId ); // Add assistant message to active messages immediately for UI reactivity this.activeMessages.push(assistantMessage); // Stream response to new assistant message await this.streamChatCompletion(conversationPath, assistantMessage); } catch (error) { console.error('Failed to generate response:', error); this.setConversationLoading(this.activeConversation!.id, false); } } /** * Public methods for accessing per-conversation states */ public isConversationLoadingPublic(convId: string): boolean { return this.isConversationLoading(convId); } public getConversationStreamingPublic( convId: string ): { response: string; messageId: string } | undefined { return this.getConversationStreaming(convId); } public getAllLoadingConversations(): string[] { return Array.from(this.conversationLoadingStates.keys()); } public getAllStreamingConversations(): string[] { return Array.from(this.conversationStreamingStates.keys()); } } export const chatStore = new ChatStore(); export const conversations = () => chatStore.conversations; export const activeConversation = () => chatStore.activeConversation; export const activeMessages = () => chatStore.activeMessages; export const isLoading = () => chatStore.isLoading; export const currentResponse = () => chatStore.currentResponse; export const isInitialized = () => chatStore.isInitialized; export const errorDialog = () => chatStore.errorDialogState; export const createConversation = chatStore.createConversation.bind(chatStore); export const downloadConversation = chatStore.downloadConversation.bind(chatStore); export const exportAllConversations = chatStore.exportAllConversations.bind(chatStore); export const importConversations = chatStore.importConversations.bind(chatStore); export const deleteConversation = chatStore.deleteConversation.bind(chatStore); export const sendMessage = chatStore.sendMessage.bind(chatStore); export const dismissErrorDialog = chatStore.dismissErrorDialog.bind(chatStore); export const gracefulStop = chatStore.gracefulStop.bind(chatStore); // Branching operations export const refreshActiveMessages = chatStore.refreshActiveMessages.bind(chatStore); export const navigateToSibling = chatStore.navigateToSibling.bind(chatStore); export const editAssistantMessage = chatStore.editAssistantMessage.bind(chatStore); export const editMessageWithBranching = chatStore.editMessageWithBranching.bind(chatStore); export const regenerateMessageWithBranching = chatStore.regenerateMessageWithBranching.bind(chatStore); export const deleteMessage = chatStore.deleteMessage.bind(chatStore); export const getDeletionInfo = chatStore.getDeletionInfo.bind(chatStore); export const updateConversationName = chatStore.updateConversationName.bind(chatStore); export const setTitleUpdateConfirmationCallback = chatStore.setTitleUpdateConfirmationCallback.bind(chatStore); export function stopGeneration() { chatStore.stopGeneration(); } export const messages = () => chatStore.activeMessages; // Per-conversation state access export const isConversationLoading = (convId: string) => chatStore.isConversationLoadingPublic(convId); export const getConversationStreaming = (convId: string) => chatStore.getConversationStreamingPublic(convId); export const getAllLoadingConversations = () => chatStore.getAllLoadingConversations(); export const getAllStreamingConversations = () => chatStore.getAllStreamingConversations();