summaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/stores
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
commitb333b06772c89d96aacb5490d6a219fba7c09cc6 (patch)
tree211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/stores
downloadllmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/stores')
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts1487
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts662
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts605
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts50
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts140
-rw-r--r--llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts421
6 files changed, 3365 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts
new file mode 100644
index 0000000..879b2f3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/chat.svelte.ts
@@ -0,0 +1,1487 @@
+import { DatabaseService, ChatService } from '$lib/services';
+import { conversationsStore } from '$lib/stores/conversations.svelte';
+import { config } from '$lib/stores/settings.svelte';
+import { contextSize, isRouterMode } from '$lib/stores/server.svelte';
+import {
+ selectedModelName,
+ modelsStore,
+ selectedModelContextSize
+} from '$lib/stores/models.svelte';
+import {
+ normalizeModelName,
+ filterByLeafNodeId,
+ findDescendantMessages,
+ findLeafNode
+} from '$lib/utils';
+import { SvelteMap } from 'svelte/reactivity';
+import { DEFAULT_CONTEXT } from '$lib/constants/default-context';
+
+/**
+ * chatStore - Active AI interaction and streaming state management
+ *
+ * **Terminology - Chat vs Conversation:**
+ * - **Chat**: The active interaction space with the Chat Completions API. Represents the
+ * real-time streaming session, loading states, and UI visualization of AI communication.
+ * A "chat" is ephemeral - it exists only while the user is actively interacting with the AI.
+ * - **Conversation**: The persistent database entity storing all messages and metadata.
+ * Managed by conversationsStore, conversations persist across sessions and page reloads.
+ *
+ * This store manages all active AI interactions including real-time streaming, response
+ * generation, and per-chat loading states. It handles the runtime layer between UI and
+ * AI backend, supporting concurrent streaming across multiple conversations.
+ *
+ * **Architecture & Relationships:**
+ * - **chatStore** (this class): Active AI session and streaming management
+ * - Manages real-time AI response streaming via ChatService
+ * - Tracks per-chat loading and streaming states for concurrent sessions
+ * - Handles message operations (send, edit, regenerate, branch)
+ * - Coordinates with conversationsStore for persistence
+ *
+ * - **conversationsStore**: Provides conversation data and message arrays for chat context
+ * - **ChatService**: Low-level API communication with llama.cpp server
+ * - **DatabaseService**: Message persistence and retrieval
+ *
+ * **Key Features:**
+ * - **AI Streaming**: Real-time token streaming with abort support
+ * - **Concurrent Chats**: Independent loading/streaming states per conversation
+ * - **Message Branching**: Edit, regenerate, and branch conversation trees
+ * - **Error Handling**: Timeout and server error recovery with user feedback
+ * - **Graceful Stop**: Save partial responses when stopping generation
+ *
+ * **State Management:**
+ * - Global `isLoading` and `currentResponse` for active chat UI
+ * - `chatLoadingStates` Map for per-conversation streaming tracking
+ * - `chatStreamingStates` Map for per-conversation streaming content
+ * - `processingStates` Map for per-conversation processing state (timing/context info)
+ * - Automatic state sync when switching between conversations
+ */
+class ChatStore {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // State
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ activeProcessingState = $state<ApiProcessingState | null>(null);
+ currentResponse = $state('');
+ errorDialogState = $state<{
+ type: 'timeout' | 'server';
+ message: string;
+ contextInfo?: { n_prompt_tokens: number; n_ctx: number };
+ } | null>(null);
+ isLoading = $state(false);
+ chatLoadingStates = new SvelteMap<string, boolean>();
+ chatStreamingStates = new SvelteMap<string, { response: string; messageId: string }>();
+ private abortControllers = new SvelteMap<string, AbortController>();
+ private processingStates = new SvelteMap<string, ApiProcessingState | null>();
+ private activeConversationId = $state<string | null>(null);
+ private isStreamingActive = $state(false);
+ private isEditModeActive = $state(false);
+ private addFilesHandler: ((files: File[]) => void) | null = $state(null);
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Loading State
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private setChatLoading(convId: string, loading: boolean): void {
+ 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 isChatLoading(convId: string): boolean {
+ return this.chatLoadingStates.get(convId) || false;
+ }
+
+ private setChatStreaming(convId: string, response: string, messageId: string): void {
+ this.chatStreamingStates.set(convId, { response, messageId });
+ if (conversationsStore.activeConversation?.id === convId) this.currentResponse = response;
+ }
+
+ private clearChatStreaming(convId: string): void {
+ this.chatStreamingStates.delete(convId);
+ 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.isChatLoading(convId);
+ const streamingState = this.getChatStreaming(convId);
+ this.currentResponse = streamingState?.response || '';
+ }
+
+ /**
+ * Clears global UI state without affecting background streaming.
+ * Used when navigating to empty/new chat while other chats stream in background.
+ */
+ clearUIState(): void {
+ this.isLoading = false;
+ this.currentResponse = '';
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Processing State
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Set the active conversation for statistics display
+ */
+ setActiveProcessingConversation(conversationId: string | null): void {
+ this.activeConversationId = conversationId;
+
+ if (conversationId) {
+ this.activeProcessingState = this.processingStates.get(conversationId) || null;
+ } else {
+ this.activeProcessingState = null;
+ }
+ }
+
+ /**
+ * Get processing state for a specific conversation
+ */
+ getProcessingState(conversationId: string): ApiProcessingState | null {
+ return this.processingStates.get(conversationId) || null;
+ }
+
+ /**
+ * Clear processing state for a specific conversation
+ */
+ clearProcessingState(conversationId: string): void {
+ this.processingStates.delete(conversationId);
+
+ if (conversationId === this.activeConversationId) {
+ this.activeProcessingState = null;
+ }
+ }
+
+ /**
+ * Get the current processing state for the active conversation (reactive)
+ * Returns the direct reactive state for UI binding
+ */
+ getActiveProcessingState(): ApiProcessingState | null {
+ return this.activeProcessingState;
+ }
+
+ /**
+ * Updates processing state with timing data from streaming response
+ */
+ updateProcessingStateFromTimings(
+ timingData: {
+ prompt_n: number;
+ prompt_ms?: number;
+ predicted_n: number;
+ predicted_per_second: number;
+ cache_n: number;
+ prompt_progress?: ChatMessagePromptProgress;
+ },
+ conversationId?: string
+ ): void {
+ const processingState = this.parseTimingData(timingData);
+
+ if (processingState === null) {
+ console.warn('Failed to parse timing data - skipping update');
+ return;
+ }
+
+ const targetId = conversationId || this.activeConversationId;
+ if (targetId) {
+ this.processingStates.set(targetId, processingState);
+
+ if (targetId === this.activeConversationId) {
+ this.activeProcessingState = processingState;
+ }
+ }
+ }
+
+ /**
+ * Get current processing state (sync version for reactive access)
+ */
+ getCurrentProcessingStateSync(): ApiProcessingState | null {
+ return this.activeProcessingState;
+ }
+
+ /**
+ * Restore processing state from last assistant message timings
+ * Call this when keepStatsVisible is enabled and we need to show last known stats
+ */
+ restoreProcessingStateFromMessages(messages: DatabaseMessage[], conversationId: string): void {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role === 'assistant' && message.timings) {
+ const restoredState = this.parseTimingData({
+ prompt_n: message.timings.prompt_n || 0,
+ prompt_ms: message.timings.prompt_ms,
+ predicted_n: message.timings.predicted_n || 0,
+ predicted_per_second:
+ message.timings.predicted_n && message.timings.predicted_ms
+ ? (message.timings.predicted_n / message.timings.predicted_ms) * 1000
+ : 0,
+ cache_n: message.timings.cache_n || 0
+ });
+
+ if (restoredState) {
+ this.processingStates.set(conversationId, restoredState);
+
+ if (conversationId === this.activeConversationId) {
+ this.activeProcessingState = restoredState;
+ }
+
+ return;
+ }
+ }
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Streaming
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Start streaming session tracking
+ */
+ startStreaming(): void {
+ this.isStreamingActive = true;
+ }
+
+ /**
+ * Stop streaming session tracking
+ */
+ stopStreaming(): void {
+ this.isStreamingActive = false;
+ }
+
+ /**
+ * Check if currently in a streaming session
+ */
+ isStreaming(): boolean {
+ return this.isStreamingActive;
+ }
+
+ private getContextTotal(): number {
+ const activeState = this.getActiveProcessingState();
+
+ if (activeState && activeState.contextTotal > 0) {
+ return activeState.contextTotal;
+ }
+
+ if (isRouterMode()) {
+ const modelContextSize = selectedModelContextSize();
+ if (modelContextSize && modelContextSize > 0) {
+ return modelContextSize;
+ }
+ }
+
+ const propsContextSize = contextSize();
+ if (propsContextSize && propsContextSize > 0) {
+ return propsContextSize;
+ }
+
+ return DEFAULT_CONTEXT;
+ }
+
+ private parseTimingData(timingData: Record<string, unknown>): ApiProcessingState | null {
+ const promptTokens = (timingData.prompt_n as number) || 0;
+ const promptMs = (timingData.prompt_ms as number) || undefined;
+ const predictedTokens = (timingData.predicted_n as number) || 0;
+ const tokensPerSecond = (timingData.predicted_per_second as number) || 0;
+ const cacheTokens = (timingData.cache_n as number) || 0;
+ const promptProgress = timingData.prompt_progress as
+ | {
+ total: number;
+ cache: number;
+ processed: number;
+ time_ms: number;
+ }
+ | undefined;
+
+ const contextTotal = this.getContextTotal();
+ const currentConfig = config();
+ const outputTokensMax = currentConfig.max_tokens || -1;
+
+ // Note: for timings data, the n_prompt does NOT include cache tokens
+ const contextUsed = promptTokens + cacheTokens + predictedTokens;
+ const outputTokensUsed = predictedTokens;
+
+ // Note: for prompt progress, the "processed" DOES include cache tokens
+ // we need to exclude them to get the real prompt tokens processed count
+ const progressCache = promptProgress?.cache || 0;
+ const progressActualDone = (promptProgress?.processed ?? 0) - progressCache;
+ const progressActualTotal = (promptProgress?.total ?? 0) - progressCache;
+ const progressPercent = promptProgress
+ ? Math.round((progressActualDone / progressActualTotal) * 100)
+ : undefined;
+
+ return {
+ status: predictedTokens > 0 ? 'generating' : promptProgress ? 'preparing' : 'idle',
+ tokensDecoded: predictedTokens,
+ tokensRemaining: outputTokensMax - predictedTokens,
+ contextUsed,
+ contextTotal,
+ outputTokensUsed,
+ outputTokensMax,
+ hasNextToken: predictedTokens > 0,
+ tokensPerSecond,
+ temperature: currentConfig.temperature ?? 0.8,
+ topP: currentConfig.top_p ?? 0.95,
+ speculative: false,
+ progressPercent,
+ promptProgress,
+ promptTokens,
+ promptMs,
+ cacheTokens
+ };
+ }
+
+ /**
+ * Gets the model used in a conversation based on the latest assistant message.
+ * Returns the model from the most recent assistant message that has a model field set.
+ *
+ * @param messages - Array of messages to search through
+ * @returns The model name or null if no model found
+ */
+ getConversationModel(messages: DatabaseMessage[]): string | null {
+ // Search backwards through messages to find most recent assistant message with model
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i];
+ if (message.role === 'assistant' && message.model) {
+ return message.model;
+ }
+ }
+ return null;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Error Handling
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private isAbortError(error: unknown): boolean {
+ return error instanceof Error && (error.name === 'AbortError' || error instanceof DOMException);
+ }
+
+ private showErrorDialog(
+ type: 'timeout' | 'server',
+ message: string,
+ contextInfo?: { n_prompt_tokens: number; n_ctx: number }
+ ): void {
+ this.errorDialogState = { type, message, contextInfo };
+ }
+
+ dismissErrorDialog(): void {
+ this.errorDialogState = null;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Message Operations
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Finds a message by ID and optionally validates its role.
+ * Returns message and index, or null if not found or role doesn't match.
+ */
+ private getMessageByIdWithRole(
+ messageId: string,
+ expectedRole?: ChatRole
+ ): { message: DatabaseMessage; index: number } | null {
+ const index = conversationsStore.findMessageIndex(messageId);
+ if (index === -1) return null;
+
+ const message = conversationsStore.activeMessages[index];
+ if (expectedRole && message.role !== expectedRole) return null;
+
+ return { message, index };
+ }
+
+ async addMessage(
+ role: ChatRole,
+ content: string,
+ type: ChatMessageType = 'text',
+ parent: string = '-1',
+ extras?: DatabaseMessageExtra[]
+ ): Promise<DatabaseMessage | null> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) {
+ console.error('No active conversation when trying to add message');
+ return null;
+ }
+
+ try {
+ let parentId: string | null = null;
+
+ if (parent === '-1') {
+ const activeMessages = conversationsStore.activeMessages;
+ if (activeMessages.length > 0) {
+ parentId = activeMessages[activeMessages.length - 1].id;
+ } else {
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const rootMessage = allMessages.find((m) => m.parent === null && m.type === 'root');
+ if (!rootMessage) {
+ parentId = await DatabaseService.createRootMessage(activeConv.id);
+ } else {
+ parentId = rootMessage.id;
+ }
+ }
+ } else {
+ parentId = parent;
+ }
+
+ const message = await DatabaseService.createMessageBranch(
+ {
+ convId: activeConv.id,
+ role,
+ content,
+ type,
+ timestamp: Date.now(),
+ thinking: '',
+ toolCalls: '',
+ children: [],
+ extra: extras
+ },
+ parentId
+ );
+
+ conversationsStore.addMessageToActive(message);
+ await conversationsStore.updateCurrentNode(message.id);
+ conversationsStore.updateConversationTimestamp();
+
+ return message;
+ } catch (error) {
+ console.error('Failed to add message:', error);
+ return null;
+ }
+ }
+
+ private async createAssistantMessage(parentId?: string): Promise<DatabaseMessage | null> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) return null;
+
+ return await DatabaseService.createMessageBranch(
+ {
+ convId: activeConv.id,
+ type: 'text',
+ role: 'assistant',
+ content: '',
+ timestamp: Date.now(),
+ thinking: '',
+ toolCalls: '',
+ children: [],
+ model: null
+ },
+ parentId || null
+ );
+ }
+
+ private async streamChatCompletion(
+ allMessages: DatabaseMessage[],
+ assistantMessage: DatabaseMessage,
+ onComplete?: (content: string) => Promise<void>,
+ onError?: (error: Error) => void,
+ modelOverride?: string | null
+ ): Promise<void> {
+ // Ensure model props are cached before streaming (for correct n_ctx in processing info)
+ if (isRouterMode()) {
+ const modelName = modelOverride || selectedModelName();
+ if (modelName && !modelsStore.getModelProps(modelName)) {
+ await modelsStore.fetchModelProps(modelName);
+ }
+ }
+
+ let streamedContent = '';
+ let streamedReasoningContent = '';
+ let streamedToolCallContent = '';
+ let resolvedModel: string | null = null;
+ let modelPersisted = false;
+
+ const recordModel = (modelName: string | null | undefined, persistImmediately = true): void => {
+ if (!modelName) return;
+ const normalizedModel = normalizeModelName(modelName);
+ if (!normalizedModel || normalizedModel === resolvedModel) return;
+ resolvedModel = normalizedModel;
+ const messageIndex = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(messageIndex, { model: normalizedModel });
+ if (persistImmediately && !modelPersisted) {
+ modelPersisted = true;
+ DatabaseService.updateMessage(assistantMessage.id, { model: normalizedModel }).catch(() => {
+ modelPersisted = false;
+ resolvedModel = null;
+ });
+ }
+ };
+
+ this.startStreaming();
+ this.setActiveProcessingConversation(assistantMessage.convId);
+
+ const abortController = this.getOrCreateAbortController(assistantMessage.convId);
+
+ await ChatService.sendMessage(
+ allMessages,
+ {
+ ...this.getApiOptions(),
+ ...(modelOverride ? { model: modelOverride } : {}),
+ onChunk: (chunk: string) => {
+ streamedContent += chunk;
+ this.setChatStreaming(assistantMessage.convId, streamedContent, assistantMessage.id);
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { content: streamedContent });
+ },
+ onReasoningChunk: (reasoningChunk: string) => {
+ streamedReasoningContent += reasoningChunk;
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { thinking: streamedReasoningContent });
+ },
+ onToolCallChunk: (toolCallChunk: string) => {
+ const chunk = toolCallChunk.trim();
+ if (!chunk) return;
+ streamedToolCallContent = chunk;
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ conversationsStore.updateMessageAtIndex(idx, { toolCalls: streamedToolCallContent });
+ },
+ onModel: (modelName: string) => recordModel(modelName),
+ onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
+ const tokensPerSecond =
+ timings?.predicted_ms && timings?.predicted_n
+ ? (timings.predicted_n / timings.predicted_ms) * 1000
+ : 0;
+ this.updateProcessingStateFromTimings(
+ {
+ prompt_n: timings?.prompt_n || 0,
+ prompt_ms: timings?.prompt_ms,
+ predicted_n: timings?.predicted_n || 0,
+ predicted_per_second: tokensPerSecond,
+ cache_n: timings?.cache_n || 0,
+ prompt_progress: promptProgress
+ },
+ assistantMessage.convId
+ );
+ },
+ onComplete: async (
+ finalContent?: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings,
+ toolCallContent?: string
+ ) => {
+ this.stopStreaming();
+
+ const updateData: Record<string, unknown> = {
+ content: finalContent || streamedContent,
+ thinking: reasoningContent || streamedReasoningContent,
+ toolCalls: toolCallContent || streamedToolCallContent,
+ timings
+ };
+ if (resolvedModel && !modelPersisted) {
+ updateData.model = resolvedModel;
+ }
+ await DatabaseService.updateMessage(assistantMessage.id, updateData);
+
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+ const uiUpdate: Partial<DatabaseMessage> = {
+ content: updateData.content as string,
+ toolCalls: updateData.toolCalls as string
+ };
+ if (timings) uiUpdate.timings = timings;
+ if (resolvedModel) uiUpdate.model = resolvedModel;
+
+ conversationsStore.updateMessageAtIndex(idx, uiUpdate);
+ await conversationsStore.updateCurrentNode(assistantMessage.id);
+
+ if (onComplete) await onComplete(streamedContent);
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.clearProcessingState(assistantMessage.convId);
+
+ if (isRouterMode()) {
+ modelsStore.fetchRouterModels().catch(console.error);
+ }
+ },
+ onError: (error: Error) => {
+ this.stopStreaming();
+
+ if (this.isAbortError(error)) {
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.clearProcessingState(assistantMessage.convId);
+
+ return;
+ }
+
+ console.error('Streaming error:', error);
+
+ this.setChatLoading(assistantMessage.convId, false);
+ this.clearChatStreaming(assistantMessage.convId);
+ this.clearProcessingState(assistantMessage.convId);
+
+ const idx = conversationsStore.findMessageIndex(assistantMessage.id);
+
+ if (idx !== -1) {
+ const failedMessage = conversationsStore.removeMessageAtIndex(idx);
+ if (failedMessage) DatabaseService.deleteMessage(failedMessage.id).catch(console.error);
+ }
+
+ const contextInfo = (
+ error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
+ ).contextInfo;
+
+ this.showErrorDialog(
+ error.name === 'TimeoutError' ? 'timeout' : 'server',
+ error.message,
+ contextInfo
+ );
+
+ if (onError) onError(error);
+ }
+ },
+ assistantMessage.convId,
+ abortController.signal
+ );
+ }
+
+ async sendMessage(content: string, extras?: DatabaseMessageExtra[]): Promise<void> {
+ if (!content.trim() && (!extras || extras.length === 0)) return;
+ const activeConv = conversationsStore.activeConversation;
+ if (activeConv && this.isChatLoading(activeConv.id)) return;
+
+ let isNewConversation = false;
+ if (!activeConv) {
+ await conversationsStore.createConversation();
+ isNewConversation = true;
+ }
+ const currentConv = conversationsStore.activeConversation;
+ if (!currentConv) return;
+
+ this.errorDialogState = null;
+ this.setChatLoading(currentConv.id, true);
+ this.clearChatStreaming(currentConv.id);
+
+ try {
+ if (isNewConversation) {
+ const rootId = await DatabaseService.createRootMessage(currentConv.id);
+ const currentConfig = config();
+ const systemPrompt = currentConfig.systemMessage?.toString().trim();
+
+ if (systemPrompt) {
+ const systemMessage = await DatabaseService.createSystemMessage(
+ currentConv.id,
+ systemPrompt,
+ rootId
+ );
+
+ conversationsStore.addMessageToActive(systemMessage);
+ }
+ }
+
+ const userMessage = await this.addMessage('user', content, 'text', '-1', extras);
+ if (!userMessage) throw new Error('Failed to add user message');
+ if (isNewConversation && content)
+ await conversationsStore.updateConversationName(currentConv.id, content.trim());
+
+ const assistantMessage = await this.createAssistantMessage(userMessage.id);
+
+ if (!assistantMessage) throw new Error('Failed to create assistant message');
+
+ conversationsStore.addMessageToActive(assistantMessage);
+ await this.streamChatCompletion(
+ conversationsStore.activeMessages.slice(0, -1),
+ assistantMessage
+ );
+ } catch (error) {
+ if (this.isAbortError(error)) {
+ this.setChatLoading(currentConv.id, false);
+ return;
+ }
+ console.error('Failed to send message:', error);
+ this.setChatLoading(currentConv.id, false);
+ if (!this.errorDialogState) {
+ const dialogType =
+ error instanceof Error && error.name === 'TimeoutError' ? 'timeout' : 'server';
+ const contextInfo = (
+ error as Error & { contextInfo?: { n_prompt_tokens: number; n_ctx: number } }
+ ).contextInfo;
+
+ this.showErrorDialog(
+ dialogType,
+ error instanceof Error ? error.message : 'Unknown error',
+ contextInfo
+ );
+ }
+ }
+ }
+
+ async stopGeneration(): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+
+ if (!activeConv) return;
+
+ await this.stopGenerationForChat(activeConv.id);
+ }
+
+ async stopGenerationForChat(convId: string): Promise<void> {
+ await this.savePartialResponseIfNeeded(convId);
+
+ this.stopStreaming();
+ this.abortRequest(convId);
+ this.setChatLoading(convId, false);
+ this.clearChatStreaming(convId);
+ this.clearProcessingState(convId);
+ }
+
+ /**
+ * Gets or creates an AbortController for a conversation
+ */
+ 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;
+ }
+
+ /**
+ * Aborts any ongoing request for a conversation
+ */
+ 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 async savePartialResponseIfNeeded(convId?: string): Promise<void> {
+ const conversationId = convId || conversationsStore.activeConversation?.id;
+
+ if (!conversationId) return;
+
+ const streamingState = this.chatStreamingStates.get(conversationId);
+
+ if (!streamingState || !streamingState.response.trim()) return;
+
+ const messages =
+ conversationId === conversationsStore.activeConversation?.id
+ ? conversationsStore.activeMessages
+ : await conversationsStore.getConversationMessages(conversationId);
+
+ if (!messages.length) return;
+
+ const lastMessage = messages[messages.length - 1];
+
+ if (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 = this.getProcessingState(conversationId);
+ if (lastKnownState) {
+ updateData.timings = {
+ prompt_n: lastKnownState.promptTokens || 0,
+ prompt_ms: lastKnownState.promptMs,
+ predicted_n: lastKnownState.tokensDecoded || 0,
+ cache_n: lastKnownState.cacheTokens || 0,
+ predicted_ms:
+ lastKnownState.tokensPerSecond && lastKnownState.tokensDecoded
+ ? (lastKnownState.tokensDecoded / lastKnownState.tokensPerSecond) * 1000
+ : undefined
+ };
+ }
+
+ await DatabaseService.updateMessage(lastMessage.id, updateData);
+
+ lastMessage.content = this.currentResponse;
+
+ if (updateData.thinking) 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);
+ }
+ }
+ }
+
+ async updateMessage(messageId: string, newContent: string): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) return;
+ if (this.isLoading) this.stopGeneration();
+
+ const result = this.getMessageByIdWithRole(messageId, 'user');
+ if (!result) return;
+ const { message: messageToUpdate, index: messageIndex } = result;
+ const originalContent = messageToUpdate.content;
+
+ try {
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+ const isFirstUserMessage = rootMessage && messageToUpdate.parent === rootMessage.id;
+
+ conversationsStore.updateMessageAtIndex(messageIndex, { content: newContent });
+ await DatabaseService.updateMessage(messageId, { content: newContent });
+
+ if (isFirstUserMessage && newContent.trim()) {
+ await conversationsStore.updateConversationTitleWithConfirmation(
+ activeConv.id,
+ newContent.trim(),
+ conversationsStore.titleUpdateConfirmationCallback
+ );
+ }
+
+ const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
+
+ for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
+
+ conversationsStore.sliceActiveMessages(messageIndex + 1);
+ conversationsStore.updateConversationTimestamp();
+
+ this.setChatLoading(activeConv.id, true);
+ this.clearChatStreaming(activeConv.id);
+
+ const assistantMessage = await this.createAssistantMessage();
+
+ if (!assistantMessage) throw new Error('Failed to create assistant message');
+
+ conversationsStore.addMessageToActive(assistantMessage);
+
+ await conversationsStore.updateCurrentNode(assistantMessage.id);
+ await this.streamChatCompletion(
+ conversationsStore.activeMessages.slice(0, -1),
+ assistantMessage,
+ undefined,
+ () => {
+ conversationsStore.updateMessageAtIndex(conversationsStore.findMessageIndex(messageId), {
+ content: originalContent
+ });
+ }
+ );
+ } catch (error) {
+ if (!this.isAbortError(error)) console.error('Failed to update message:', error);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Regeneration
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ async regenerateMessage(messageId: string): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv || this.isLoading) return;
+
+ const result = this.getMessageByIdWithRole(messageId, 'assistant');
+ if (!result) return;
+ const { index: messageIndex } = result;
+
+ try {
+ const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
+ for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
+ conversationsStore.sliceActiveMessages(messageIndex);
+ conversationsStore.updateConversationTimestamp();
+
+ this.setChatLoading(activeConv.id, true);
+ this.clearChatStreaming(activeConv.id);
+
+ const parentMessageId =
+ conversationsStore.activeMessages.length > 0
+ ? conversationsStore.activeMessages[conversationsStore.activeMessages.length - 1].id
+ : undefined;
+ const assistantMessage = await this.createAssistantMessage(parentMessageId);
+ if (!assistantMessage) throw new Error('Failed to create assistant message');
+ conversationsStore.addMessageToActive(assistantMessage);
+ await this.streamChatCompletion(
+ conversationsStore.activeMessages.slice(0, -1),
+ assistantMessage
+ );
+ } catch (error) {
+ if (!this.isAbortError(error)) console.error('Failed to regenerate message:', error);
+ this.setChatLoading(activeConv?.id || '', false);
+ }
+ }
+
+ async getDeletionInfo(messageId: string): Promise<{
+ totalCount: number;
+ userMessages: number;
+ assistantMessages: number;
+ messageTypes: string[];
+ }> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv)
+ return { totalCount: 0, userMessages: 0, assistantMessages: 0, messageTypes: [] };
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const descendants = findDescendantMessages(allMessages, messageId);
+ const allToDelete = [messageId, ...descendants];
+ const messagesToDelete = allMessages.filter((m) => allToDelete.includes(m.id));
+ let userMessages = 0,
+ 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 };
+ }
+
+ async deleteMessage(messageId: string): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) return;
+ try {
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const messageToDelete = allMessages.find((m) => m.id === messageId);
+ if (!messageToDelete) return;
+
+ const currentPath = filterByLeafNodeId(allMessages, activeConv.currNode || '', false);
+ const isInCurrentPath = currentPath.some((m) => m.id === messageId);
+
+ if (isInCurrentPath && messageToDelete.parent) {
+ const siblings = allMessages.filter(
+ (m) => m.parent === messageToDelete.parent && m.id !== messageId
+ );
+
+ if (siblings.length > 0) {
+ const latestSibling = siblings.reduce((latest, sibling) =>
+ sibling.timestamp > latest.timestamp ? sibling : latest
+ );
+ await conversationsStore.updateCurrentNode(findLeafNode(allMessages, latestSibling.id));
+ } else if (messageToDelete.parent) {
+ await conversationsStore.updateCurrentNode(
+ findLeafNode(allMessages, messageToDelete.parent)
+ );
+ }
+ }
+ await DatabaseService.deleteMessageCascading(activeConv.id, messageId);
+ await conversationsStore.refreshActiveMessages();
+
+ conversationsStore.updateConversationTimestamp();
+ } catch (error) {
+ console.error('Failed to delete message:', error);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Editing
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ clearEditMode(): void {
+ this.isEditModeActive = false;
+ this.addFilesHandler = null;
+ }
+
+ async continueAssistantMessage(messageId: string): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv || this.isLoading) return;
+
+ const result = this.getMessageByIdWithRole(messageId, 'assistant');
+ if (!result) return;
+ const { message: msg, index: idx } = result;
+
+ if (this.isChatLoading(activeConv.id)) return;
+
+ try {
+ this.errorDialogState = null;
+ this.setChatLoading(activeConv.id, true);
+ this.clearChatStreaming(activeConv.id);
+
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const dbMessage = allMessages.find((m) => m.id === messageId);
+
+ if (!dbMessage) {
+ this.setChatLoading(activeConv.id, false);
+
+ return;
+ }
+
+ const originalContent = dbMessage.content;
+ const originalThinking = dbMessage.thinking || '';
+
+ const conversationContext = conversationsStore.activeMessages.slice(0, idx);
+ const contextWithContinue = [
+ ...conversationContext,
+ { role: 'assistant' as const, content: originalContent }
+ ];
+
+ let appendedContent = '',
+ appendedThinking = '',
+ hasReceivedContent = false;
+
+ const abortController = this.getOrCreateAbortController(msg.convId);
+
+ await ChatService.sendMessage(
+ contextWithContinue,
+ {
+ ...this.getApiOptions(),
+
+ onChunk: (chunk: string) => {
+ hasReceivedContent = true;
+ appendedContent += chunk;
+ const fullContent = originalContent + appendedContent;
+ this.setChatStreaming(msg.convId, fullContent, msg.id);
+ conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
+ },
+
+ onReasoningChunk: (reasoningChunk: string) => {
+ hasReceivedContent = true;
+ appendedThinking += reasoningChunk;
+ conversationsStore.updateMessageAtIndex(idx, {
+ thinking: originalThinking + appendedThinking
+ });
+ },
+
+ onTimings: (timings?: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
+ const tokensPerSecond =
+ timings?.predicted_ms && timings?.predicted_n
+ ? (timings.predicted_n / timings.predicted_ms) * 1000
+ : 0;
+ this.updateProcessingStateFromTimings(
+ {
+ prompt_n: timings?.prompt_n || 0,
+ prompt_ms: timings?.prompt_ms,
+ predicted_n: timings?.predicted_n || 0,
+ predicted_per_second: tokensPerSecond,
+ cache_n: timings?.cache_n || 0,
+ prompt_progress: promptProgress
+ },
+ msg.convId
+ );
+ },
+
+ onComplete: async (
+ finalContent?: string,
+ reasoningContent?: string,
+ timings?: ChatMessageTimings
+ ) => {
+ const fullContent = originalContent + (finalContent || appendedContent);
+ const fullThinking = originalThinking + (reasoningContent || appendedThinking);
+ await DatabaseService.updateMessage(msg.id, {
+ content: fullContent,
+ thinking: fullThinking,
+ timestamp: Date.now(),
+ timings
+ });
+ conversationsStore.updateMessageAtIndex(idx, {
+ content: fullContent,
+ thinking: fullThinking,
+ timestamp: Date.now(),
+ timings
+ });
+ conversationsStore.updateConversationTimestamp();
+ this.setChatLoading(msg.convId, false);
+ this.clearChatStreaming(msg.convId);
+ this.clearProcessingState(msg.convId);
+ },
+
+ onError: async (error: Error) => {
+ if (this.isAbortError(error)) {
+ if (hasReceivedContent && appendedContent) {
+ await DatabaseService.updateMessage(msg.id, {
+ content: originalContent + appendedContent,
+ thinking: originalThinking + appendedThinking,
+ timestamp: Date.now()
+ });
+ conversationsStore.updateMessageAtIndex(idx, {
+ content: originalContent + appendedContent,
+ thinking: originalThinking + appendedThinking,
+ timestamp: Date.now()
+ });
+ }
+ this.setChatLoading(msg.convId, false);
+ this.clearChatStreaming(msg.convId);
+ this.clearProcessingState(msg.convId);
+ return;
+ }
+ console.error('Continue generation error:', error);
+ conversationsStore.updateMessageAtIndex(idx, {
+ content: originalContent,
+ thinking: originalThinking
+ });
+ await DatabaseService.updateMessage(msg.id, {
+ content: originalContent,
+ thinking: originalThinking
+ });
+ this.setChatLoading(msg.convId, false);
+ this.clearChatStreaming(msg.convId);
+ this.clearProcessingState(msg.convId);
+ this.showErrorDialog(
+ error.name === 'TimeoutError' ? 'timeout' : 'server',
+ error.message
+ );
+ }
+ },
+ msg.convId,
+ abortController.signal
+ );
+ } catch (error) {
+ if (!this.isAbortError(error)) console.error('Failed to continue message:', error);
+ if (activeConv) this.setChatLoading(activeConv.id, false);
+ }
+ }
+
+ async editAssistantMessage(
+ messageId: string,
+ newContent: string,
+ shouldBranch: boolean
+ ): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv || this.isLoading) return;
+
+ const result = this.getMessageByIdWithRole(messageId, 'assistant');
+ if (!result) return;
+ const { message: msg, index: idx } = result;
+
+ try {
+ if (shouldBranch) {
+ const newMessage = await DatabaseService.createMessageBranch(
+ {
+ convId: msg.convId,
+ type: msg.type,
+ timestamp: Date.now(),
+ role: msg.role,
+ content: newContent,
+ thinking: msg.thinking || '',
+ toolCalls: msg.toolCalls || '',
+ children: [],
+ model: msg.model
+ },
+ msg.parent!
+ );
+ await conversationsStore.updateCurrentNode(newMessage.id);
+ } else {
+ await DatabaseService.updateMessage(msg.id, { content: newContent });
+ await conversationsStore.updateCurrentNode(msg.id);
+ conversationsStore.updateMessageAtIndex(idx, {
+ content: newContent
+ });
+ }
+ conversationsStore.updateConversationTimestamp();
+ await conversationsStore.refreshActiveMessages();
+ } catch (error) {
+ console.error('Failed to edit assistant message:', error);
+ }
+ }
+
+ async editUserMessagePreserveResponses(
+ messageId: string,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv) return;
+
+ const result = this.getMessageByIdWithRole(messageId, 'user');
+ if (!result) return;
+ const { message: msg, index: idx } = result;
+
+ try {
+ const updateData: Partial<DatabaseMessage> = {
+ content: newContent
+ };
+
+ // Update extras if provided (including empty array to clear attachments)
+ // Deep clone to avoid Proxy objects from Svelte reactivity
+ if (newExtras !== undefined) {
+ updateData.extra = JSON.parse(JSON.stringify(newExtras));
+ }
+
+ await DatabaseService.updateMessage(messageId, updateData);
+ conversationsStore.updateMessageAtIndex(idx, updateData);
+
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+
+ if (rootMessage && msg.parent === rootMessage.id && newContent.trim()) {
+ await conversationsStore.updateConversationTitleWithConfirmation(
+ activeConv.id,
+ newContent.trim(),
+ conversationsStore.titleUpdateConfirmationCallback
+ );
+ }
+ conversationsStore.updateConversationTimestamp();
+ } catch (error) {
+ console.error('Failed to edit user message:', error);
+ }
+ }
+
+ async editMessageWithBranching(
+ messageId: string,
+ newContent: string,
+ newExtras?: DatabaseMessageExtra[]
+ ): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv || this.isLoading) return;
+
+ let result = this.getMessageByIdWithRole(messageId, 'user');
+
+ if (!result) {
+ result = this.getMessageByIdWithRole(messageId, 'system');
+ }
+
+ if (!result) return;
+ const { message: msg } = result;
+
+ try {
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
+ const isFirstUserMessage =
+ msg.role === 'user' && rootMessage && msg.parent === rootMessage.id;
+
+ const parentId = msg.parent || rootMessage?.id;
+ if (!parentId) return;
+
+ // Use newExtras if provided, otherwise copy existing extras
+ // Deep clone to avoid Proxy objects from Svelte reactivity
+ const extrasToUse =
+ newExtras !== undefined
+ ? JSON.parse(JSON.stringify(newExtras))
+ : msg.extra
+ ? JSON.parse(JSON.stringify(msg.extra))
+ : undefined;
+
+ const newMessage = await DatabaseService.createMessageBranch(
+ {
+ convId: msg.convId,
+ type: msg.type,
+ timestamp: Date.now(),
+ role: msg.role,
+ content: newContent,
+ thinking: msg.thinking || '',
+ toolCalls: msg.toolCalls || '',
+ children: [],
+ extra: extrasToUse,
+ model: msg.model
+ },
+ parentId
+ );
+ await conversationsStore.updateCurrentNode(newMessage.id);
+ conversationsStore.updateConversationTimestamp();
+
+ if (isFirstUserMessage && newContent.trim()) {
+ await conversationsStore.updateConversationTitleWithConfirmation(
+ activeConv.id,
+ newContent.trim(),
+ conversationsStore.titleUpdateConfirmationCallback
+ );
+ }
+ await conversationsStore.refreshActiveMessages();
+
+ if (msg.role === 'user') {
+ await this.generateResponseForMessage(newMessage.id);
+ }
+ } catch (error) {
+ console.error('Failed to edit message with branching:', error);
+ }
+ }
+
+ async regenerateMessageWithBranching(messageId: string, modelOverride?: string): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+ if (!activeConv || this.isLoading) return;
+ try {
+ const idx = conversationsStore.findMessageIndex(messageId);
+ if (idx === -1) return;
+ const msg = conversationsStore.activeMessages[idx];
+ if (msg.role !== 'assistant') return;
+
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const parentMessage = allMessages.find((m) => m.id === msg.parent);
+ if (!parentMessage) return;
+
+ this.setChatLoading(activeConv.id, true);
+ this.clearChatStreaming(activeConv.id);
+
+ const newAssistantMessage = await DatabaseService.createMessageBranch(
+ {
+ convId: activeConv.id,
+ type: 'text',
+ timestamp: Date.now(),
+ role: 'assistant',
+ content: '',
+ thinking: '',
+ toolCalls: '',
+ children: [],
+ model: null
+ },
+ parentMessage.id
+ );
+ await conversationsStore.updateCurrentNode(newAssistantMessage.id);
+ conversationsStore.updateConversationTimestamp();
+ await conversationsStore.refreshActiveMessages();
+
+ const conversationPath = filterByLeafNodeId(
+ allMessages,
+ parentMessage.id,
+ false
+ ) as DatabaseMessage[];
+ // Use modelOverride if provided, otherwise use the original message's model
+ // If neither is available, don't pass model (will use global selection)
+ const modelToUse = modelOverride || msg.model || undefined;
+ await this.streamChatCompletion(
+ conversationPath,
+ newAssistantMessage,
+ undefined,
+ undefined,
+ modelToUse
+ );
+ } catch (error) {
+ if (!this.isAbortError(error))
+ console.error('Failed to regenerate message with branching:', error);
+ this.setChatLoading(activeConv?.id || '', false);
+ }
+ }
+
+ private async generateResponseForMessage(userMessageId: string): Promise<void> {
+ const activeConv = conversationsStore.activeConversation;
+
+ if (!activeConv) return;
+
+ this.errorDialogState = null;
+ this.setChatLoading(activeConv.id, true);
+ this.clearChatStreaming(activeConv.id);
+
+ try {
+ const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+ const conversationPath = filterByLeafNodeId(
+ allMessages,
+ userMessageId,
+ false
+ ) as DatabaseMessage[];
+ const assistantMessage = await DatabaseService.createMessageBranch(
+ {
+ convId: activeConv.id,
+ type: 'text',
+ timestamp: Date.now(),
+ role: 'assistant',
+ content: '',
+ thinking: '',
+ toolCalls: '',
+ children: [],
+ model: null
+ },
+ userMessageId
+ );
+ conversationsStore.addMessageToActive(assistantMessage);
+ await this.streamChatCompletion(conversationPath, assistantMessage);
+ } catch (error) {
+ console.error('Failed to generate response:', error);
+ this.setChatLoading(activeConv.id, false);
+ }
+ }
+
+ getAddFilesHandler(): ((files: File[]) => void) | null {
+ return this.addFilesHandler;
+ }
+
+ public getAllLoadingChats(): string[] {
+ return Array.from(this.chatLoadingStates.keys());
+ }
+
+ public getAllStreamingChats(): string[] {
+ return Array.from(this.chatStreamingStates.keys());
+ }
+
+ public getChatStreamingPublic(
+ convId: string
+ ): { response: string; messageId: string } | undefined {
+ return this.getChatStreaming(convId);
+ }
+
+ public isChatLoadingPublic(convId: string): boolean {
+ return this.isChatLoading(convId);
+ }
+
+ isEditing(): boolean {
+ return this.isEditModeActive;
+ }
+
+ setEditModeActive(handler: (files: File[]) => void): void {
+ this.isEditModeActive = true;
+ this.addFilesHandler = handler;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Utilities
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private getApiOptions(): Record<string, unknown> {
+ const currentConfig = config();
+ const hasValue = (value: unknown): boolean =>
+ value !== undefined && value !== null && value !== '';
+
+ const apiOptions: Record<string, unknown> = { stream: true, timings_per_token: true };
+
+ // Model selection (required in ROUTER mode)
+ if (isRouterMode()) {
+ const modelName = selectedModelName();
+ if (modelName) apiOptions.model = modelName;
+ }
+
+ // Config options needed by ChatService
+ if (currentConfig.systemMessage) apiOptions.systemMessage = currentConfig.systemMessage;
+ if (currentConfig.disableReasoningFormat) apiOptions.disableReasoningFormat = 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)
+ apiOptions.backend_sampling = currentConfig.backend_sampling;
+ if (currentConfig.custom) apiOptions.custom = currentConfig.custom;
+
+ return apiOptions;
+ }
+}
+
+export const chatStore = new ChatStore();
+
+export const activeProcessingState = () => chatStore.activeProcessingState;
+export const clearEditMode = () => chatStore.clearEditMode();
+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 setEditModeActive = (handler: (files: File[]) => void) =>
+ chatStore.setEditModeActive(handler);
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts
new file mode 100644
index 0000000..3300eb3
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/conversations.svelte.ts
@@ -0,0 +1,662 @@
+import { browser } from '$app/environment';
+import { goto } from '$app/navigation';
+import { toast } from 'svelte-sonner';
+import { DatabaseService } from '$lib/services/database';
+import { config } from '$lib/stores/settings.svelte';
+import { filterByLeafNodeId, findLeafNode } from '$lib/utils';
+import { AttachmentType } from '$lib/enums';
+
+/**
+ * conversationsStore - Persistent conversation data and lifecycle management
+ *
+ * **Terminology - Chat vs Conversation:**
+ * - **Chat**: The active interaction space with the Chat Completions API. Represents the
+ * real-time streaming session, loading states, and UI visualization of AI communication.
+ * Managed by chatStore, a "chat" is ephemeral and exists during active AI interactions.
+ * - **Conversation**: The persistent database entity storing all messages and metadata.
+ * A "conversation" survives across sessions, page reloads, and browser restarts.
+ * It contains the complete message history, branching structure, and conversation metadata.
+ *
+ * This store manages all conversation-level data and operations including creation, loading,
+ * deletion, and navigation. It maintains the list of conversations and the currently active
+ * conversation with its message history, providing reactive state for UI components.
+ *
+ * **Architecture & Relationships:**
+ * - **conversationsStore** (this class): Persistent conversation data management
+ * - Manages conversation list and active conversation state
+ * - Handles conversation CRUD operations via DatabaseService
+ * - Maintains active message array for current conversation
+ * - Coordinates branching navigation (currNode tracking)
+ *
+ * - **chatStore**: Uses conversation data as context for active AI streaming
+ * - **DatabaseService**: Low-level IndexedDB storage for conversations and messages
+ *
+ * **Key Features:**
+ * - **Conversation Lifecycle**: Create, load, update, delete conversations
+ * - **Message Management**: Active message array with branching support
+ * - **Import/Export**: JSON-based conversation backup and restore
+ * - **Branch Navigation**: Navigate between message tree branches
+ * - **Title Management**: Auto-update titles with confirmation dialogs
+ * - **Reactive State**: Svelte 5 runes for automatic UI updates
+ *
+ * **State Properties:**
+ * - `conversations`: All conversations sorted by last modified
+ * - `activeConversation`: Currently viewed conversation
+ * - `activeMessages`: Messages in current conversation path
+ * - `isInitialized`: Store initialization status
+ */
+class ConversationsStore {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // State
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /** List of all conversations */
+ conversations = $state<DatabaseConversation[]>([]);
+
+ /** Currently active conversation */
+ activeConversation = $state<DatabaseConversation | null>(null);
+
+ /** Messages in the active conversation (filtered by currNode path) */
+ activeMessages = $state<DatabaseMessage[]>([]);
+
+ /** Whether the store has been initialized */
+ isInitialized = $state(false);
+
+ /** Callback for title update confirmation dialog */
+ titleUpdateConfirmationCallback?: (currentTitle: string, newTitle: string) => Promise<boolean>;
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Modalities
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Modalities used in the active conversation.
+ * Computed from attachments in activeMessages.
+ * Used to filter available models - models must support all used modalities.
+ */
+ usedModalities: ModelModalities = $derived.by(() => {
+ return this.calculateModalitiesFromMessages(this.activeMessages);
+ });
+
+ /**
+ * Calculate modalities from a list of messages.
+ * Helper method used by both usedModalities and getModalitiesUpToMessage.
+ */
+ private calculateModalitiesFromMessages(messages: DatabaseMessage[]): ModelModalities {
+ const modalities: ModelModalities = { vision: false, audio: false };
+
+ for (const message of messages) {
+ if (!message.extra) continue;
+
+ for (const extra of message.extra) {
+ if (extra.type === AttachmentType.IMAGE) {
+ modalities.vision = true;
+ }
+
+ // PDF only requires vision if processed as images
+ if (extra.type === AttachmentType.PDF) {
+ const pdfExtra = extra as DatabaseMessageExtraPdfFile;
+
+ if (pdfExtra.processedAsImages) {
+ modalities.vision = true;
+ }
+ }
+
+ if (extra.type === AttachmentType.AUDIO) {
+ modalities.audio = true;
+ }
+ }
+
+ if (modalities.vision && modalities.audio) break;
+ }
+
+ return modalities;
+ }
+
+ /**
+ * Get modalities used in messages BEFORE the specified message.
+ * Used for regeneration - only consider context that was available when generating this message.
+ */
+ getModalitiesUpToMessage(messageId: string): ModelModalities {
+ const messageIndex = this.activeMessages.findIndex((m) => m.id === messageId);
+
+ if (messageIndex === -1) {
+ return this.usedModalities;
+ }
+
+ const messagesBefore = this.activeMessages.slice(0, messageIndex);
+ return this.calculateModalitiesFromMessages(messagesBefore);
+ }
+
+ constructor() {
+ if (browser) {
+ this.initialize();
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Lifecycle
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Initializes the conversations store by loading conversations from the database
+ */
+ async initialize(): Promise<void> {
+ try {
+ await this.loadConversations();
+ this.isInitialized = true;
+ } catch (error) {
+ console.error('Failed to initialize conversations store:', error);
+ }
+ }
+
+ /**
+ * Loads all conversations from the database
+ */
+ async loadConversations(): Promise<void> {
+ this.conversations = await DatabaseService.getAllConversations();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Conversation CRUD
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * 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<string> {
+ const conversationName = name || `Chat ${new Date().toLocaleString()}`;
+ const conversation = await DatabaseService.createConversation(conversationName);
+
+ this.conversations.unshift(conversation);
+ this.activeConversation = conversation;
+ this.activeMessages = [];
+
+ 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<boolean> {
+ try {
+ const conversation = await DatabaseService.getConversation(convId);
+
+ if (!conversation) {
+ return false;
+ }
+
+ this.activeConversation = conversation;
+
+ if (conversation.currNode) {
+ const allMessages = await DatabaseService.getConversationMessages(convId);
+ this.activeMessages = filterByLeafNodeId(
+ allMessages,
+ conversation.currNode,
+ false
+ ) as DatabaseMessage[];
+ } else {
+ this.activeMessages = await DatabaseService.getConversationMessages(convId);
+ }
+
+ return true;
+ } catch (error) {
+ console.error('Failed to load conversation:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Clears the active conversation and messages
+ * Used when navigating away from chat or starting fresh
+ */
+ clearActiveConversation(): void {
+ this.activeConversation = null;
+ this.activeMessages = [];
+ // Active processing conversation is now managed by chatStore
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Message Management
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Refreshes active messages based on currNode after branch navigation
+ */
+ async refreshActiveMessages(): Promise<void> {
+ if (!this.activeConversation) return;
+
+ const allMessages = await DatabaseService.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);
+ }
+
+ /**
+ * 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<void> {
+ try {
+ await DatabaseService.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);
+ }
+ }
+
+ /**
+ * 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 True if title was updated, false if cancelled
+ */
+ async updateConversationTitleWithConfirmation(
+ convId: string,
+ newTitle: string,
+ onConfirmationNeeded?: (currentTitle: string, newTitle: string) => Promise<boolean>
+ ): Promise<boolean> {
+ try {
+ const currentConfig = config();
+
+ 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;
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Navigation
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Updates the current node of the active conversation
+ * @param nodeId - The new current node ID
+ */
+ async updateCurrentNode(nodeId: string): Promise<void> {
+ if (!this.activeConversation) return;
+
+ await DatabaseService.updateCurrentNode(this.activeConversation.id, nodeId);
+ this.activeConversation.currNode = nodeId;
+ }
+
+ /**
+ * Updates conversation lastModified timestamp and moves it to top of list
+ */
+ 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);
+ }
+ }
+
+ /**
+ * 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<void> {
+ if (!this.activeConversation) return;
+
+ const allMessages = await DatabaseService.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 DatabaseService.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) {
+ const newFirstUserMessage = this.activeMessages.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(
+ this.activeConversation.id,
+ newFirstUserMessage.content.trim(),
+ this.titleUpdateConfirmationCallback
+ );
+ }
+ }
+ }
+
+ /**
+ * Deletes a conversation and all its messages
+ * @param convId - The conversation ID to delete
+ */
+ async deleteConversation(convId: string): Promise<void> {
+ try {
+ await DatabaseService.deleteConversation(convId);
+
+ this.conversations = this.conversations.filter((c) => c.id !== convId);
+
+ if (this.activeConversation?.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<void> {
+ try {
+ const allConversations = await DatabaseService.getAllConversations();
+
+ for (const conv of allConversations) {
+ await DatabaseService.deleteConversation(conv.id);
+ }
+
+ this.clearActiveConversation();
+ this.conversations = [];
+
+ 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');
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Import/Export
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Downloads a conversation as JSON file
+ * @param convId - The conversation ID to download
+ */
+ async downloadConversation(convId: string): Promise<void> {
+ let conversation: DatabaseConversation | null;
+ let messages: DatabaseMessage[];
+
+ if (this.activeConversation?.id === convId) {
+ conversation = this.activeConversation;
+ messages = this.activeMessages;
+ } 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<DatabaseConversation[]> {
+ 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<DatabaseConversation[]> {
+ 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();
+ });
+ }
+
+ /**
+ * Gets all messages for a specific conversation
+ * @param convId - The conversation ID
+ * @returns Array of messages
+ */
+ async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
+ return await DatabaseService.getConversationMessages(convId);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Adds a message to the active messages array
+ * Used by chatStore when creating new messages
+ * @param message - The message to add
+ */
+ addMessageToActive(message: DatabaseMessage): void {
+ this.activeMessages.push(message);
+ }
+
+ /**
+ * Updates a message at a specific index in active messages
+ * Creates a new object to trigger Svelte 5 reactivity
+ * @param index - The index of the message to update
+ * @param updates - Partial message data to update
+ */
+ updateMessageAtIndex(index: number, updates: Partial<DatabaseMessage>): void {
+ if (index !== -1 && this.activeMessages[index]) {
+ // Create new object to trigger Svelte 5 reactivity
+ this.activeMessages[index] = { ...this.activeMessages[index], ...updates };
+ }
+ }
+
+ /**
+ * Finds the index of a message in active messages
+ * @param messageId - The message ID to find
+ * @returns The index of the message, or -1 if not found
+ */
+ findMessageIndex(messageId: string): number {
+ return this.activeMessages.findIndex((m) => m.id === messageId);
+ }
+
+ /**
+ * Removes messages from active messages starting at an index
+ * @param startIndex - The index to start removing from
+ */
+ sliceActiveMessages(startIndex: number): void {
+ this.activeMessages = this.activeMessages.slice(0, startIndex);
+ }
+
+ /**
+ * Removes a message from active messages by index
+ * @param index - The index to remove
+ * @returns The removed message or undefined
+ */
+ removeMessageAtIndex(index: number): DatabaseMessage | undefined {
+ if (index !== -1) {
+ return this.activeMessages.splice(index, 1)[0];
+ }
+ return undefined;
+ }
+
+ /**
+ * Triggers file download in browser
+ * @param data - The data to download
+ * @param filename - Optional filename for the download
+ */
+ 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);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Utilities
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Sets the callback function for title update confirmations
+ * @param callback - Function to call when confirmation is needed
+ */
+ setTitleUpdateConfirmationCallback(
+ callback: (currentTitle: string, newTitle: string) => Promise<boolean>
+ ): void {
+ this.titleUpdateConfirmationCallback = callback;
+ }
+}
+
+export const conversationsStore = new ConversationsStore();
+
+export const conversations = () => conversationsStore.conversations;
+export const activeConversation = () => conversationsStore.activeConversation;
+export const activeMessages = () => conversationsStore.activeMessages;
+export const isConversationsInitialized = () => conversationsStore.isInitialized;
+export const usedModalities = () => conversationsStore.usedModalities;
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts
new file mode 100644
index 0000000..34b2640
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/models.svelte.ts
@@ -0,0 +1,605 @@
+import { SvelteSet } from 'svelte/reactivity';
+import { ModelsService } from '$lib/services/models';
+import { PropsService } from '$lib/services/props';
+import { ServerModelStatus, ModelModality } from '$lib/enums';
+import { serverStore } from '$lib/stores/server.svelte';
+
+/**
+ * modelsStore - Reactive store for model management in both MODEL and ROUTER modes
+ *
+ * This store manages:
+ * - Available models list
+ * - Selected model for new conversations
+ * - Loaded models tracking (ROUTER mode)
+ * - Model usage tracking per conversation
+ * - Automatic unloading of unused models
+ *
+ * **Architecture & Relationships:**
+ * - **ModelsService**: Stateless service for model API communication
+ * - **PropsService**: Stateless service for props/modalities fetching
+ * - **modelsStore** (this class): Reactive store for model state
+ * - **conversationsStore**: Tracks which conversations use which models
+ *
+ * **API Inconsistency Workaround:**
+ * In MODEL mode, `/props` returns modalities for the single model.
+ * In ROUTER mode, `/props` has no modalities - must use `/props?model=<id>` per model.
+ * This store normalizes this behavior so consumers don't need to know the server mode.
+ *
+ * **Key Features:**
+ * - **MODEL mode**: Single model, always loaded
+ * - **ROUTER mode**: Multi-model with load/unload capability
+ * - **Auto-unload**: Automatically unloads models not used by any conversation
+ * - **Lazy loading**: ensureModelLoaded() loads models on demand
+ */
+class ModelsStore {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // State
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ models = $state<ModelOption[]>([]);
+ routerModels = $state<ApiModelDataEntry[]>([]);
+ loading = $state(false);
+ updating = $state(false);
+ error = $state<string | null>(null);
+ selectedModelId = $state<string | null>(null);
+ selectedModelName = $state<string | null>(null);
+
+ private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
+ private modelLoadingStates = $state<Map<string, boolean>>(new Map());
+
+ /**
+ * Model-specific props cache
+ * Key: modelId, Value: props data including modalities
+ */
+ private modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map());
+ private modelPropsFetching = $state<Set<string>>(new Set());
+
+ /**
+ * Version counter for props cache - used to trigger reactivity when props are updated
+ */
+ propsCacheVersion = $state(0);
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Computed Getters
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ get selectedModel(): ModelOption | null {
+ if (!this.selectedModelId) return null;
+ return this.models.find((model) => model.id === this.selectedModelId) ?? null;
+ }
+
+ get loadedModelIds(): string[] {
+ return this.routerModels
+ .filter((m) => m.status.value === ServerModelStatus.LOADED)
+ .map((m) => m.id);
+ }
+
+ get loadingModelIds(): string[] {
+ return Array.from(this.modelLoadingStates.entries())
+ .filter(([, loading]) => loading)
+ .map(([id]) => id);
+ }
+
+ /**
+ * Get model name in MODEL mode (single model).
+ * Extracts from model_path or model_alias from server props.
+ * In ROUTER mode, returns null (model is per-conversation).
+ */
+ get singleModelName(): string | null {
+ if (serverStore.isRouterMode) return null;
+
+ const props = serverStore.props;
+ if (props?.model_alias) return props.model_alias;
+ if (!props?.model_path) return null;
+
+ return props.model_path.split(/(\\|\/)/).pop() || null;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Modalities
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Get modalities for a specific model
+ * Returns cached modalities from model props
+ */
+ getModelModalities(modelId: string): ModelModalities | null {
+ // First check if modalities are stored in the model option
+ const model = this.models.find((m) => m.model === modelId || m.id === modelId);
+ if (model?.modalities) {
+ return model.modalities;
+ }
+
+ // Fall back to props cache
+ const props = this.modelPropsCache.get(modelId);
+ if (props?.modalities) {
+ return {
+ vision: props.modalities.vision ?? false,
+ audio: props.modalities.audio ?? false
+ };
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if a model supports vision modality
+ */
+ modelSupportsVision(modelId: string): boolean {
+ return this.getModelModalities(modelId)?.vision ?? false;
+ }
+
+ /**
+ * Check if a model supports audio modality
+ */
+ modelSupportsAudio(modelId: string): boolean {
+ return this.getModelModalities(modelId)?.audio ?? false;
+ }
+
+ /**
+ * Get model modalities as an array of ModelModality enum values
+ */
+ getModelModalitiesArray(modelId: string): ModelModality[] {
+ const modalities = this.getModelModalities(modelId);
+ if (!modalities) return [];
+
+ const result: ModelModality[] = [];
+
+ if (modalities.vision) result.push(ModelModality.VISION);
+ if (modalities.audio) result.push(ModelModality.AUDIO);
+
+ return result;
+ }
+
+ /**
+ * Get props for a specific model (from cache)
+ */
+ getModelProps(modelId: string): ApiLlamaCppServerProps | null {
+ return this.modelPropsCache.get(modelId) ?? null;
+ }
+
+ /**
+ * Get context size (n_ctx) for a specific model from cached props
+ */
+ getModelContextSize(modelId: string): number | null {
+ const props = this.modelPropsCache.get(modelId);
+ return props?.default_generation_settings?.n_ctx ?? null;
+ }
+
+ /**
+ * Get context size for the currently selected model or null if no model is selected
+ */
+ get selectedModelContextSize(): number | null {
+ if (!this.selectedModelName) return null;
+ return this.getModelContextSize(this.selectedModelName);
+ }
+
+ /**
+ * Check if props are being fetched for a model
+ */
+ isModelPropsFetching(modelId: string): boolean {
+ return this.modelPropsFetching.has(modelId);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Status Queries
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ isModelLoaded(modelId: string): boolean {
+ const model = this.routerModels.find((m) => m.id === modelId);
+ return model?.status.value === ServerModelStatus.LOADED || false;
+ }
+
+ isModelOperationInProgress(modelId: string): boolean {
+ return this.modelLoadingStates.get(modelId) ?? false;
+ }
+
+ getModelStatus(modelId: string): ServerModelStatus | null {
+ const model = this.routerModels.find((m) => m.id === modelId);
+ return model?.status.value ?? null;
+ }
+
+ getModelUsage(modelId: string): SvelteSet<string> {
+ return this.modelUsage.get(modelId) ?? new SvelteSet<string>();
+ }
+
+ isModelInUse(modelId: string): boolean {
+ const usage = this.modelUsage.get(modelId);
+ return usage !== undefined && usage.size > 0;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Data Fetching
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Fetch list of models from server and detect server role
+ * Also fetches modalities for MODEL mode (single model)
+ */
+ async fetch(force = false): Promise<void> {
+ if (this.loading) return;
+ if (this.models.length > 0 && !force) return;
+
+ this.loading = true;
+ this.error = null;
+
+ try {
+ // Ensure server props are loaded (for role detection and MODEL mode modalities)
+ if (!serverStore.props) {
+ await serverStore.fetch();
+ }
+
+ const response = await ModelsService.list();
+
+ const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
+ const details = response.models?.[index];
+ const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
+ const displayNameSource =
+ details?.name && details.name.trim().length > 0 ? details.name : item.id;
+ const displayName = this.toDisplayName(displayNameSource);
+
+ return {
+ id: item.id,
+ name: displayName,
+ model: details?.model || item.id,
+ description: details?.description,
+ capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
+ details: details?.details,
+ meta: item.meta ?? null
+ } satisfies ModelOption;
+ });
+
+ this.models = models;
+
+ // In MODEL mode, populate modalities from serverStore.props (single model)
+ // WORKAROUND: In MODEL mode, /props returns modalities for the single model,
+ // but /v1/models doesn't include modalities. We bridge this gap here.
+ const serverProps = serverStore.props;
+ if (serverStore.isModelMode && this.models.length > 0 && serverProps?.modalities) {
+ const modalities: ModelModalities = {
+ vision: serverProps.modalities.vision ?? false,
+ audio: serverProps.modalities.audio ?? false
+ };
+ // Cache props for the single model
+ this.modelPropsCache.set(this.models[0].model, serverProps);
+ // Update model with modalities
+ this.models = this.models.map((model, index) =>
+ index === 0 ? { ...model, modalities } : model
+ );
+ }
+ } catch (error) {
+ this.models = [];
+ this.error = error instanceof Error ? error.message : 'Failed to load models';
+ throw error;
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ /**
+ * Fetch router models with full metadata (ROUTER mode only)
+ * This fetches the /models endpoint which returns status info for each model
+ */
+ async fetchRouterModels(): Promise<void> {
+ try {
+ const response = await ModelsService.listRouter();
+ this.routerModels = response.data;
+ await this.fetchModalitiesForLoadedModels();
+ } catch (error) {
+ console.warn('Failed to fetch router models:', error);
+ this.routerModels = [];
+ }
+ }
+
+ /**
+ * Fetch props for a specific model from /props endpoint
+ * Uses caching to avoid redundant requests
+ *
+ * In ROUTER mode, this will only fetch props if the model is loaded,
+ * since unloaded models return 400 from /props endpoint.
+ *
+ * @param modelId - Model identifier to fetch props for
+ * @returns Props data or null if fetch failed or model not loaded
+ */
+ async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
+ // Return cached props if available
+ const cached = this.modelPropsCache.get(modelId);
+ if (cached) return cached;
+
+ if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) {
+ return null;
+ }
+
+ // Avoid duplicate fetches
+ if (this.modelPropsFetching.has(modelId)) return null;
+
+ this.modelPropsFetching.add(modelId);
+
+ try {
+ const props = await PropsService.fetchForModel(modelId);
+ this.modelPropsCache.set(modelId, props);
+ return props;
+ } catch (error) {
+ console.warn(`Failed to fetch props for model ${modelId}:`, error);
+ return null;
+ } finally {
+ this.modelPropsFetching.delete(modelId);
+ }
+ }
+
+ /**
+ * Fetch modalities for all loaded models from /props endpoint
+ * This updates the modalities field in models array
+ */
+ async fetchModalitiesForLoadedModels(): Promise<void> {
+ const loadedModelIds = this.loadedModelIds;
+ if (loadedModelIds.length === 0) return;
+
+ // Fetch props for each loaded model in parallel
+ const propsPromises = loadedModelIds.map((modelId) => this.fetchModelProps(modelId));
+
+ try {
+ const results = await Promise.all(propsPromises);
+
+ // Update models with modalities
+ this.models = this.models.map((model) => {
+ const modelIndex = loadedModelIds.indexOf(model.model);
+ if (modelIndex === -1) return model;
+
+ const props = results[modelIndex];
+ if (!props?.modalities) return model;
+
+ const modalities: ModelModalities = {
+ vision: props.modalities.vision ?? false,
+ audio: props.modalities.audio ?? false
+ };
+
+ return { ...model, modalities };
+ });
+
+ // Increment version to trigger reactivity
+ this.propsCacheVersion++;
+ } catch (error) {
+ console.warn('Failed to fetch modalities for loaded models:', error);
+ }
+ }
+
+ /**
+ * Update modalities for a specific model
+ * Called when a model is loaded or when we need fresh modality data
+ */
+ async updateModelModalities(modelId: string): Promise<void> {
+ try {
+ const props = await this.fetchModelProps(modelId);
+ if (!props?.modalities) return;
+
+ const modalities: ModelModalities = {
+ vision: props.modalities.vision ?? false,
+ audio: props.modalities.audio ?? false
+ };
+
+ this.models = this.models.map((model) =>
+ model.model === modelId ? { ...model, modalities } : model
+ );
+
+ // Increment version to trigger reactivity
+ this.propsCacheVersion++;
+ } catch (error) {
+ console.warn(`Failed to update modalities for model ${modelId}:`, error);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Model Selection
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Select a model for new conversations
+ */
+ async selectModelById(modelId: string): Promise<void> {
+ if (!modelId || this.updating) return;
+ if (this.selectedModelId === modelId) return;
+
+ const option = this.models.find((model) => model.id === modelId);
+ if (!option) throw new Error('Selected model is not available');
+
+ this.updating = true;
+ this.error = null;
+
+ try {
+ this.selectedModelId = option.id;
+ this.selectedModelName = option.model;
+ } finally {
+ this.updating = false;
+ }
+ }
+
+ /**
+ * Select a model by its model name (used for syncing with conversation model)
+ * @param modelName - Model name to select (e.g., "unsloth/gemma-3-12b-it-GGUF:latest")
+ */
+ selectModelByName(modelName: string): void {
+ const option = this.models.find((model) => model.model === modelName);
+ if (option) {
+ this.selectedModelId = option.id;
+ this.selectedModelName = option.model;
+ }
+ }
+
+ clearSelection(): void {
+ this.selectedModelId = null;
+ this.selectedModelName = null;
+ }
+
+ findModelByName(modelName: string): ModelOption | null {
+ return this.models.find((model) => model.model === modelName) ?? null;
+ }
+
+ findModelById(modelId: string): ModelOption | null {
+ return this.models.find((model) => model.id === modelId) ?? null;
+ }
+
+ hasModel(modelName: string): boolean {
+ return this.models.some((model) => model.model === modelName);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Loading/Unloading Models
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * WORKAROUND: Polling for model status after load/unload operations.
+ *
+ * Currently, the `/models/load` and `/models/unload` endpoints return success
+ * before the operation actually completes on the server. This means an immediate
+ * request to `/models` returns stale status (e.g., "loading" after load request,
+ * "loaded" after unload request).
+ *
+ * TODO: Remove this polling once llama-server properly waits for the operation
+ * to complete before returning success from `/load` and `/unload` endpoints.
+ * At that point, a single `fetchRouterModels()` call after the operation will
+ * be sufficient to get the correct status.
+ */
+
+ /** Polling interval in ms for checking model status */
+ private static readonly STATUS_POLL_INTERVAL = 500;
+ /** Maximum polling attempts before giving up */
+ private static readonly STATUS_POLL_MAX_ATTEMPTS = 60; // 30 seconds max
+
+ /**
+ * Poll for expected model status after load/unload operation.
+ * Keeps polling until the model reaches the expected status or max attempts reached.
+ *
+ * @param modelId - Model identifier to check
+ * @param expectedStatus - Expected status to wait for
+ * @returns Promise that resolves when expected status is reached
+ */
+ private async pollForModelStatus(
+ modelId: string,
+ expectedStatus: ServerModelStatus
+ ): Promise<void> {
+ for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) {
+ await this.fetchRouterModels();
+
+ const currentStatus = this.getModelStatus(modelId);
+ if (currentStatus === expectedStatus) {
+ return;
+ }
+
+ // Wait before next poll
+ await new Promise((resolve) => setTimeout(resolve, ModelsStore.STATUS_POLL_INTERVAL));
+ }
+
+ console.warn(
+ `Model ${modelId} did not reach expected status ${expectedStatus} after ${ModelsStore.STATUS_POLL_MAX_ATTEMPTS} attempts`
+ );
+ }
+
+ /**
+ * Load a model (ROUTER mode)
+ * @param modelId - Model identifier to load
+ */
+ async loadModel(modelId: string): Promise<void> {
+ if (this.isModelLoaded(modelId)) {
+ return;
+ }
+
+ if (this.modelLoadingStates.get(modelId)) return;
+
+ this.modelLoadingStates.set(modelId, true);
+ this.error = null;
+
+ try {
+ await ModelsService.load(modelId);
+
+ // Poll until model is loaded
+ await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
+
+ await this.updateModelModalities(modelId);
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : 'Failed to load model';
+ throw error;
+ } finally {
+ this.modelLoadingStates.set(modelId, false);
+ }
+ }
+
+ /**
+ * Unload a model (ROUTER mode)
+ * @param modelId - Model identifier to unload
+ */
+ async unloadModel(modelId: string): Promise<void> {
+ if (!this.isModelLoaded(modelId)) {
+ return;
+ }
+
+ if (this.modelLoadingStates.get(modelId)) return;
+
+ this.modelLoadingStates.set(modelId, true);
+ this.error = null;
+
+ try {
+ await ModelsService.unload(modelId);
+
+ await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
+ } catch (error) {
+ this.error = error instanceof Error ? error.message : 'Failed to unload model';
+ throw error;
+ } finally {
+ this.modelLoadingStates.set(modelId, false);
+ }
+ }
+
+ /**
+ * Ensure a model is loaded before use
+ * @param modelId - Model identifier to ensure is loaded
+ */
+ async ensureModelLoaded(modelId: string): Promise<void> {
+ if (this.isModelLoaded(modelId)) {
+ return;
+ }
+
+ await this.loadModel(modelId);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Utilities
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private toDisplayName(id: string): string {
+ const segments = id.split(/\\|\//);
+ const candidate = segments.pop();
+
+ return candidate && candidate.trim().length > 0 ? candidate : id;
+ }
+
+ clear(): void {
+ this.models = [];
+ this.routerModels = [];
+ this.loading = false;
+ this.updating = false;
+ this.error = null;
+ this.selectedModelId = null;
+ this.selectedModelName = null;
+ this.modelUsage.clear();
+ this.modelLoadingStates.clear();
+ this.modelPropsCache.clear();
+ this.modelPropsFetching.clear();
+ }
+}
+
+export const modelsStore = new ModelsStore();
+
+export const modelOptions = () => modelsStore.models;
+export const routerModels = () => modelsStore.routerModels;
+export const modelsLoading = () => modelsStore.loading;
+export const modelsUpdating = () => modelsStore.updating;
+export const modelsError = () => modelsStore.error;
+export const selectedModelId = () => modelsStore.selectedModelId;
+export const selectedModelName = () => modelsStore.selectedModelName;
+export const selectedModelOption = () => modelsStore.selectedModel;
+export const loadedModelIds = () => modelsStore.loadedModelIds;
+export const loadingModelIds = () => modelsStore.loadingModelIds;
+export const propsCacheVersion = () => modelsStore.propsCacheVersion;
+export const singleModelName = () => modelsStore.singleModelName;
+export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts
new file mode 100644
index 0000000..1e07f80
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/persisted.svelte.ts
@@ -0,0 +1,50 @@
+import { browser } from '$app/environment';
+
+type PersistedValue<T> = {
+ get value(): T;
+ set value(newValue: T);
+};
+
+export function persisted<T>(key: string, initialValue: T): PersistedValue<T> {
+ let value = initialValue;
+
+ if (browser) {
+ try {
+ const stored = localStorage.getItem(key);
+
+ if (stored !== null) {
+ value = JSON.parse(stored) as T;
+ }
+ } catch (error) {
+ console.warn(`Failed to load ${key}:`, error);
+ }
+ }
+
+ const persist = (next: T) => {
+ if (!browser) {
+ return;
+ }
+
+ try {
+ if (next === null || next === undefined) {
+ localStorage.removeItem(key);
+ return;
+ }
+
+ localStorage.setItem(key, JSON.stringify(next));
+ } catch (error) {
+ console.warn(`Failed to persist ${key}:`, error);
+ }
+ };
+
+ return {
+ get value() {
+ return value;
+ },
+
+ set value(newValue: T) {
+ value = newValue;
+ persist(newValue);
+ }
+ };
+}
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts
new file mode 100644
index 0000000..facfd33
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/server.svelte.ts
@@ -0,0 +1,140 @@
+import { PropsService } from '$lib/services/props';
+import { ServerRole } from '$lib/enums';
+
+/**
+ * serverStore - Server connection state, configuration, and role detection
+ *
+ * This store manages the server connection state and properties fetched from `/props`.
+ * It provides reactive state for server configuration and role detection.
+ *
+ * **Architecture & Relationships:**
+ * - **PropsService**: Stateless service for fetching `/props` data
+ * - **serverStore** (this class): Reactive store for server state
+ * - **modelsStore**: Independent store for model management (uses PropsService directly)
+ *
+ * **Key Features:**
+ * - **Server State**: Connection status, loading, error handling
+ * - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
+ * - **Default Params**: Server-wide generation defaults
+ */
+class ServerStore {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // State
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ props = $state<ApiLlamaCppServerProps | null>(null);
+ loading = $state(false);
+ error = $state<string | null>(null);
+ role = $state<ServerRole | null>(null);
+ private fetchPromise: Promise<void> | null = null;
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Getters
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
+ return this.props?.default_generation_settings?.params || null;
+ }
+
+ get contextSize(): number | null {
+ return this.props?.default_generation_settings?.n_ctx ?? null;
+ }
+
+ get webuiSettings(): Record<string, string | number | boolean> | undefined {
+ return this.props?.webui_settings;
+ }
+
+ get isRouterMode(): boolean {
+ return this.role === ServerRole.ROUTER;
+ }
+
+ get isModelMode(): boolean {
+ return this.role === ServerRole.MODEL;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Data Handling
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ async fetch(): Promise<void> {
+ if (this.fetchPromise) return this.fetchPromise;
+
+ this.loading = true;
+ this.error = null;
+
+ const fetchPromise = (async () => {
+ try {
+ const props = await PropsService.fetch();
+ this.props = props;
+ this.error = null;
+ this.detectRole(props);
+ } catch (error) {
+ this.error = this.getErrorMessage(error);
+ console.error('Error fetching server properties:', error);
+ } finally {
+ this.loading = false;
+ this.fetchPromise = null;
+ }
+ })();
+
+ this.fetchPromise = fetchPromise;
+ await fetchPromise;
+ }
+
+ private getErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ const message = error.message || '';
+
+ if (error.name === 'TypeError' && message.includes('fetch')) {
+ return 'Server is not running or unreachable';
+ } else if (message.includes('ECONNREFUSED')) {
+ return 'Connection refused - server may be offline';
+ } else if (message.includes('ENOTFOUND')) {
+ return 'Server not found - check server address';
+ } else if (message.includes('ETIMEDOUT')) {
+ return 'Request timed out';
+ } else if (message.includes('503')) {
+ return 'Server temporarily unavailable';
+ } else if (message.includes('500')) {
+ return 'Server error - check server logs';
+ } else if (message.includes('404')) {
+ return 'Server endpoint not found';
+ } else if (message.includes('403') || message.includes('401')) {
+ return 'Access denied';
+ }
+ }
+
+ return 'Failed to connect to server';
+ }
+
+ clear(): void {
+ this.props = null;
+ this.error = null;
+ this.loading = false;
+ this.role = null;
+ this.fetchPromise = null;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Utilities
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ private detectRole(props: ApiLlamaCppServerProps): void {
+ const newRole = props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
+ if (this.role !== newRole) {
+ this.role = newRole;
+ console.info(`Server running in ${newRole === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
+ }
+ }
+}
+
+export const serverStore = new ServerStore();
+
+export const serverProps = () => serverStore.props;
+export const serverLoading = () => serverStore.loading;
+export const serverError = () => serverStore.error;
+export const serverRole = () => serverStore.role;
+export const defaultParams = () => serverStore.defaultParams;
+export const contextSize = () => serverStore.contextSize;
+export const isRouterMode = () => serverStore.isRouterMode;
+export const isModelMode = () => serverStore.isModelMode;
diff --git a/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts b/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts
new file mode 100644
index 0000000..cda940b
--- /dev/null
+++ b/llama.cpp/tools/server/webui/src/lib/stores/settings.svelte.ts
@@ -0,0 +1,421 @@
+/**
+ * settingsStore - Application configuration and theme management
+ *
+ * This store manages all application settings including AI model parameters, UI preferences,
+ * and theme configuration. It provides persistent storage through localStorage with reactive
+ * state management using Svelte 5 runes.
+ *
+ * **Architecture & Relationships:**
+ * - **settingsStore** (this class): Configuration state management
+ * - Manages AI model parameters (temperature, max tokens, etc.)
+ * - Handles theme switching and persistence
+ * - Provides localStorage synchronization
+ * - Offers reactive configuration access
+ *
+ * - **ChatService**: Reads model parameters for API requests
+ * - **UI Components**: Subscribe to theme and configuration changes
+ *
+ * **Key Features:**
+ * - **Model Parameters**: Temperature, max tokens, top-p, top-k, repeat penalty
+ * - **Theme Management**: Auto, light, dark theme switching
+ * - **Persistence**: Automatic localStorage synchronization
+ * - **Reactive State**: Svelte 5 runes for automatic UI updates
+ * - **Default Handling**: Graceful fallback to defaults for missing settings
+ * - **Batch Updates**: Efficient multi-setting updates
+ * - **Reset Functionality**: Restore defaults for individual or all settings
+ *
+ * **Configuration Categories:**
+ * - Generation parameters (temperature, tokens, sampling)
+ * - UI preferences (theme, display options)
+ * - System settings (model selection, prompts)
+ * - Advanced options (seed, penalties, context handling)
+ */
+
+import { browser } from '$app/environment';
+import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
+import { ParameterSyncService } from '$lib/services/parameter-sync';
+import { serverStore } from '$lib/stores/server.svelte';
+import {
+ configToParameterRecord,
+ normalizeFloatingPoint,
+ getConfigValue,
+ setConfigValue
+} from '$lib/utils';
+import {
+ CONFIG_LOCALSTORAGE_KEY,
+ USER_OVERRIDES_LOCALSTORAGE_KEY
+} from '$lib/constants/localstorage-keys';
+
+class SettingsStore {
+ // ─────────────────────────────────────────────────────────────────────────────
+ // State
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ config = $state<SettingsConfigType>({ ...SETTING_CONFIG_DEFAULT });
+ theme = $state<string>('auto');
+ isInitialized = $state(false);
+ userOverrides = $state<Set<string>>(new Set());
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Utilities (private helpers)
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Helper method to get server defaults with null safety
+ * Centralizes the pattern of getting and extracting server defaults
+ */
+ private getServerDefaults(): Record<string, string | number | boolean> {
+ const serverParams = serverStore.defaultParams;
+ const webuiSettings = serverStore.webuiSettings;
+ return ParameterSyncService.extractServerDefaults(serverParams, webuiSettings);
+ }
+
+ constructor() {
+ if (browser) {
+ this.initialize();
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Lifecycle
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Initialize the settings store by loading from localStorage
+ */
+ initialize() {
+ try {
+ this.loadConfig();
+ this.loadTheme();
+ this.isInitialized = true;
+ } catch (error) {
+ console.error('Failed to initialize settings store:', error);
+ }
+ }
+
+ /**
+ * Load configuration from localStorage
+ * Returns default values for missing keys to prevent breaking changes
+ */
+ private loadConfig() {
+ if (!browser) return;
+
+ try {
+ const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
+ const savedVal = JSON.parse(storedConfigRaw || '{}');
+
+ // Merge with defaults to prevent breaking changes
+ this.config = {
+ ...SETTING_CONFIG_DEFAULT,
+ ...savedVal
+ };
+
+ // Load user overrides
+ const savedOverrides = JSON.parse(
+ localStorage.getItem(USER_OVERRIDES_LOCALSTORAGE_KEY) || '[]'
+ );
+ this.userOverrides = new Set(savedOverrides);
+ } catch (error) {
+ console.warn('Failed to parse config from localStorage, using defaults:', error);
+ this.config = { ...SETTING_CONFIG_DEFAULT };
+ this.userOverrides = new Set();
+ }
+ }
+
+ /**
+ * Load theme from localStorage
+ */
+ private loadTheme() {
+ if (!browser) return;
+
+ this.theme = localStorage.getItem('theme') || 'auto';
+ }
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Config Updates
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Update a specific configuration setting
+ * @param key - The configuration key to update
+ * @param value - The new value for the configuration key
+ */
+ updateConfig<K extends keyof SettingsConfigType>(key: K, value: SettingsConfigType[K]): void {
+ this.config[key] = value;
+
+ if (ParameterSyncService.canSyncParameter(key as string)) {
+ const propsDefaults = this.getServerDefaults();
+ const propsDefault = propsDefaults[key as string];
+
+ if (propsDefault !== undefined) {
+ const normalizedValue = normalizeFloatingPoint(value);
+ const normalizedDefault = normalizeFloatingPoint(propsDefault);
+
+ if (normalizedValue === normalizedDefault) {
+ this.userOverrides.delete(key as string);
+ } else {
+ this.userOverrides.add(key as string);
+ }
+ }
+ }
+
+ this.saveConfig();
+ }
+
+ /**
+ * Update multiple configuration settings at once
+ * @param updates - Object containing the configuration updates
+ */
+ updateMultipleConfig(updates: Partial<SettingsConfigType>) {
+ Object.assign(this.config, updates);
+
+ const propsDefaults = this.getServerDefaults();
+
+ for (const [key, value] of Object.entries(updates)) {
+ if (ParameterSyncService.canSyncParameter(key)) {
+ const propsDefault = propsDefaults[key];
+
+ if (propsDefault !== undefined) {
+ const normalizedValue = normalizeFloatingPoint(value);
+ const normalizedDefault = normalizeFloatingPoint(propsDefault);
+
+ if (normalizedValue === normalizedDefault) {
+ this.userOverrides.delete(key);
+ } else {
+ this.userOverrides.add(key);
+ }
+ }
+ }
+ }
+
+ this.saveConfig();
+ }
+
+ /**
+ * Save the current configuration to localStorage
+ */
+ private saveConfig() {
+ if (!browser) return;
+
+ try {
+ localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
+
+ localStorage.setItem(
+ USER_OVERRIDES_LOCALSTORAGE_KEY,
+ JSON.stringify(Array.from(this.userOverrides))
+ );
+ } catch (error) {
+ console.error('Failed to save config to localStorage:', error);
+ }
+ }
+
+ /**
+ * Update the theme setting
+ * @param newTheme - The new theme value
+ */
+ updateTheme(newTheme: string) {
+ this.theme = newTheme;
+ this.saveTheme();
+ }
+
+ /**
+ * Save the current theme to localStorage
+ */
+ private saveTheme() {
+ if (!browser) return;
+
+ try {
+ if (this.theme === 'auto') {
+ localStorage.removeItem('theme');
+ } else {
+ localStorage.setItem('theme', this.theme);
+ }
+ } catch (error) {
+ console.error('Failed to save theme to localStorage:', error);
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Reset
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Reset configuration to defaults
+ */
+ resetConfig() {
+ this.config = { ...SETTING_CONFIG_DEFAULT };
+ this.saveConfig();
+ }
+
+ /**
+ * Reset theme to auto
+ */
+ resetTheme() {
+ this.theme = 'auto';
+ this.saveTheme();
+ }
+
+ /**
+ * Reset all settings to defaults
+ */
+ resetAll() {
+ this.resetConfig();
+ this.resetTheme();
+ }
+
+ /**
+ * Reset a parameter to server default (or webui default if no server default)
+ */
+ resetParameterToServerDefault(key: string): void {
+ const serverDefaults = this.getServerDefaults();
+
+ if (serverDefaults[key] !== undefined) {
+ const value = normalizeFloatingPoint(serverDefaults[key]);
+
+ this.config[key as keyof SettingsConfigType] =
+ value as SettingsConfigType[keyof SettingsConfigType];
+ } else {
+ if (key in SETTING_CONFIG_DEFAULT) {
+ const defaultValue = getConfigValue(SETTING_CONFIG_DEFAULT, key);
+
+ setConfigValue(this.config, key, defaultValue);
+ }
+ }
+
+ this.userOverrides.delete(key);
+ this.saveConfig();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Server Sync
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Initialize settings with props defaults when server properties are first loaded
+ * This sets up the default values from /props endpoint
+ */
+ syncWithServerDefaults(): void {
+ const propsDefaults = this.getServerDefaults();
+
+ if (Object.keys(propsDefaults).length === 0) {
+ console.warn('No server defaults available for initialization');
+
+ return;
+ }
+
+ for (const [key, propsValue] of Object.entries(propsDefaults)) {
+ const currentValue = getConfigValue(this.config, key);
+
+ const normalizedCurrent = normalizeFloatingPoint(currentValue);
+ const normalizedDefault = normalizeFloatingPoint(propsValue);
+
+ if (normalizedCurrent === normalizedDefault) {
+ this.userOverrides.delete(key);
+ setConfigValue(this.config, key, propsValue);
+ } else if (!this.userOverrides.has(key)) {
+ setConfigValue(this.config, key, propsValue);
+ }
+ }
+
+ this.saveConfig();
+ console.log('Settings initialized with props defaults:', propsDefaults);
+ console.log('Current user overrides after sync:', Array.from(this.userOverrides));
+ }
+
+ /**
+ * Reset all parameters to their default values (from props)
+ * This is used by the "Reset to Default" functionality
+ * Prioritizes server defaults from /props, falls back to webui defaults
+ */
+ forceSyncWithServerDefaults(): void {
+ const propsDefaults = this.getServerDefaults();
+ const syncableKeys = ParameterSyncService.getSyncableParameterKeys();
+
+ for (const key of syncableKeys) {
+ if (propsDefaults[key] !== undefined) {
+ const normalizedValue = normalizeFloatingPoint(propsDefaults[key]);
+
+ setConfigValue(this.config, key, normalizedValue);
+ } else {
+ if (key in SETTING_CONFIG_DEFAULT) {
+ const defaultValue = getConfigValue(SETTING_CONFIG_DEFAULT, key);
+
+ setConfigValue(this.config, key, defaultValue);
+ }
+ }
+
+ this.userOverrides.delete(key);
+ }
+
+ this.saveConfig();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Utilities
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Get a specific configuration value
+ * @param key - The configuration key to get
+ * @returns The configuration value
+ */
+ getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
+ return this.config[key];
+ }
+
+ /**
+ * Get the entire configuration object
+ * @returns The complete configuration object
+ */
+ getAllConfig(): SettingsConfigType {
+ return { ...this.config };
+ }
+
+ canSyncParameter(key: string): boolean {
+ return ParameterSyncService.canSyncParameter(key);
+ }
+
+ /**
+ * Get parameter information including source for a specific parameter
+ */
+ getParameterInfo(key: string) {
+ const propsDefaults = this.getServerDefaults();
+ const currentValue = getConfigValue(this.config, key);
+
+ return ParameterSyncService.getParameterInfo(
+ key,
+ currentValue ?? '',
+ propsDefaults,
+ this.userOverrides
+ );
+ }
+
+ /**
+ * Get diff between current settings and server defaults
+ */
+ getParameterDiff() {
+ const serverDefaults = this.getServerDefaults();
+ if (Object.keys(serverDefaults).length === 0) return {};
+
+ const configAsRecord = configToParameterRecord(
+ this.config,
+ ParameterSyncService.getSyncableParameterKeys()
+ );
+
+ return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
+ }
+
+ /**
+ * Clear all user overrides (for debugging)
+ */
+ clearAllUserOverrides(): void {
+ this.userOverrides.clear();
+ this.saveConfig();
+ console.log('Cleared all user overrides');
+ }
+}
+
+export const settingsStore = new SettingsStore();
+
+export const config = () => settingsStore.config;
+export const theme = () => settingsStore.theme;
+export const isInitialized = () => settingsStore.isInitialized;