refactor: Server store

This commit is contained in:
Aleksander Grygier 2025-11-26 17:16:41 +01:00
parent 456828b365
commit d6ee3d133a
19 changed files with 208 additions and 385 deletions

View File

@ -16,7 +16,7 @@
supportsVision,
fetchModelProps,
getModelProps
} from '$lib/stores/props.svelte';
} from '$lib/stores/server.svelte';
import { getConversationModel } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import {

View File

@ -15,7 +15,7 @@
isRouterMode,
fetchModelProps,
getModelProps
} from '$lib/stores/props.svelte';
} from '$lib/stores/server.svelte';
import { config } from '$lib/stores/settings.svelte';
import { modelOptions, selectedModelId, selectModelByName } from '$lib/stores/models.svelte';
import { getConversationModel } from '$lib/stores/chat.svelte';

View File

@ -18,7 +18,7 @@
import { INPUT_CLASSES } from '$lib/constants/input-classes';
import Label from '$lib/components/ui/label/label.svelte';
import { config } from '$lib/stores/settings.svelte';
import { isRouterMode } from '$lib/stores/props.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { selectModel } from '$lib/stores/models.svelte';
import { copyToClipboard } from '$lib/utils/copy';
import type { ApiChatCompletionToolCall } from '$lib/types/api';

View File

@ -32,13 +32,13 @@
import {
supportsVision,
supportsAudio,
propsLoading,
propsError,
propsStore,
serverLoading,
serverError,
serverStore,
isRouterMode,
fetchModelProps,
getModelProps
} from '$lib/stores/props.svelte';
} from '$lib/stores/server.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { getConversationModel } from '$lib/stores/chat.svelte';
import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra';
@ -87,8 +87,8 @@
);
let activeErrorDialog = $derived(errorDialog());
let isServerLoading = $derived(propsLoading());
let hasPropsError = $derived(!!propsError());
let isServerLoading = $derived(serverLoading());
let hasPropsError = $derived(!!serverError());
let isCurrentConversationLoading = $derived(isLoading());
@ -407,10 +407,10 @@
<div class="flex items-center gap-2">
<AlertTriangle class="h-4 w-4 text-destructive" />
<span class="text-sm font-medium text-destructive">Server unavailable</span>
<span class="text-sm text-muted-foreground">{propsError()}</span>
<span class="text-sm text-muted-foreground">{serverError()}</span>
</div>
<button
onclick={() => propsStore.fetch()}
onclick={() => serverStore.fetch()}
disabled={isServerLoading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/30 disabled:opacity-50"
>
@ -454,7 +454,7 @@
<h1 class="mb-4 text-3xl font-semibold tracking-tight">llama.cpp</h1>
<p class="text-lg text-muted-foreground">
{propsStore.serverProps?.modalities?.audio
{serverStore.props?.modalities?.audio
? 'Record audio, type a message '
: 'Type a message'} or upload files to get started
</p>
@ -467,10 +467,10 @@
<div class="flex items-center gap-2">
<AlertTriangle class="h-4 w-4 text-destructive" />
<span class="text-sm font-medium text-destructive">Server unavailable</span>
<span class="text-sm text-muted-foreground">{propsError()}</span>
<span class="text-sm text-muted-foreground">{serverError()}</span>
</div>
<button
onclick={() => propsStore.fetch()}
onclick={() => serverStore.fetch()}
disabled={isServerLoading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-3 py-1.5 text-xs font-medium text-destructive hover:bg-destructive/30 disabled:opacity-50"
>

View File

@ -6,7 +6,7 @@
import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
import { supportsVision } from '$lib/stores/props.svelte';
import { supportsVision } from '$lib/stores/server.svelte';
import { getParameterInfo, resetParameterToServerDefault } from '$lib/stores/settings.svelte';
import { ParameterSyncService } from '$lib/services/parameter-sync';
import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';

View File

@ -2,7 +2,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { BadgeModality, CopyToClipboardIcon } from '$lib/components/app';
import { propsStore } from '$lib/stores/props.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { ChatService } from '$lib/services/chat';
import type { ApiModelListResponse } from '$lib/types/api';
import { formatFileSize, formatParameters, formatNumber } from '$lib/utils/formatters';
@ -14,8 +14,8 @@
let { open = $bindable(), onOpenChange }: Props = $props();
let serverProps = $derived(propsStore.serverProps);
let modalities = $derived(propsStore.supportedModalities);
let serverProps = $derived(serverStore.props);
let modalities = $derived(serverStore.supportedModalities);
let modelsData = $state<ApiModelListResponse | null>(null);
let isLoadingModels = $state(false);
@ -77,12 +77,12 @@
class="resizable-text-container min-w-0 flex-1 truncate"
style:--threshold="12rem"
>
{propsStore.modelName}
{serverStore.modelName}
</span>
<CopyToClipboardIcon
text={propsStore.modelName || ''}
canCopy={!!propsStore.modelName}
text={serverStore.modelName || ''}
canCopy={!!serverStore.modelName}
ariaLabel="Copy model name to clipboard"
/>
</div>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { Package } from '@lucide/svelte';
import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app';
import { propsStore } from '$lib/stores/props.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config';
@ -21,8 +21,8 @@
showTooltip = false
}: Props = $props();
let model = $derived(modelProp || propsStore.modelName);
let isModelMode = $derived(propsStore.isModelMode);
let model = $derived(modelProp || serverStore.modelName);
let isModelMode = $derived(serverStore.isModelMode);
</script>
{#snippet badgeContent()}

View File

@ -15,7 +15,7 @@
loadModel
} from '$lib/stores/models.svelte';
import { ServerModelStatus } from '$lib/enums';
import { isRouterMode, propsStore } from '$lib/stores/props.svelte';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import { DialogModelInformation } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
@ -43,7 +43,7 @@
let updating = $derived(modelsUpdating());
let activeId = $derived(selectedModelId());
let isRouter = $derived(isRouterMode());
let serverModel = $derived(propsStore.modelName);
let serverModel = $derived(serverStore.modelName);
// Reactive router models state - needed for proper reactivity of status checks
let currentRouterModels = $derived(routerModels());

View File

@ -4,7 +4,7 @@
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
import { propsStore, propsLoading } from '$lib/stores/props.svelte';
import { serverStore, serverLoading } from '$lib/stores/server.svelte';
import { config, updateConfig } from '$lib/stores/settings.svelte';
import { fade, fly, scale } from 'svelte/transition';
@ -24,7 +24,7 @@
showTroubleshooting = false
}: Props = $props();
let isServerLoading = $derived(propsLoading());
let isServerLoading = $derived(serverLoading());
let isAccessDeniedError = $derived(
error.toLowerCase().includes('access denied') ||
error.toLowerCase().includes('invalid api key') ||
@ -42,7 +42,7 @@
if (onRetry) {
onRetry();
} else {
propsStore.fetch();
serverStore.fetch();
}
}

View File

@ -2,7 +2,7 @@
import { AlertTriangle, Server } from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { serverProps, propsLoading, propsError, modelName } from '$lib/stores/props.svelte';
import { serverProps, serverLoading, serverError, modelName } from '$lib/stores/server.svelte';
interface Props {
class?: string;
@ -11,8 +11,8 @@
let { class: className = '', showActions = false }: Props = $props();
let error = $derived(propsError());
let loading = $derived(propsLoading());
let error = $derived(serverError());
let loading = $derived(serverLoading());
let model = $derived(modelName());
let serverData = $derived(serverProps());

View File

@ -1,7 +1,7 @@
import { config } from '$lib/stores/settings.svelte';
import { getJsonHeaders } from '$lib/utils/api-headers';
import { selectedModelName } from '$lib/stores/models.svelte';
import { isRouterMode, propsStore } from '$lib/stores/props.svelte';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import type {
ApiChatCompletionRequest,
ApiChatCompletionResponse,
@ -792,7 +792,7 @@ export class ChatService {
* Handles various response formats including streaming chunks and final responses.
*
* WORKAROUND: In single model mode, llama-server returns a default/incorrect model name
* in the response. We override it with the actual model name from propsStore.
* in the response. We override it with the actual model name from serverStore.
*
* @param data - Raw response data from the Chat Completions API
* @returns Model name string if found, undefined otherwise
@ -803,7 +803,7 @@ export class ChatService {
// because llama-server returns `gpt-3.5-turbo` value in the `model` field
const isRouter = isRouterMode();
if (!isRouter) {
const propsModelName = propsStore.modelName;
const propsModelName = serverStore.modelName;
if (propsModelName) {
return propsModelName;
}

View File

@ -2,7 +2,7 @@ import { DatabaseService } from '$lib/services/database';
import { chatService } from '$lib/services';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte';
import { contextSize } from '$lib/stores/props.svelte';
import { contextSize } from '$lib/stores/server.svelte';
import { normalizeModelName } from '$lib/utils/model-names';
import { filterByLeafNodeId, findDescendantMessages, findLeafNode } from '$lib/utils/branching';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';

View File

@ -1,7 +1,7 @@
import { SvelteSet } from 'svelte/reactivity';
import { ModelsService } from '$lib/services/models';
import { ServerModelStatus } from '$lib/enums';
import { propsStore } from '$lib/stores/props.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import type { ModelOption, ModelModalities } from '$lib/types/models';
import type { ApiModelDataEntry } from '$lib/types/api';
@ -18,7 +18,7 @@ import type { ApiModelDataEntry } from '$lib/types/api';
* **Architecture & Relationships:**
* - **ModelsService**: Stateless service for API communication
* - **ModelsStore** (this class): Reactive store for model state
* - **PropsStore**: Provides server mode detection
* - **ServerStore**: Provides server mode detection
* - **ConversationsStore**: Tracks which conversations use which models
*
* **Key Features:**
@ -32,121 +32,62 @@ class ModelsStore {
// State
// ─────────────────────────────────────────────────────────────────────────────
private _models = $state<ModelOption[]>([]);
private _routerModels = $state<ApiModelDataEntry[]>([]);
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);
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);
/** Maps modelId -> Set of conversationIds that use this model */
private _modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
/** Maps modelId -> loading state for load/unload operations */
private _modelLoadingStates = $state<Map<string, boolean>>(new Map());
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
private modelLoadingStates = $state<Map<string, boolean>>(new Map());
// ─────────────────────────────────────────────────────────────────────────────
// Getters - Basic
// Computed Getters
// ─────────────────────────────────────────────────────────────────────────────
get models(): ModelOption[] {
return this._models;
}
get routerModels(): ApiModelDataEntry[] {
return this._routerModels;
}
get loading(): boolean {
return this._loading;
}
get updating(): boolean {
return this._updating;
}
get error(): string | null {
return this._error;
}
get selectedModelId(): string | null {
return this._selectedModelId;
}
get selectedModelName(): string | null {
return this._selectedModelName;
}
get selectedModel(): ModelOption | null {
if (!this._selectedModelId) {
return null;
if (!this.selectedModelId) return null;
return this.models.find((model) => model.id === this.selectedModelId) ?? null;
}
return this._models.find((model) => model.id === this._selectedModelId) ?? null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Getters - Loaded Models (ROUTER mode)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get list of currently loaded model IDs
*/
get loadedModelIds(): string[] {
return this._routerModels
return this.routerModels
.filter((m) => m.status.value === ServerModelStatus.LOADED)
.map((m) => m.name);
}
/**
* Get list of models currently being loaded/unloaded
*/
get loadingModelIds(): string[] {
return Array.from(this._modelLoadingStates.entries())
return Array.from(this.modelLoadingStates.entries())
.filter(([, loading]) => loading)
.map(([id]) => id);
}
/**
* Check if a specific model is loaded
*/
// ─────────────────────────────────────────────────────────────────────────────
// Methods - Model Status
// ─────────────────────────────────────────────────────────────────────────────
isModelLoaded(modelId: string): boolean {
const model = this._routerModels.find((m) => m.name === modelId);
const model = this.routerModels.find((m) => m.name === modelId);
return model?.status.value === ServerModelStatus.LOADED || false;
}
/**
* Check if a specific model is currently loading/unloading
*/
isModelOperationInProgress(modelId: string): boolean {
return this._modelLoadingStates.get(modelId) ?? false;
return this.modelLoadingStates.get(modelId) ?? false;
}
/**
* Get the status of a specific model
*/
getModelStatus(modelId: string): ServerModelStatus | null {
const model = this._routerModels.find((m) => m.name === modelId);
const model = this.routerModels.find((m) => m.name === modelId);
return model?.status.value ?? null;
}
// ─────────────────────────────────────────────────────────────────────────────
// Getters - Model Usage
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get set of conversation IDs using a specific model
*/
getModelUsage(modelId: string): SvelteSet<string> {
return this._modelUsage.get(modelId) ?? new SvelteSet<string>();
return this.modelUsage.get(modelId) ?? new SvelteSet<string>();
}
/**
* Check if a model is used by any conversation
*/
isModelInUse(modelId: string): boolean {
const usage = this._modelUsage.get(modelId);
const usage = this.modelUsage.get(modelId);
return usage !== undefined && usage.size > 0;
}
@ -158,11 +99,11 @@ class ModelsStore {
* Fetch list of models from server
*/
async fetch(force = false): Promise<void> {
if (this._loading) return;
if (this._models.length > 0 && !force) return;
if (this.loading) return;
if (this.models.length > 0 && !force) return;
this._loading = true;
this._error = null;
this.loading = true;
this.error = null;
try {
const response = await ModelsService.list();
@ -185,18 +126,13 @@ class ModelsStore {
} satisfies ModelOption;
});
this._models = models;
// Don't auto-select any model - selection should come from:
// 1. User explicitly selecting a model in the UI
// 2. Conversation model (synced via ChatFormActions effect)
this.models = models;
} 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;
}
}
@ -207,13 +143,11 @@ class ModelsStore {
async fetchRouterModels(): Promise<void> {
try {
const response = await ModelsService.listRouter();
this._routerModels = response.data;
// Fetch modalities for loaded models
this.routerModels = response.data;
await this.fetchModalitiesForLoadedModels();
} catch (error) {
console.warn('Failed to fetch router models:', error);
this._routerModels = [];
this.routerModels = [];
}
}
@ -226,13 +160,13 @@ class ModelsStore {
if (loadedModelIds.length === 0) return;
// Fetch props for each loaded model in parallel
const propsPromises = loadedModelIds.map((modelId) => propsStore.fetchModelProps(modelId));
const propsPromises = loadedModelIds.map((modelId) => serverStore.fetchModelProps(modelId));
try {
const results = await Promise.all(propsPromises);
// Update models with modalities
this._models = this._models.map((model) => {
this.models = this.models.map((model) => {
const modelIndex = loadedModelIds.indexOf(model.model);
if (modelIndex === -1) return model;
@ -257,7 +191,7 @@ class ModelsStore {
*/
async updateModelModalities(modelId: string): Promise<void> {
try {
const props = await propsStore.fetchModelProps(modelId);
const props = await serverStore.fetchModelProps(modelId);
if (!props?.modalities) return;
const modalities: ModelModalities = {
@ -265,7 +199,7 @@ class ModelsStore {
audio: props.modalities.audio ?? false
};
this._models = this._models.map((model) =>
this.models = this.models.map((model) =>
model.model === modelId ? { ...model, modalities } : model
);
} catch (error) {
@ -281,27 +215,20 @@ class ModelsStore {
* Select a model for new conversations
*/
async select(modelId: string): Promise<void> {
if (!modelId || this._updating) {
return;
}
if (!modelId || this.updating) return;
if (this.selectedModelId === modelId) 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');
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;
this.updating = true;
this.error = null;
try {
this._selectedModelId = option.id;
this._selectedModelName = option.model;
this.selectedModelId = option.id;
this.selectedModelName = option.model;
} finally {
this._updating = false;
this.updating = false;
}
}
@ -310,47 +237,28 @@ class ModelsStore {
* @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);
const option = this.models.find((model) => model.model === modelName);
if (option) {
this._selectedModelId = option.id;
this._selectedModelName = option.model;
// Don't persist - this is just for syncing with conversation
this.selectedModelId = option.id;
this.selectedModelName = option.model;
}
}
/**
* Clear the current model selection
*/
clearSelection(): void {
this._selectedModelId = null;
this._selectedModelName = null;
this.selectedModelId = null;
this.selectedModelName = null;
}
/**
* Find a model by its model name
* @param modelName - Model name to search for (e.g., "unsloth/gemma-3-12b-it-GGUF:latest")
* @returns ModelOption if found, null otherwise
*/
findModelByName(modelName: string): ModelOption | null {
return this._models.find((model) => model.model === modelName) ?? null;
return this.models.find((model) => model.model === modelName) ?? null;
}
/**
* Find a model by its display ID
* @param modelId - Model ID to search for
* @returns ModelOption if found, null otherwise
*/
findModelById(modelId: string): ModelOption | null {
return this._models.find((model) => model.id === modelId) ?? null;
return this.models.find((model) => model.id === modelId) ?? null;
}
/**
* Check if a model exists by name
* @param modelName - Model name to check
* @returns true if model exists
*/
hasModel(modelName: string): boolean {
return this._models.some((model) => model.model === modelName);
return this.models.some((model) => model.model === modelName);
}
// ─────────────────────────────────────────────────────────────────────────────
@ -414,12 +322,10 @@ class ModelsStore {
return;
}
if (this._modelLoadingStates.get(modelId)) {
return; // Already loading
}
if (this.modelLoadingStates.get(modelId)) return;
this._modelLoadingStates.set(modelId, true);
this._error = null;
this.modelLoadingStates.set(modelId, true);
this.error = null;
try {
await ModelsService.load(modelId);
@ -427,13 +333,12 @@ class ModelsStore {
// Poll until model is loaded
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
// Update modalities for this specific model
await this.updateModelModalities(modelId);
} catch (error) {
this._error = error instanceof Error ? error.message : 'Failed to load model';
this.error = error instanceof Error ? error.message : 'Failed to load model';
throw error;
} finally {
this._modelLoadingStates.set(modelId, false);
this.modelLoadingStates.set(modelId, false);
}
}
@ -446,23 +351,20 @@ class ModelsStore {
return;
}
if (this._modelLoadingStates.get(modelId)) {
return; // Already unloading
}
if (this.modelLoadingStates.get(modelId)) return;
this._modelLoadingStates.set(modelId, true);
this._error = null;
this.modelLoadingStates.set(modelId, true);
this.error = null;
try {
await ModelsService.unload(modelId);
// Poll until model is unloaded
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
} catch (error) {
this._error = error instanceof Error ? error.message : 'Failed to unload model';
this.error = error instanceof Error ? error.message : 'Failed to unload model';
throw error;
} finally {
this._modelLoadingStates.set(modelId, false);
this.modelLoadingStates.set(modelId, false);
}
}
@ -486,9 +388,9 @@ class ModelsStore {
* Register that a conversation is using a model
*/
registerModelUsage(modelId: string, conversationId: string): void {
const usage = this._modelUsage.get(modelId) ?? new SvelteSet<string>();
const usage = this.modelUsage.get(modelId) ?? new SvelteSet<string>();
usage.add(conversationId);
this._modelUsage.set(modelId, usage);
this.modelUsage.set(modelId, usage);
}
/**
@ -502,14 +404,11 @@ class ModelsStore {
conversationId: string,
autoUnload = true
): Promise<void> {
const usage = this._modelUsage.get(modelId);
const usage = this.modelUsage.get(modelId);
if (usage) {
usage.delete(conversationId);
if (usage.size === 0) {
this._modelUsage.delete(modelId);
// Auto-unload if model is not used by any conversation
this.modelUsage.delete(modelId);
if (autoUnload && this.isModelLoaded(modelId)) {
await this.unloadModel(modelId);
}
@ -521,10 +420,8 @@ class ModelsStore {
* Clear all usage for a conversation (when conversation is deleted)
*/
async clearConversationUsage(conversationId: string): Promise<void> {
for (const [modelId, usage] of this._modelUsage.entries()) {
if (usage.has(conversationId)) {
await this.unregisterModelUsage(modelId, conversationId);
}
for (const [modelId, usage] of this.modelUsage.entries()) {
if (usage.has(conversationId)) await this.unregisterModelUsage(modelId, conversationId);
}
}
@ -544,15 +441,15 @@ class ModelsStore {
// ─────────────────────────────────────────────────────────────────────────────
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.models = [];
this.routerModels = [];
this.loading = false;
this.updating = false;
this.error = null;
this.selectedModelId = null;
this.selectedModelName = null;
this.modelUsage.clear();
this.modelLoadingStates.clear();
}
}

View File

@ -2,143 +2,93 @@ import { PropsService } from '$lib/services/props';
import { ServerRole, ModelModality } from '$lib/enums';
/**
* PropsStore - Server properties management and mode detection
* ServerStore - Server state, capabilities, and mode detection
*
* This store manages the server properties fetched from the `/props` endpoint.
* It provides reactive state for server configuration, capabilities, and mode detection.
* This store manages the server connection state and properties fetched from `/props`.
* It provides reactive state for server configuration, capabilities, and role detection.
*
* **Architecture & Relationships:**
* - **PropsService**: Stateless service for fetching `/props` data
* - **PropsStore** (this class): Reactive store for server properties
* - **ModelsStore**: Uses server mode for model management strategy
* - **ServerStore** (this class): Reactive store for server state
* - **ModelsStore**: Uses server role for model management strategy
*
* **Key Features:**
* - **Server Properties**: Model info, context size, build information
* - **Mode Detection**: MODEL (single model) vs ROUTER (multi-model)
* - **Server State**: Connection status, loading, error handling
* - **Role Detection**: MODEL (single model) vs ROUTER (multi-model)
* - **Capability Detection**: Vision and audio modality support
* - **Error Handling**: Clear error states when server unavailable
* - **Props Cache**: Per-model props caching for ROUTER mode
*/
class PropsStore {
private _serverProps = $state<ApiLlamaCppServerProps | null>(null);
private _loading = $state(false);
private _error = $state<string | null>(null);
private _serverRole = $state<ServerRole | null>(null);
class ServerStore {
props = $state<ApiLlamaCppServerProps | null>(null);
loading = $state(false);
error = $state<string | null>(null);
role = $state<ServerRole | null>(null);
private fetchPromise: Promise<void> | null = null;
// Model-specific props cache (ROUTER mode)
private _modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map());
private _modelPropsFetching = $state<Set<string>>(new Set());
private modelPropsCache = $state<Map<string, ApiLlamaCppServerProps>>(new Map());
private modelPropsFetching = $state<Set<string>>(new Set());
// ─────────────────────────────────────────────────────────────────────────────
// Getters - Server Properties
// Computed Getters
// ─────────────────────────────────────────────────────────────────────────────
get serverProps(): ApiLlamaCppServerProps | null {
return this._serverProps;
}
get loading(): boolean {
return this._loading;
}
get error(): string | null {
return this._error;
}
/**
* Get model name from server props.
* In MODEL mode: extracts from model_path or model_alias
* In ROUTER mode: returns null (model is per-conversation)
*/
get modelName(): string | null {
if (this._serverRole === ServerRole.ROUTER) {
return 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;
if (this.role === ServerRole.ROUTER) return null;
if (this.props?.model_alias) return this.props.model_alias;
if (!this.props?.model_path) return null;
return this.props.model_path.split(/(\\|\/)/).pop() || null;
}
get supportedModalities(): ModelModality[] {
const modalities: ModelModality[] = [];
if (this._serverProps?.modalities?.audio) {
modalities.push(ModelModality.AUDIO);
}
if (this._serverProps?.modalities?.vision) {
modalities.push(ModelModality.VISION);
}
if (this.props?.modalities?.audio) modalities.push(ModelModality.AUDIO);
if (this.props?.modalities?.vision) modalities.push(ModelModality.VISION);
return modalities;
}
get supportsVision(): boolean {
return this._serverProps?.modalities?.vision ?? false;
return this.props?.modalities?.vision ?? false;
}
get supportsAudio(): boolean {
return this._serverProps?.modalities?.audio ?? false;
return this.props?.modalities?.audio ?? false;
}
get defaultParams(): ApiLlamaCppServerProps['default_generation_settings']['params'] | null {
return this._serverProps?.default_generation_settings?.params || null;
return this.props?.default_generation_settings?.params || null;
}
/**
* Get context size (n_ctx) from server props
*/
get contextSize(): number | null {
return this._serverProps?.default_generation_settings?.n_ctx ?? null;
return this.props?.default_generation_settings?.n_ctx ?? null;
}
/**
* Check if slots endpoint is available (set by --slots flag on server)
*/
get slotsEndpointAvailable(): boolean {
return this._serverProps?.endpoint_slots ?? false;
return this.props?.endpoint_slots ?? false;
}
// ─────────────────────────────────────────────────────────────────────────────
// Getters - Server Mode
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get current server mode
*/
get serverRole(): ServerRole | null {
return this._serverRole;
}
/**
* Detect if server is running in router mode (multi-model management)
*/
get isRouterMode(): boolean {
return this._serverRole === ServerRole.ROUTER;
return this.role === ServerRole.ROUTER;
}
/**
* Detect if server is running in model mode (single model loaded)
*/
get isModelMode(): boolean {
return this._serverRole === ServerRole.MODEL;
return this.role === ServerRole.MODEL;
}
// ─────────────────────────────────────────────────────────────────────────────
// Server Mode Detection
// Server Role Detection
// ─────────────────────────────────────────────────────────────────────────────
private detectServerRole(props: ApiLlamaCppServerProps): void {
console.log('Server props role:', props?.role);
const newMode =
// todo - `role` attribute should always be available on the `/props` endpoint
props?.role === ServerRole.ROUTER ? ServerRole.ROUTER : ServerRole.MODEL;
// Only log when mode changes
if (this._serverRole !== newMode) {
this._serverRole = newMode;
console.info(`Server running in ${newMode === ServerRole.ROUTER ? 'ROUTER' : 'MODEL'} mode`);
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`);
}
}
@ -146,18 +96,13 @@ class PropsStore {
// Fetch Server Properties
// ─────────────────────────────────────────────────────────────────────────────
/**
* Fetches server properties from the server
*/
async fetch(): Promise<void> {
if (this.fetchPromise) {
return this.fetchPromise;
}
if (this.fetchPromise) return this.fetchPromise;
this._loading = true;
this._error = null;
this.loading = true;
this.error = null;
const previousBuildInfo = this._serverProps?.build_info;
const previousBuildInfo = this.props?.build_info;
const fetchPromise = (async () => {
try {
@ -165,18 +110,18 @@ class PropsStore {
// Clear model-specific props cache if server was restarted
if (previousBuildInfo && previousBuildInfo !== props.build_info) {
this._modelPropsCache.clear();
this.modelPropsCache.clear();
console.info('Cleared model props cache due to server restart');
}
this._serverProps = props;
this._error = null;
this.detectServerRole(props);
this.props = props;
this.error = null;
this.detectRole(props);
} catch (error) {
this._error = this.getErrorMessage(error);
this.error = this.getErrorMessage(error);
console.error('Error fetching server properties:', error);
} finally {
this._loading = false;
this.loading = false;
this.fetchPromise = null;
}
})();
@ -189,45 +134,31 @@ class PropsStore {
// Fetch Model-Specific Properties (ROUTER mode)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Get cached props for a specific model
*/
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
return this._modelPropsCache.get(modelId) ?? null;
return this.modelPropsCache.get(modelId) ?? null;
}
/**
* Check if model props are being fetched
*/
isModelPropsFetching(modelId: string): boolean {
return this._modelPropsFetching.has(modelId);
return this.modelPropsFetching.has(modelId);
}
/**
* Fetches properties for a specific model (ROUTER mode)
* Results are cached for subsequent calls
*/
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
// Return cached if available
const cached = this._modelPropsCache.get(modelId);
const cached = this.modelPropsCache.get(modelId);
if (cached) return cached;
// Don't fetch if already fetching
if (this._modelPropsFetching.has(modelId)) {
return null;
}
if (this.modelPropsFetching.has(modelId)) return null;
this._modelPropsFetching.add(modelId);
this.modelPropsFetching.add(modelId);
try {
const props = await PropsService.fetchForModel(modelId);
this._modelPropsCache.set(modelId, props);
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);
this.modelPropsFetching.delete(modelId);
}
}
@ -265,42 +196,37 @@ class PropsStore {
// Clear State
// ─────────────────────────────────────────────────────────────────────────────
/**
* Clears all server state
*/
clear(): void {
this._serverProps = null;
this._error = null;
this._loading = false;
this._serverRole = null;
this.props = null;
this.error = null;
this.loading = false;
this.role = null;
this.fetchPromise = null;
this._modelPropsCache.clear();
this.modelPropsCache.clear();
}
}
export const propsStore = new PropsStore();
export const serverStore = new ServerStore();
// ─────────────────────────────────────────────────────────────────────────────
// Reactive Getters (for use in components)
// ─────────────────────────────────────────────────────────────────────────────
export const serverProps = () => propsStore.serverProps;
export const propsLoading = () => propsStore.loading;
export const propsError = () => propsStore.error;
export const modelName = () => propsStore.modelName;
export const supportedModalities = () => propsStore.supportedModalities;
export const supportsVision = () => propsStore.supportsVision;
export const supportsAudio = () => propsStore.supportsAudio;
export const slotsEndpointAvailable = () => propsStore.slotsEndpointAvailable;
export const defaultParams = () => propsStore.defaultParams;
export const contextSize = () => propsStore.contextSize;
// Server mode exports
export const serverRole = () => propsStore.serverRole;
export const isRouterMode = () => propsStore.isRouterMode;
export const isModelMode = () => propsStore.isModelMode;
export const serverProps = () => serverStore.props;
export const serverLoading = () => serverStore.loading;
export const serverError = () => serverStore.error;
export const serverRole = () => serverStore.role;
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 defaultParams = () => serverStore.defaultParams;
export const contextSize = () => serverStore.contextSize;
export const isRouterMode = () => serverStore.isRouterMode;
export const isModelMode = () => serverStore.isModelMode;
// Actions
export const fetchProps = propsStore.fetch.bind(propsStore);
export const fetchModelProps = propsStore.fetchModelProps.bind(propsStore);
export const getModelProps = propsStore.getModelProps.bind(propsStore);
export const fetchServerProps = serverStore.fetch.bind(serverStore);
export const fetchModelProps = serverStore.fetchModelProps.bind(serverStore);
export const getModelProps = serverStore.getModelProps.bind(serverStore);

View File

@ -35,7 +35,7 @@ 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 { propsStore } from '$lib/stores/props.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { setConfigValue, getConfigValue, configToParameterRecord } from '$lib/utils/config-helpers';
import {
CONFIG_LOCALSTORAGE_KEY,
@ -53,7 +53,7 @@ class SettingsStore {
* Centralizes the pattern of getting and extracting server defaults
*/
private getServerDefaults(): Record<string, string | number | boolean> {
const serverParams = propsStore.defaultParams;
const serverParams = serverStore.defaultParams;
return serverParams ? ParameterSyncService.extractServerDefaults(serverParams) : {};
}
@ -259,7 +259,7 @@ class SettingsStore {
* This sets up the default values from /props endpoint
*/
syncWithServerDefaults(): void {
const serverParams = propsStore.defaultParams;
const serverParams = serverStore.defaultParams;
if (!serverParams) {
console.warn('No server parameters available for initialization');

View File

@ -3,7 +3,7 @@ import { isSvgMimeType, svgBase64UrlToPngDataURL } from './svg-to-png';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory, AttachmentType } from '$lib/enums';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { supportsVision } from '$lib/stores/props.svelte';
import { supportsVision } from '$lib/stores/server.svelte';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { readFileAsText, isLikelyTextFile } from './text-files';
import { toast } from 'svelte-sonner';

View File

@ -3,7 +3,7 @@ import { isTextFileByName } from './text-files';
import { isWebpMimeType, webpBase64UrlToPngDataURL } from './webp-to-png';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils/file-type';
import { supportsVision } from '$lib/stores/props.svelte';
import { supportsVision } from '$lib/stores/server.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { toast } from 'svelte-sonner';

View File

@ -9,7 +9,7 @@
setTitleUpdateConfirmationCallback
} from '$lib/stores/conversations.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { isRouterMode, propsStore } from '$lib/stores/props.svelte';
import { isRouterMode, serverStore } from '$lib/stores/server.svelte';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner';
@ -95,16 +95,16 @@
// Initialize server properties on app load (run once)
$effect(() => {
// Only fetch if we don't already have props
if (!propsStore.serverProps) {
if (!serverStore.props) {
untrack(() => {
propsStore.fetch();
serverStore.fetch();
});
}
});
// Sync settings when server props are loaded
$effect(() => {
const serverProps = propsStore.serverProps;
const serverProps = serverStore.props;
if (serverProps?.default_generation_settings?.params) {
settingsStore.syncWithServerDefaults();

View File

@ -1,12 +1,12 @@
import { propsStore } from '$lib/stores/props.svelte';
import { serverStore } from '$lib/stores/server.svelte';
/**
* Mock server properties for Storybook testing
* This utility allows setting mock server configurations without polluting production code
*/
export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
// Directly set the private _serverProps for testing purposes
(propsStore as unknown as { _serverProps: ApiLlamaCppServerProps })._serverProps = {
// Directly set the props for testing purposes
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
model_path: props.model_path || 'test-model',
modalities: {
vision: props.modalities?.vision ?? false,
@ -17,18 +17,18 @@ export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
}
/**
* Reset props store to clean state for testing
* Reset server store to clean state for testing
*/
export function resetPropsStore(): void {
(propsStore as unknown as { _serverProps: ApiLlamaCppServerProps })._serverProps = {
export function resetServerStore(): void {
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
model_path: '',
modalities: {
vision: false,
audio: false
}
} as ApiLlamaCppServerProps;
(propsStore as unknown as { _error: string })._error = '';
(propsStore as unknown as { _loading: boolean })._loading = false;
(serverStore as unknown as { error: string }).error = '';
(serverStore as unknown as { loading: boolean }).loading = false;
}
/**