/** * chatStore - Reactive State Store for Chat Operations * * This store contains ONLY reactive state ($state, $derived). * All business logic is delegated to ChatClient. * * **Architecture & Relationships:** * - **ChatClient**: Business logic facade (streaming, message ops, branching) * - **ChatService**: Stateless API layer (sendMessage) * - **chatStore** (this): Reactive state for UI components * * **Responsibilities:** * - Hold reactive state for UI binding * - Provide getters for computed values * - Expose setters for ChatClient to update state * - Forward method calls to ChatClient * * @see ChatClient in clients/chat/ for business logic * @see ChatService in services/chat.ts for API operations */ import { SvelteMap } from 'svelte/reactivity'; import { browser } from '$app/environment'; import { chatClient } from '$lib/clients'; import type { ApiProcessingState, ErrorDialogState, DatabaseMessage, DatabaseMessageExtra } from '$lib/types'; import { MessageRole, MessageType } from '$lib/enums'; class ChatStore { activeProcessingState = $state(null); currentResponse = $state(''); errorDialogState = $state(null); isLoading = $state(false); chatLoadingStates = new SvelteMap(); chatStreamingStates = new SvelteMap(); private abortControllers = new SvelteMap(); private processingStates = new SvelteMap(); private activeConversationId = $state(null); private isStreamingActive = $state(false); private isEditModeActive = $state(false); private addFilesHandler: ((files: File[]) => void) | null = $state(null); pendingEditMessageId = $state(null); // Draft preservation for navigation (e.g., when adding system prompt from welcome page) private _pendingDraftMessage = $state(''); private _pendingDraftFiles = $state([]); constructor() { if (browser) { chatClient.setStoreCallbacks({ setChatLoading: (convId, loading) => this.setChatLoading(convId, loading), setChatStreaming: (convId, response, messageId) => this.setChatStreaming(convId, response, messageId), clearChatStreaming: (convId) => this.clearChatStreaming(convId), getChatStreaming: (convId) => this.getChatStreaming(convId), setProcessingState: (convId, state) => this.setProcessingState(convId, state), getProcessingState: (convId) => this.getProcessingState(convId), setActiveProcessingConversation: (convId) => this.setActiveProcessingConversation(convId), setStreamingActive: (active) => this.setStreamingActive(active), showErrorDialog: (state) => this.showErrorDialog(state), getAbortController: (convId) => this.getOrCreateAbortController(convId), abortRequest: (convId) => this.abortRequest(convId), setPendingEditMessageId: (messageId) => (this.pendingEditMessageId = messageId), getActiveConversationId: () => this.activeConversationId, getCurrentResponse: () => this.currentResponse }); } } private setChatLoading(convId: string, loading: boolean): void { import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => { if (loading) { this.chatLoadingStates.set(convId, true); if (conversationsStore.activeConversation?.id === convId) this.isLoading = true; } else { this.chatLoadingStates.delete(convId); if (conversationsStore.activeConversation?.id === convId) this.isLoading = false; } }); } private setChatStreaming(convId: string, response: string, messageId: string): void { this.chatStreamingStates.set(convId, { response, messageId }); import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => { if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response; }); } private clearChatStreaming(convId: string): void { this.chatStreamingStates.delete(convId); import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => { if (conversationsStore.activeConversation?.id === convId) this.currentResponse = ''; }); } private getChatStreaming(convId: string): { response: string; messageId: string } | undefined { return this.chatStreamingStates.get(convId); } syncLoadingStateForChat(convId: string): void { this.isLoading = this.chatLoadingStates.get(convId) || false; const streamingState = this.chatStreamingStates.get(convId); this.currentResponse = streamingState?.response || ''; // If there's an active stream for this conversation, update the message content // This ensures streaming content is visible when switching back to a conversation if (streamingState?.response && streamingState?.messageId) { import('$lib/stores/conversations.svelte').then(({ conversationsStore }) => { const idx = conversationsStore.findMessageIndex(streamingState.messageId); if (idx !== -1) { conversationsStore.updateMessageAtIndex(idx, { content: streamingState.response }); } }); } } clearUIState(): void { this.isLoading = false; this.currentResponse = ''; } setActiveProcessingConversation(conversationId: string | null): void { this.activeConversationId = conversationId; if (conversationId) { this.activeProcessingState = this.processingStates.get(conversationId) || null; } else { this.activeProcessingState = null; } } getProcessingState(conversationId: string): ApiProcessingState | null { return this.processingStates.get(conversationId) || null; } private setProcessingState(conversationId: string, state: ApiProcessingState | null): void { if (state === null) { this.processingStates.delete(conversationId); } else { this.processingStates.set(conversationId, state); } if (conversationId === this.activeConversationId) { this.activeProcessingState = state; } } clearProcessingState(conversationId: string): void { this.processingStates.delete(conversationId); if (conversationId === this.activeConversationId) { this.activeProcessingState = null; } } getActiveProcessingState(): ApiProcessingState | null { return this.activeProcessingState; } getCurrentProcessingStateSync(): ApiProcessingState | null { return this.activeProcessingState; } private setStreamingActive(active: boolean): void { this.isStreamingActive = active; } isStreaming(): boolean { return this.isStreamingActive; } private getOrCreateAbortController(convId: string): AbortController { let controller = this.abortControllers.get(convId); if (!controller || controller.signal.aborted) { controller = new AbortController(); this.abortControllers.set(convId, controller); } return controller; } private abortRequest(convId?: string): void { if (convId) { const controller = this.abortControllers.get(convId); if (controller) { controller.abort(); this.abortControllers.delete(convId); } } else { for (const controller of this.abortControllers.values()) { controller.abort(); } this.abortControllers.clear(); } } private showErrorDialog(state: ErrorDialogState | null): void { this.errorDialogState = state; } dismissErrorDialog(): void { this.errorDialogState = null; } clearEditMode(): void { this.isEditModeActive = false; this.addFilesHandler = null; } isEditing(): boolean { return this.isEditModeActive; } setEditModeActive(handler: (files: File[]) => void): void { this.isEditModeActive = true; this.addFilesHandler = handler; } getAddFilesHandler(): ((files: File[]) => void) | null { return this.addFilesHandler; } clearPendingEditMessageId(): void { this.pendingEditMessageId = null; } savePendingDraft(message: string, files: ChatUploadedFile[]): void { this._pendingDraftMessage = message; this._pendingDraftFiles = [...files]; } consumePendingDraft(): { message: string; files: ChatUploadedFile[] } | null { if (!this._pendingDraftMessage && this._pendingDraftFiles.length === 0) { return null; } const draft = { message: this._pendingDraftMessage, files: [...this._pendingDraftFiles] }; this._pendingDraftMessage = ''; this._pendingDraftFiles = []; return draft; } hasPendingDraft(): boolean { return Boolean(this._pendingDraftMessage) || this._pendingDraftFiles.length > 0; } getAllLoadingChats(): string[] { return Array.from(this.chatLoadingStates.keys()); } getAllStreamingChats(): string[] { return Array.from(this.chatStreamingStates.keys()); } getChatStreamingPublic(convId: string): { response: string; messageId: string } | undefined { return this.getChatStreaming(convId); } isChatLoadingPublic(convId: string): boolean { return this.chatLoadingStates.get(convId) || false; } async addMessage( role: MessageRole, content: string, type: MessageType = MessageType.TEXT, parent: string = '-1', extras?: DatabaseMessageExtra[] ): Promise { return chatClient.addMessage(role, content, type, parent, extras); } async addSystemPrompt(): Promise { return chatClient.addSystemPrompt(); } async removeSystemPromptPlaceholder(messageId: string): Promise { return chatClient.removeSystemPromptPlaceholder(messageId); } async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise { return chatClient.sendMessage(content, extras); } async stopGeneration(): Promise { return chatClient.stopGeneration(); } async stopGenerationForChat(convId: string): Promise { return chatClient.stopGenerationForChat(convId); } async updateMessage(messageId: string, newContent: string): Promise { return chatClient.updateMessage(messageId, newContent); } async regenerateMessage(messageId: string): Promise { return chatClient.regenerateMessage(messageId); } async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise { return chatClient.regenerateMessageWithBranching(messageId, modelOverride); } async getDeletionInfo(messageId: string): Promise<{ totalCount: number; userMessages: number; assistantMessages: number; messageTypes: string[]; }> { return chatClient.getDeletionInfo(messageId); } async deleteMessage(messageId: string): Promise { return chatClient.deleteMessage(messageId); } async continueAssistantMessage(messageId: string): Promise { return chatClient.continueAssistantMessage(messageId); } async editAssistantMessage( messageId: string, newContent: string, shouldBranch: boolean ): Promise { return chatClient.editAssistantMessage(messageId, newContent, shouldBranch); } async editUserMessagePreserveResponses( messageId: string, newContent: string, newExtras?: DatabaseMessageExtra[] ): Promise { return chatClient.editUserMessagePreserveResponses(messageId, newContent, newExtras); } async editMessageWithBranching( messageId: string, newContent: string, newExtras?: DatabaseMessageExtra[] ): Promise { return chatClient.editMessageWithBranching(messageId, newContent, newExtras); } updateProcessingStateFromTimings( timingData: { prompt_n: number; prompt_ms?: number; predicted_n: number; predicted_per_second: number; cache_n: number; prompt_progress?: { total: number; cache: number; processed: number; time_ms: number; }; }, conversationId?: string ): void { chatClient.updateProcessingStateFromTimings(timingData, conversationId); } restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void { chatClient.restoreProcessingStateFromMessages(messages, conversationId); } getConversationModel(messages: DatabaseMessage[]): string | null { return chatClient.getConversationModel(messages); } } export const chatStore = new ChatStore(); // State access functions (getters only - use chatStore.method() for actions) export const activeProcessingState = () => chatStore.activeProcessingState; export const currentResponse = () => chatStore.currentResponse; export const errorDialog = () => chatStore.errorDialogState; export const getAddFilesHandler = () => chatStore.getAddFilesHandler(); export const getAllLoadingChats = () => chatStore.getAllLoadingChats(); export const getAllStreamingChats = () => chatStore.getAllStreamingChats(); export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId); export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId); export const isChatStreaming = () => chatStore.isStreaming(); export const isEditing = () => chatStore.isEditing(); export const isLoading = () => chatStore.isLoading; export const pendingEditMessageId = () => chatStore.pendingEditMessageId;