server: introduce API for serving / loading / unloading multiple models (#17470)

* server: add model management and proxy

* fix compile error

* does this fix windows?

* fix windows build

* use subprocess.h, better logging

* add test

* fix windows

* feat: Model/Router server architecture WIP

* more stable

* fix unsafe pointer

* also allow terminate loading model

* add is_active()

* refactor: Architecture improvements

* tmp apply upstream fix

* address most problems

* address thread safety issue

* address review comment

* add docs (first version)

* address review comment

* feat: Improved UX for model information, modality interactions etc

* chore: update webui build output

* refactor: Use only the message data `model` property for displaying model used info

* chore: update webui build output

* add --models-dir param

* feat: New Model Selection UX WIP

* chore: update webui build output

* feat: Add auto-mic setting

* feat: Attachments UX improvements

* implement LRU

* remove default model path

* better --models-dir

* add env for args

* address review comments

* fix compile

* refactor: Chat Form Submit component

* ad endpoint docs

* Merge remote-tracking branch 'webui/allozaur/server_model_management_v1_2' into xsn/server_model_maagement_v1_2

Co-authored-by: Aleksander <aleksander.grygier@gmail.com>

* feat: Add copy to clipboard to model name in model info dialog

* feat: Model unavailable UI state for model selector

* feat: Chat Form Actions UI logic improvements

* feat: Auto-select model from last assistant response

* chore: update webui build output

* expose args and exit_code in API

* add note

* support extra_args on loading model

* allow reusing args if auto_load

* typo docs

* oai-compat /models endpoint

* cleaner

* address review comments

* feat: Use `model` property for displaying the `repo/model-name` naming format

* refactor: Attachments data

* chore: update webui build output

* refactor: Enum imports

* feat: Improve Model Selector responsiveness

* chore: update webui build output

* refactor: Cleanup

* refactor: Cleanup

* refactor: Formatters

* chore: update webui build output

* refactor: Copy To Clipboard Icon component

* chore: update webui build output

* refactor: Cleanup

* chore: update webui build output

* refactor: UI badges

* chore: update webui build output

* refactor: Cleanup

* refactor: Cleanup

* chore: update webui build output

* add --models-allow-extra-args for security

* nits

* add stdin_file

* fix merge

* fix: Retrieve lost setting after resolving merge conflict

* refactor: DatabaseStore -> DatabaseService

* refactor: Database, Conversations & Chat services + stores architecture improvements (WIP)

* refactor: Remove redundant settings

* refactor: Multi-model business logic WIP

* chore: update webui build output

* feat: Switching models logic for ChatForm or when regenerating messges + modality detection logic

* chore: update webui build output

* fix: Add `untrack` inside chat processing info data logic to prevent infinite effect

* fix: Regenerate

* feat: Remove redundant settigns + rearrange

* fix: Audio attachments

* refactor: Icons

* chore: update webui build output

* feat: Model management and selection features WIP

* chore: update webui build output

* refactor: Improve server properties management

* refactor: Icons

* chore: update webui build output

* feat: Improve model loading/unloading status updates

* chore: update webui build output

* refactor: Improve API header management via utility functions

* remove support for extra args

* set hf_repo/docker_repo as model alias when posible

* refactor: Remove ConversationsService

* refactor: Chat requests abort handling

* refactor: Server store

* tmp webui build

* refactor: Model modality handling

* chore: update webui build output

* refactor: Processing state reactivity

* fix: UI

* refactor: Services/Stores syntax + logic improvements

Refactors components to access stores directly instead of using exported getter functions.

This change centralizes store access and logic, simplifying component code and improving maintainability by reducing the number of exported functions and promoting direct store interaction.

Removes exported getter functions from `chat.svelte.ts`, `conversations.svelte.ts`, `models.svelte.ts` and `settings.svelte.ts`.

* refactor: Architecture cleanup

* feat: Improve statistic badges

* feat: Condition available models based on modality + better model loading strategy & UX

* docs: Architecture documentation

* feat: Update logic for PDF as Image

* add TODO for http client

* refactor: Enhance model info and attachment handling

* chore: update webui build output

* refactor: Components naming

* chore: update webui build output

* refactor: Cleanup

* refactor: DRY `getAttachmentDisplayItems` function + fix UI

* chore: update webui build output

* fix: Modality detection improvement for text-based PDF attachments

* refactor: Cleanup

* docs: Add info comment

* refactor: Cleanup

* re

* refactor: Cleanup

* refactor: Cleanup

* feat: Attachment logic & UI improvements

* refactor: Constants

* feat: Improve UI sidebar background color

* chore: update webui build output

* refactor: Utils imports + move types to `app.d.ts`

* test: Fix Storybook mocks

* chore: update webui build output

* test: Update Chat Form UI tests

* refactor: Tooltip Provider from core layout

* refactor: Tests to separate location

* decouple server_models from server_routes

* test: Move demo test  to tests/server

* refactor: Remove redundant method

* chore: update webui build output

* also route anthropic endpoints

* fix duplicated arg

* fix invalid ptr to shutdown_handler

* server : minor

* rm unused fn

* add ?autoload=true|false query param

* refactor: Remove redundant code

* docs: Update README documentations + architecture & data flow diagrams

* fix: Disable autoload on calling server props for the model

* chore: update webui build output

* fix ubuntu build

* fix: Model status reactivity

* fix: Modality detection for MODEL mode

* chore: update webui build output

---------

Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
Co-authored-by: Georgi Gerganov <ggerganov@gmail.com>
This commit is contained in:
Xuan-Son Nguyen
2025-12-01 19:41:04 +01:00
committed by GitHub
parent 7733409734
commit ec18edfcba
178 changed files with 11643 additions and 4356 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,627 @@
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.activeConversation = null;
this.activeMessages = [];
await goto(`?new_chat=true#/`);
}
} catch (error) {
console.error('Failed to delete conversation:', error);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 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);
}
/**
* 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;
@@ -1,386 +0,0 @@
import Dexie, { type EntityTable } from 'dexie';
import { filterByLeafNodeId, findDescendantMessages } from '$lib/utils/branching';
class LlamacppDatabase extends Dexie {
conversations!: EntityTable<DatabaseConversation, string>;
messages!: EntityTable<DatabaseMessage, string>;
constructor() {
super('LlamacppWebui');
this.version(1).stores({
conversations: 'id, lastModified, currNode, name',
messages: 'id, convId, type, role, timestamp, parent, children'
});
}
}
const db = new LlamacppDatabase();
/**
* DatabaseStore - Persistent data layer for conversation and message management
*
* This service provides a comprehensive data access layer built on IndexedDB using Dexie.
* It handles all persistent storage operations for conversations, messages, and application settings
* with support for complex conversation branching and message threading.
*
* **Architecture & Relationships:**
* - **DatabaseStore** (this class): Stateless data persistence layer
* - Manages IndexedDB operations through Dexie ORM
* - Handles conversation and message CRUD operations
* - Supports complex branching with parent-child relationships
* - Provides transaction safety for multi-table operations
*
* - **ChatStore**: Primary consumer for conversation state management
* - Uses DatabaseStore for all persistence operations
* - Coordinates UI state with database state
* - Handles conversation lifecycle and message branching
*
* **Key Features:**
* - **Conversation Management**: Create, read, update, delete conversations
* - **Message Branching**: Support for tree-like conversation structures
* - **Transaction Safety**: Atomic operations for data consistency
* - **Path Resolution**: Navigate conversation branches and find leaf nodes
* - **Cascading Deletion**: Remove entire conversation branches
*
* **Database Schema:**
* - `conversations`: Conversation metadata with current node tracking
* - `messages`: Individual messages with parent-child relationships
*
* **Branching Model:**
* Messages form a tree structure where each message can have multiple children,
* enabling conversation branching and alternative response paths. The conversation's
* `currNode` tracks the currently active branch endpoint.
*/
import { v4 as uuid } from 'uuid';
export class DatabaseStore {
/**
* Adds a new message to the database.
*
* @param message - Message to add (without id)
* @returns The created message
*/
static async addMessage(message: Omit<DatabaseMessage, 'id'>): Promise<DatabaseMessage> {
const newMessage: DatabaseMessage = {
...message,
id: uuid()
};
await db.messages.add(newMessage);
return newMessage;
}
/**
* Creates a new conversation.
*
* @param name - Name of the conversation
* @returns The created conversation
*/
static async createConversation(name: string): Promise<DatabaseConversation> {
const conversation: DatabaseConversation = {
id: uuid(),
name,
lastModified: Date.now(),
currNode: ''
};
await db.conversations.add(conversation);
return conversation;
}
/**
* Creates a new message branch by adding a message and updating parent/child relationships.
* Also updates the conversation's currNode to point to the new message.
*
* @param message - Message to add (without id)
* @param parentId - Parent message ID to attach to
* @returns The created message
*/
static async createMessageBranch(
message: Omit<DatabaseMessage, 'id'>,
parentId: string | null
): Promise<DatabaseMessage> {
return await db.transaction('rw', [db.conversations, db.messages], async () => {
// Handle null parent (root message case)
if (parentId !== null) {
const parentMessage = await db.messages.get(parentId);
if (!parentMessage) {
throw new Error(`Parent message ${parentId} not found`);
}
}
const newMessage: DatabaseMessage = {
...message,
id: uuid(),
parent: parentId,
toolCalls: message.toolCalls ?? '',
children: []
};
await db.messages.add(newMessage);
// Update parent's children array if parent exists
if (parentId !== null) {
const parentMessage = await db.messages.get(parentId);
if (parentMessage) {
await db.messages.update(parentId, {
children: [...parentMessage.children, newMessage.id]
});
}
}
await this.updateConversation(message.convId, {
currNode: newMessage.id
});
return newMessage;
});
}
/**
* Creates a root message for a new conversation.
* Root messages are not displayed but serve as the tree root for branching.
*
* @param convId - Conversation ID
* @returns The created root message
*/
static async createRootMessage(convId: string): Promise<string> {
const rootMessage: DatabaseMessage = {
id: uuid(),
convId,
type: 'root',
timestamp: Date.now(),
role: 'system',
content: '',
parent: null,
thinking: '',
toolCalls: '',
children: []
};
await db.messages.add(rootMessage);
return rootMessage.id;
}
/**
* Deletes a conversation and all its messages.
*
* @param id - Conversation ID
*/
static async deleteConversation(id: string): Promise<void> {
await db.transaction('rw', [db.conversations, db.messages], async () => {
await db.conversations.delete(id);
await db.messages.where('convId').equals(id).delete();
});
}
/**
* Deletes a message and removes it from its parent's children array.
*
* @param messageId - ID of the message to delete
*/
static async deleteMessage(messageId: string): Promise<void> {
await db.transaction('rw', db.messages, async () => {
const message = await db.messages.get(messageId);
if (!message) return;
// Remove this message from its parent's children array
if (message.parent) {
const parent = await db.messages.get(message.parent);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db.messages.put(parent);
}
}
// Delete the message
await db.messages.delete(messageId);
});
}
/**
* Deletes a message and all its descendant messages (cascading deletion).
* This removes the entire branch starting from the specified message.
*
* @param conversationId - ID of the conversation containing the message
* @param messageId - ID of the root message to delete (along with all descendants)
* @returns Array of all deleted message IDs
*/
static async deleteMessageCascading(
conversationId: string,
messageId: string
): Promise<string[]> {
return await db.transaction('rw', db.messages, async () => {
// Get all messages in the conversation to find descendants
const allMessages = await db.messages.where('convId').equals(conversationId).toArray();
// Find all descendant messages
const descendants = findDescendantMessages(allMessages, messageId);
const allToDelete = [messageId, ...descendants];
// Get the message to delete for parent cleanup
const message = await db.messages.get(messageId);
if (message && message.parent) {
const parent = await db.messages.get(message.parent);
if (parent) {
parent.children = parent.children.filter((childId: string) => childId !== messageId);
await db.messages.put(parent);
}
}
// Delete all messages in the branch
await db.messages.bulkDelete(allToDelete);
return allToDelete;
});
}
/**
* Gets all conversations, sorted by last modified time (newest first).
*
* @returns Array of conversations
*/
static async getAllConversations(): Promise<DatabaseConversation[]> {
return await db.conversations.orderBy('lastModified').reverse().toArray();
}
/**
* Gets a conversation by ID.
*
* @param id - Conversation ID
* @returns The conversation if found, otherwise undefined
*/
static async getConversation(id: string): Promise<DatabaseConversation | undefined> {
return await db.conversations.get(id);
}
/**
* Gets all leaf nodes (messages with no children) in a conversation.
* Useful for finding all possible conversation endpoints.
*
* @param convId - Conversation ID
* @returns Array of leaf node message IDs
*/
static async getConversationLeafNodes(convId: string): Promise<string[]> {
const allMessages = await this.getConversationMessages(convId);
return allMessages.filter((msg) => msg.children.length === 0).map((msg) => msg.id);
}
/**
* Gets all messages in a conversation, sorted by timestamp (oldest first).
*
* @param convId - Conversation ID
* @returns Array of messages in the conversation
*/
static async getConversationMessages(convId: string): Promise<DatabaseMessage[]> {
return await db.messages.where('convId').equals(convId).sortBy('timestamp');
}
/**
* Gets the conversation path from root to the current leaf node.
* Uses the conversation's currNode to determine the active branch.
*
* @param convId - Conversation ID
* @returns Array of messages in the current conversation path
*/
static async getConversationPath(convId: string): Promise<DatabaseMessage[]> {
const conversation = await this.getConversation(convId);
if (!conversation) {
return [];
}
const allMessages = await this.getConversationMessages(convId);
if (allMessages.length === 0) {
return [];
}
// If no currNode is set, use the latest message as leaf
const leafNodeId =
conversation.currNode ||
allMessages.reduce((latest, msg) => (msg.timestamp > latest.timestamp ? msg : latest)).id;
return filterByLeafNodeId(allMessages, leafNodeId, false) as DatabaseMessage[];
}
/**
* Updates a conversation.
*
* @param id - Conversation ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the conversation is updated
*/
static async updateConversation(
id: string,
updates: Partial<Omit<DatabaseConversation, 'id'>>
): Promise<void> {
await db.conversations.update(id, {
...updates,
lastModified: Date.now()
});
}
/**
* Updates the conversation's current node (active branch).
* This determines which conversation path is currently being viewed.
*
* @param convId - Conversation ID
* @param nodeId - Message ID to set as current node
*/
static async updateCurrentNode(convId: string, nodeId: string): Promise<void> {
await this.updateConversation(convId, {
currNode: nodeId
});
}
/**
* Updates a message.
*
* @param id - Message ID
* @param updates - Partial updates to apply
* @returns Promise that resolves when the message is updated
*/
static async updateMessage(
id: string,
updates: Partial<Omit<DatabaseMessage, 'id'>>
): Promise<void> {
await db.messages.update(id, updates);
}
/**
* Imports multiple conversations and their messages.
* Skips conversations that already exist.
*
* @param data - Array of { conv, messages } objects
*/
static async importConversations(
data: { conv: DatabaseConversation; messages: DatabaseMessage[] }[]
): Promise<{ imported: number; skipped: number }> {
let importedCount = 0;
let skippedCount = 0;
return await db.transaction('rw', [db.conversations, db.messages], async () => {
for (const item of data) {
const { conv, messages } = item;
const existing = await db.conversations.get(conv.id);
if (existing) {
console.warn(`Conversation "${conv.name}" already exists, skipping...`);
skippedCount++;
continue;
}
await db.conversations.add(conv);
for (const msg of messages) {
await db.messages.put(msg);
}
importedCount++;
}
return { imported: importedCount, skipped: skippedCount };
});
}
}
+512 -118
View File
@@ -1,76 +1,221 @@
import { SvelteSet } from 'svelte/reactivity';
import { ModelsService } from '$lib/services/models';
import { persisted } from '$lib/stores/persisted.svelte';
import { SELECTED_MODEL_LOCALSTORAGE_KEY } from '$lib/constants/localstorage-keys';
import type { ModelOption } from '$lib/types/models';
type PersistedModelSelection = {
id: string;
model: string;
};
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 {
private _models = $state<ModelOption[]>([]);
private _loading = $state(false);
private _updating = $state(false);
private _error = $state<string | null>(null);
private _selectedModelId = $state<string | null>(null);
private _selectedModelName = $state<string | null>(null);
private _persistedSelection = persisted<PersistedModelSelection | null>(
SELECTED_MODEL_LOCALSTORAGE_KEY,
null
);
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
constructor() {
const persisted = this._persistedSelection.value;
if (persisted) {
this._selectedModelId = persisted.id;
this._selectedModelName = persisted.model;
}
}
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);
get models(): ModelOption[] {
return this._models;
}
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
private modelLoadingStates = $state<Map<string, boolean>>(new Map());
get loading(): boolean {
return this._loading;
}
/**
* 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());
get updating(): boolean {
return this._updating;
}
/**
* Version counter for props cache - used to trigger reactivity when props are updated
*/
propsCacheVersion = $state(0);
get error(): string | null {
return this._error;
}
get selectedModelId(): string | null {
return this._selectedModelId;
}
get selectedModelName(): string | null {
return this._selectedModelName;
}
// ─────────────────────────────────────────────────────────────────────────────
// Computed Getters
// ─────────────────────────────────────────────────────────────────────────────
get selectedModel(): ModelOption | null {
if (!this._selectedModelId) {
return null;
}
return this._models.find((model) => model.id === this._selectedModelId) ?? null;
if (!this.selectedModelId) return null;
return this.models.find((model) => model.id === this.selectedModelId) ?? null;
}
async fetch(force = false): Promise<void> {
if (this._loading) return;
if (this._models.length > 0 && !force) return;
get loadedModelIds(): string[] {
return this.routerModels
.filter((m) => m.status.value === ServerModelStatus.LOADED)
.map((m) => m.id);
}
this._loading = true;
this._error = null;
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;
}
/**
* 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, index) => {
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 =
@@ -82,56 +227,322 @@ class ModelsStore {
name: displayName,
model: details?.model || item.id,
description: details?.description,
capabilities: rawCapabilities.filter((value): value is string => Boolean(value)),
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
details: details?.details,
meta: item.meta ?? null
} satisfies ModelOption;
});
this._models = models;
this.models = models;
const selection = this.determineInitialSelection(models);
this._selectedModelId = selection.id;
this._selectedModelName = selection.model;
this._persistedSelection.value =
selection.id && selection.model ? { id: selection.id, model: selection.model } : null;
// 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';
this.models = [];
this.error = error instanceof Error ? error.message : 'Failed to load models';
throw error;
} finally {
this._loading = false;
this.loading = false;
}
}
async select(modelId: string): Promise<void> {
if (!modelId || this._updating) {
return;
/**
* 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 = [];
}
}
if (this._selectedModelId === modelId) {
return;
}
/**
* Fetch props for a specific model from /props endpoint
* Uses caching to avoid redundant requests
*
* @param modelId - Model identifier to fetch props for
* @returns Props data or null if fetch failed
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
// Return cached props if available
const cached = this.modelPropsCache.get(modelId);
if (cached) return cached;
const option = this._models.find((model) => model.id === modelId);
if (!option) {
throw new Error('Selected model is not available');
}
// Avoid duplicate fetches
if (this.modelPropsFetching.has(modelId)) return null;
this._updating = true;
this._error = null;
this.modelPropsFetching.add(modelId);
try {
this._selectedModelId = option.id;
this._selectedModelName = option.model;
this._persistedSelection.value = { id: option.id, model: option.model };
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._updating = false;
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();
@@ -139,49 +550,32 @@ class ModelsStore {
return candidate && candidate.trim().length > 0 ? candidate : id;
}
/**
* Determines which model should be selected after fetching the models list.
* Priority: current selection > persisted selection > first available model > none
*/
private determineInitialSelection(models: ModelOption[]): {
id: string | null;
model: string | null;
} {
const persisted = this._persistedSelection.value;
let nextSelectionId = this._selectedModelId ?? persisted?.id ?? null;
let nextSelectionName = this._selectedModelName ?? persisted?.model ?? null;
if (nextSelectionId) {
const match = models.find((m) => m.id === nextSelectionId);
if (match) {
nextSelectionId = match.id;
nextSelectionName = match.model;
} else if (models[0]) {
nextSelectionId = models[0].id;
nextSelectionName = models[0].model;
} else {
nextSelectionId = null;
nextSelectionName = null;
}
} else if (models[0]) {
nextSelectionId = models[0].id;
nextSelectionName = models[0].model;
}
return { id: nextSelectionId, model: nextSelectionName };
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 fetchModels = modelsStore.fetch.bind(modelsStore);
export const selectModel = modelsStore.select.bind(modelsStore);
export const loadedModelIds = () => modelsStore.loadedModelIds;
export const loadingModelIds = () => modelsStore.loadingModelIds;
export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName;
@@ -1,331 +1,136 @@
import { browser } from '$app/environment';
import { SERVER_PROPS_LOCALSTORAGE_KEY } from '$lib/constants/localstorage-keys';
import { ChatService } from '$lib/services/chat';
import { config } from '$lib/stores/settings.svelte';
import { PropsService } from '$lib/services/props';
import { ServerRole } from '$lib/enums';
/**
* ServerStore - Server state management and capability detection
* serverStore - Server connection state, configuration, and role detection
*
* This store manages communication with the llama.cpp server to retrieve and maintain
* server properties, model information, and capability detection. It provides reactive
* state for server connectivity, model capabilities, and endpoint availability.
* This store manages the server connection state and properties fetched from `/props`.
* It provides reactive state for server configuration and role detection.
*
* **Architecture & Relationships:**
* - **ServerStore** (this class): Server state and capability management
* - Fetches and caches server properties from `/props` endpoint
* - Detects model capabilities (vision, audio support)
* - Tests endpoint availability (slots endpoint)
* - Provides reactive server state for UI components
*
* - **ChatService**: Uses server properties for request validation
* - **SlotsService**: Depends on slots endpoint availability detection
* - **UI Components**: Subscribe to server state for capability-based rendering
* - **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 Properties**: Model path, context size, build information
* - **Capability Detection**: Vision and audio modality support
* - **Endpoint Testing**: Slots endpoint availability checking
* - **Error Handling**: User-friendly error messages for connection issues
* - **Reactive State**: Svelte 5 runes for automatic UI updates
* - **State Management**: Loading states and error recovery
*
* **Server Capabilities Detected:**
* - Model name extraction from file path
* - Vision support (multimodal image processing)
* - Audio support (speech processing)
* - Slots endpoint availability (for processing state monitoring)
* - Context window size and token limits
* - **Server State**: Connection status, loading, error handling
* - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
* - **Default Params**: Server-wide generation defaults
*/
class ServerStore {
constructor() {
if (!browser) return;
// ─────────────────────────────────────────────────────────────────────────────
// State
// ─────────────────────────────────────────────────────────────────────────────
const cachedProps = this.readCachedServerProps();
if (cachedProps) {
this._serverProps = cachedProps;
}
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;
}
private _serverProps = $state<ApiLlamaCppServerProps | null>(null);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _serverWarning = $state<string | null>(null);
private _slotsEndpointAvailable = $state<boolean | null>(null);
private fetchServerPropsPromise: Promise<void> | null = null;
private readCachedServerProps(): ApiLlamaCppServerProps | null {
if (!browser) return null;
try {
const raw = localStorage.getItem(SERVER_PROPS_LOCALSTORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as ApiLlamaCppServerProps;
} catch (error) {
console.warn('Failed to read cached server props from localStorage:', error);
return null;
}
get contextSize(): number | null {
return this.props?.default_generation_settings?.n_ctx ?? null;
}
private persistServerProps(props: ApiLlamaCppServerProps | null): void {
if (!browser) return;
try {
if (props) {
localStorage.setItem(SERVER_PROPS_LOCALSTORAGE_KEY, JSON.stringify(props));
} else {
localStorage.removeItem(SERVER_PROPS_LOCALSTORAGE_KEY);
}
} catch (error) {
console.warn('Failed to persist server props to localStorage:', error);
}
get isRouterMode(): boolean {
return this.role === ServerRole.ROUTER;
}
get serverProps(): ApiLlamaCppServerProps | null {
return this._serverProps;
get isModelMode(): boolean {
return this.role === ServerRole.MODEL;
}
get loading(): boolean {
return this._loading;
}
// ─────────────────────────────────────────────────────────────────────────────
// Data Handling
// ─────────────────────────────────────────────────────────────────────────────
get error(): string | null {
return this._error;
}
async fetch(): Promise<void> {
if (this.fetchPromise) return this.fetchPromise;
get serverWarning(): string | null {
return this._serverWarning;
}
get modelName(): string | null {
if (this._serverProps?.model_alias) {
return this._serverProps.model_alias;
}
if (!this._serverProps?.model_path) return null;
return this._serverProps.model_path.split(/(\\|\/)/).pop() || null;
}
get supportedModalities(): string[] {
const modalities: string[] = [];
if (this._serverProps?.modalities?.audio) {
modalities.push('audio');
}
if (this._serverProps?.modalities?.vision) {
modalities.push('vision');
}
return modalities;
}
get supportsVision(): boolean {
return this._serverProps?.modalities?.vision ?? false;
}
get supportsAudio(): boolean {
return this._serverProps?.modalities?.audio ?? false;
}
get slotsEndpointAvailable(): boolean | null {
return this._slotsEndpointAvailable;
}
get serverDefaultParams():
| ApiLlamaCppServerProps['default_generation_settings']['params']
| null {
return this._serverProps?.default_generation_settings?.params || null;
}
/**
* Check if slots endpoint is available based on server properties and endpoint support
*/
private async checkSlotsEndpointAvailability(): Promise<void> {
if (!this._serverProps) {
this._slotsEndpointAvailable = false;
return;
}
if (this._serverProps.total_slots <= 0) {
this._slotsEndpointAvailable = false;
return;
}
try {
const currentConfig = config();
const apiKey = currentConfig.apiKey?.toString().trim();
const response = await fetch(`./slots`, {
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
}
});
if (response.status === 501) {
console.info('Slots endpoint not implemented - server started without --slots flag');
this._slotsEndpointAvailable = false;
return;
}
this._slotsEndpointAvailable = true;
} catch (error) {
console.warn('Unable to test slots endpoint availability:', error);
this._slotsEndpointAvailable = false;
}
}
/**
* Fetches server properties from the server
*/
async fetchServerProps(options: { silent?: boolean } = {}): Promise<void> {
const { silent = false } = options;
const isSilent = silent && this._serverProps !== null;
if (this.fetchServerPropsPromise) {
return this.fetchServerPropsPromise;
}
if (!isSilent) {
this._loading = true;
this._error = null;
this._serverWarning = null;
}
const hadProps = this._serverProps !== null;
this.loading = true;
this.error = null;
const fetchPromise = (async () => {
try {
const props = await ChatService.getServerProps();
this._serverProps = props;
this.persistServerProps(props);
this._error = null;
this._serverWarning = null;
await this.checkSlotsEndpointAvailability();
const props = await PropsService.fetch();
this.props = props;
this.error = null;
this.detectRole(props);
} catch (error) {
if (isSilent && hadProps) {
console.warn('Silent server props refresh failed, keeping cached data:', error);
return;
}
this.handleFetchServerPropsError(error, hadProps);
this.error = this.getErrorMessage(error);
console.error('Error fetching server properties:', error);
} finally {
if (!isSilent) {
this._loading = false;
}
this.fetchServerPropsPromise = null;
this.loading = false;
this.fetchPromise = null;
}
})();
this.fetchServerPropsPromise = fetchPromise;
this.fetchPromise = fetchPromise;
await fetchPromise;
}
/**
* Handles fetch failures by attempting to recover cached server props and
* updating the user-facing error or warning state appropriately.
*/
private handleFetchServerPropsError(error: unknown, hadProps: boolean): void {
const { errorMessage, isOfflineLikeError, isServerSideError } = this.normalizeFetchError(error);
let cachedProps: ApiLlamaCppServerProps | null = null;
if (!hadProps) {
cachedProps = this.readCachedServerProps();
if (cachedProps) {
this._serverProps = cachedProps;
this._error = null;
if (isOfflineLikeError || isServerSideError) {
this._serverWarning = errorMessage;
}
console.warn(
'Failed to refresh server properties, using cached values from localStorage:',
errorMessage
);
} else {
this._error = errorMessage;
}
} else {
this._error = null;
if (isOfflineLikeError || isServerSideError) {
this._serverWarning = errorMessage;
}
console.warn(
'Failed to refresh server properties, continuing with cached values:',
errorMessage
);
}
console.error('Error fetching server properties:', error);
}
private normalizeFetchError(error: unknown): {
errorMessage: string;
isOfflineLikeError: boolean;
isServerSideError: boolean;
} {
let errorMessage = 'Failed to connect to server';
let isOfflineLikeError = false;
let isServerSideError = false;
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message || '';
if (error.name === 'TypeError' && message.includes('fetch')) {
errorMessage = 'Server is not running or unreachable';
isOfflineLikeError = true;
return 'Server is not running or unreachable';
} else if (message.includes('ECONNREFUSED')) {
errorMessage = 'Connection refused - server may be offline';
isOfflineLikeError = true;
return 'Connection refused - server may be offline';
} else if (message.includes('ENOTFOUND')) {
errorMessage = 'Server not found - check server address';
isOfflineLikeError = true;
return 'Server not found - check server address';
} else if (message.includes('ETIMEDOUT')) {
errorMessage = 'Request timed out - the server took too long to respond';
isOfflineLikeError = true;
return 'Request timed out';
} else if (message.includes('503')) {
errorMessage = 'Server temporarily unavailable - try again shortly';
isServerSideError = true;
return 'Server temporarily unavailable';
} else if (message.includes('500')) {
errorMessage = 'Server error - check server logs';
isServerSideError = true;
return 'Server error - check server logs';
} else if (message.includes('404')) {
errorMessage = 'Server endpoint not found';
return 'Server endpoint not found';
} else if (message.includes('403') || message.includes('401')) {
errorMessage = 'Access denied';
return 'Access denied';
}
}
return { errorMessage, isOfflineLikeError, isServerSideError };
return 'Failed to connect to server';
}
/**
* Clears the server state
*/
clear(): void {
this._serverProps = null;
this._error = null;
this._serverWarning = null;
this._loading = false;
this._slotsEndpointAvailable = null;
this.fetchServerPropsPromise = null;
this.persistServerProps(null);
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.serverProps;
export const serverProps = () => serverStore.props;
export const serverLoading = () => serverStore.loading;
export const serverError = () => serverStore.error;
export const serverWarning = () => serverStore.serverWarning;
export const modelName = () => serverStore.modelName;
export const supportedModalities = () => serverStore.supportedModalities;
export const supportsVision = () => serverStore.supportsVision;
export const supportsAudio = () => serverStore.supportsAudio;
export const slotsEndpointAvailable = () => serverStore.slotsEndpointAvailable;
export const serverDefaultParams = () => serverStore.serverDefaultParams;
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;
@@ -1,12 +1,12 @@
/**
* SettingsStore - Application configuration and theme management
* 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
* - **settingsStore** (this class): Configuration state management
* - Manages AI model parameters (temperature, max tokens, etc.)
* - Handles theme switching and persistence
* - Provides localStorage synchronization
@@ -33,23 +33,39 @@
import { browser } from '$app/environment';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { normalizeFloatingPoint } from '$lib/utils/precision';
import { ParameterSyncService } from '$lib/services/parameter-sync';
import { serverStore } from '$lib/stores/server.svelte';
import { setConfigValue, getConfigValue, configToParameterRecord } from '$lib/utils/config-helpers';
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.serverDefaultParams;
const serverParams = serverStore.defaultParams;
return serverParams ? ParameterSyncService.extractServerDefaults(serverParams) : {};
}
@@ -59,6 +75,10 @@ class SettingsStore {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Lifecycle
// ─────────────────────────────────────────────────────────────────────────────
/**
* Initialize the settings store by loading from localStorage
*/
@@ -80,7 +100,7 @@ class SettingsStore {
if (!browser) return;
try {
const storedConfigRaw = localStorage.getItem('config');
const storedConfigRaw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
const savedVal = JSON.parse(storedConfigRaw || '{}');
// Merge with defaults to prevent breaking changes
@@ -90,7 +110,9 @@ class SettingsStore {
};
// Load user overrides
const savedOverrides = JSON.parse(localStorage.getItem('userOverrides') || '[]');
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);
@@ -107,6 +129,10 @@ class SettingsStore {
this.theme = localStorage.getItem('theme') || 'auto';
}
// ─────────────────────────────────────────────────────────────────────────────
// Config Updates
// ─────────────────────────────────────────────────────────────────────────────
/**
* Update a specific configuration setting
* @param key - The configuration key to update
@@ -170,9 +196,12 @@ class SettingsStore {
if (!browser) return;
try {
localStorage.setItem('config', JSON.stringify(this.config));
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(this.config));
localStorage.setItem('userOverrides', JSON.stringify(Array.from(this.userOverrides)));
localStorage.setItem(
USER_OVERRIDES_LOCALSTORAGE_KEY,
JSON.stringify(Array.from(this.userOverrides))
);
} catch (error) {
console.error('Failed to save config to localStorage:', error);
}
@@ -204,6 +233,10 @@ class SettingsStore {
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Reset
// ─────────────────────────────────────────────────────────────────────────────
/**
* Reset configuration to defaults
*/
@@ -229,28 +262,38 @@ class SettingsStore {
}
/**
* Get a specific configuration value
* @param key - The configuration key to get
* @returns The configuration value
* Reset a parameter to server default (or webui default if no server default)
*/
getConfig<K extends keyof SettingsConfigType>(key: K): SettingsConfigType[K] {
return this.config[key];
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();
}
/**
* Get the entire configuration object
* @returns The complete configuration object
*/
getAllConfig(): SettingsConfigType {
return { ...this.config };
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 serverParams = serverStore.serverDefaultParams;
const serverParams = serverStore.defaultParams;
if (!serverParams) {
console.warn('No server parameters available for initialization');
@@ -278,15 +321,6 @@ class SettingsStore {
console.log('Current user overrides after sync:', Array.from(this.userOverrides));
}
/**
* Clear all user overrides (for debugging)
*/
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
/**
* Reset all parameters to their default values (from props)
* This is used by the "Reset to Default" functionality
@@ -315,6 +349,27 @@ class SettingsStore {
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 };
}
/**
* Get parameter information including source for a specific parameter
*/
@@ -330,29 +385,6 @@ class SettingsStore {
);
}
/**
* 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();
}
/**
* Get diff between current settings and server defaults
*/
@@ -367,30 +399,19 @@ class SettingsStore {
return ParameterSyncService.createParameterDiff(configAsRecord, serverDefaults);
}
/**
* Clear all user overrides (for debugging)
*/
clearAllUserOverrides(): void {
this.userOverrides.clear();
this.saveConfig();
console.log('Cleared all user overrides');
}
}
// Create and export the settings store instance
export const settingsStore = new SettingsStore();
// Export reactive getters for easy access in components
export const config = () => settingsStore.config;
export const theme = () => settingsStore.theme;
export const isInitialized = () => settingsStore.isInitialized;
// Export bound methods for easy access
export const updateConfig = settingsStore.updateConfig.bind(settingsStore);
export const updateMultipleConfig = settingsStore.updateMultipleConfig.bind(settingsStore);
export const updateTheme = settingsStore.updateTheme.bind(settingsStore);
export const resetConfig = settingsStore.resetConfig.bind(settingsStore);
export const resetTheme = settingsStore.resetTheme.bind(settingsStore);
export const resetAll = settingsStore.resetAll.bind(settingsStore);
export const getConfig = settingsStore.getConfig.bind(settingsStore);
export const getAllConfig = settingsStore.getAllConfig.bind(settingsStore);
export const syncWithServerDefaults = settingsStore.syncWithServerDefaults.bind(settingsStore);
export const forceSyncWithServerDefaults =
settingsStore.forceSyncWithServerDefaults.bind(settingsStore);
export const getParameterInfo = settingsStore.getParameterInfo.bind(settingsStore);
export const resetParameterToServerDefault =
settingsStore.resetParameterToServerDefault.bind(settingsStore);
export const getParameterDiff = settingsStore.getParameterDiff.bind(settingsStore);
export const clearAllUserOverrides = settingsStore.clearAllUserOverrides.bind(settingsStore);