ui: Refactor models store, MCP service, and gate logs behind VITE_DEBUG (#23236)
* refactor: Scope console logs to `DEV` + `VITE_DEBUG` env vars * refactor: skip MCP proxy probe when no server requires it * refactor: suppress expected disconnect errors during MCP client shutdown * refactor: Deduplicate requests * refactor: deduplicate model fetching across ROUTER and MODEL modes * refactor: Clean up models logic * chore: Add `.env.example` file * refactor: replace client-side CORS proxy probe with server status flag * refactor: Post-review fixes * test: add vitest client setup with API fetch mocks
This commit is contained in:
@@ -20,11 +20,11 @@
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { base } from '$app/paths';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { mcpResourceStore } from '$lib/stores/mcp-resources.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { mode } from 'mode-watcher';
|
||||
import {
|
||||
parseMcpServerSettings,
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
ToolCallType
|
||||
} from '$lib/enums';
|
||||
import {
|
||||
CORS_PROXY_ENDPOINT,
|
||||
DEFAULT_CACHE_TTL_MS,
|
||||
DEFAULT_MCP_CONFIG,
|
||||
EXPECTED_THEMED_ICON_PAIR_COUNT,
|
||||
@@ -86,7 +85,6 @@ class MCPStore {
|
||||
private _toolCount = $state(0);
|
||||
private _connectedServers = $state<string[]>([]);
|
||||
private _healthChecks = $state<Record<string, HealthCheckState>>({});
|
||||
private _proxyAvailable = $state(false);
|
||||
|
||||
private connections = new Map<string, MCPConnection>();
|
||||
private toolsIndex = new Map<string, string>();
|
||||
@@ -96,27 +94,8 @@ class MCPStore {
|
||||
private initPromise: Promise<boolean> | null = null;
|
||||
private activeFlowCount = 0;
|
||||
|
||||
constructor() {
|
||||
if (browser) {
|
||||
this.probeProxy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes the CORS proxy endpoint to determine availability.
|
||||
* The endpoint is only registered when llama-server runs with --ui-mcp-proxy.
|
||||
*/
|
||||
async probeProxy(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${base}${CORS_PROXY_ENDPOINT}`, { method: 'HEAD' });
|
||||
this._proxyAvailable = response.status !== 404;
|
||||
} catch {
|
||||
this._proxyAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
get isProxyAvailable(): boolean {
|
||||
return this._proxyAvailable;
|
||||
return serverStore.props?.cors_proxy_enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@ import { toast } from 'svelte-sonner';
|
||||
import { ServerModelStatus, ModelModality } from '$lib/enums';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { PropsService } from '$lib/services/props.service';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { serverStore, isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { TTLCache } from '$lib/utils';
|
||||
import {
|
||||
MODEL_PROPS_CACHE_TTL_MS,
|
||||
@@ -14,14 +14,7 @@ import {
|
||||
import { conversationsStore } from '$lib/stores/conversations.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
|
||||
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes.
|
||||
*
|
||||
* **Architecture & Relationships:**
|
||||
* - **ModelsService**: Stateless service for model API communication
|
||||
@@ -31,14 +24,8 @@ import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
*
|
||||
* **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.
|
||||
* 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 {
|
||||
/**
|
||||
@@ -57,8 +44,8 @@ class ModelsStore {
|
||||
selectedModelId = $state<string | null>(null);
|
||||
selectedModelName = $state<string | null>(null);
|
||||
|
||||
// dedup concurrent fetch() callers, all awaiters share the same inflight promise
|
||||
// without this, ?model=<name> URL handler raced an in-progress fetch and saw an empty list
|
||||
// Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
|
||||
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
|
||||
private inflightFetch: Promise<void> | null = null;
|
||||
|
||||
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
|
||||
@@ -67,9 +54,9 @@ class ModelsStore {
|
||||
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
|
||||
|
||||
/**
|
||||
* Model-specific props cache with TTL
|
||||
* Key: modelId, Value: props data including modalities
|
||||
* TTL: 10 minutes - props don't change frequently
|
||||
* Model-specific props cache with TTL.
|
||||
* Key: modelId, Value: props data including modalities.
|
||||
* TTL: 10 minutes — props don't change frequently.
|
||||
*/
|
||||
private modelPropsCache = new TTLCache<string, ApiLlamaCppServerProps>({
|
||||
ttlMs: MODEL_PROPS_CACHE_TTL_MS,
|
||||
@@ -78,7 +65,7 @@ class ModelsStore {
|
||||
private modelPropsFetching = $state<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Version counter for props cache - used to trigger reactivity when props are updated
|
||||
* Version counter for props cache — used to trigger reactivity when props are updated.
|
||||
*/
|
||||
propsCacheVersion = $state(0);
|
||||
|
||||
@@ -92,7 +79,7 @@ class ModelsStore {
|
||||
|
||||
get selectedModel(): ModelOption | null {
|
||||
if (!this.selectedModelId) return null;
|
||||
return this.models.find((model) => model.id === this.selectedModelId) ?? null;
|
||||
return this.models.find((m) => m.id === this.selectedModelId) ?? null;
|
||||
}
|
||||
|
||||
get loadedModelIds(): string[] {
|
||||
@@ -117,7 +104,7 @@ class ModelsStore {
|
||||
* In ROUTER mode, returns null (model is per-conversation).
|
||||
*/
|
||||
get singleModelName(): string | null {
|
||||
if (serverStore.isRouterMode) return null;
|
||||
if (isRouterMode()) return null;
|
||||
|
||||
const props = serverStore.props;
|
||||
if (props?.model_alias) return props.model_alias;
|
||||
@@ -126,6 +113,11 @@ class ModelsStore {
|
||||
return props.model_path.split(/(\\|\/)/).pop() || null;
|
||||
}
|
||||
|
||||
get selectedModelContextSize(): number | null {
|
||||
if (!this.selectedModelName) return null;
|
||||
return this.getModelContextSize(this.selectedModelName);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
@@ -134,10 +126,6 @@ class ModelsStore {
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get modalities for a specific model
|
||||
* Returns cached modalities from model props
|
||||
*/
|
||||
getModelModalities(modelId: string): ModelModalities | null {
|
||||
const model = this.models.find((m) => m.model === modelId || m.id === modelId);
|
||||
if (model?.modalities) {
|
||||
@@ -146,46 +134,29 @@ class ModelsStore {
|
||||
|
||||
const props = this.modelPropsCache.get(modelId);
|
||||
if (props?.modalities) {
|
||||
return {
|
||||
vision: props.modalities.vision ?? false,
|
||||
audio: props.modalities.audio ?? false,
|
||||
video: props.modalities.video ?? false
|
||||
};
|
||||
return this.buildModalities(props.modalities);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports video modality
|
||||
*/
|
||||
modelSupportsVideo(modelId: string): boolean {
|
||||
return this.getModelModalities(modelId)?.video ?? 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);
|
||||
if (modalities.video) result.push(ModelModality.VIDEO);
|
||||
@@ -193,16 +164,10 @@ class ModelsStore {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get props for a specific model (from cache)
|
||||
*/
|
||||
getModelProps(modelId: string): ApiLlamaCppServerProps | null {
|
||||
return this.modelPropsCache.get(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context size (n_ctx) for a specific model from cached props
|
||||
*/
|
||||
getModelContextSize(modelId: string): number | null {
|
||||
const props = this.getModelProps(modelId);
|
||||
const nCtx = props?.default_generation_settings?.n_ctx;
|
||||
@@ -210,17 +175,6 @@ class ModelsStore {
|
||||
return typeof nCtx === 'number' ? nCtx : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context size for the currently selected model or null if no model is selected
|
||||
*/
|
||||
get selectedModelContextSize(): number | null {
|
||||
if (!this.selectedModelName) return null;
|
||||
return this.getModelContextSize(this.selectedModelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if props are being fetched for a model
|
||||
*/
|
||||
isModelPropsFetching(modelId: string): boolean {
|
||||
return this.modelPropsFetching.has(modelId);
|
||||
}
|
||||
@@ -235,10 +189,10 @@ class ModelsStore {
|
||||
|
||||
isModelLoaded(modelId: string): boolean {
|
||||
const model = this.routerModels.find((m) => m.id === modelId);
|
||||
|
||||
return (
|
||||
model?.status.value === ServerModelStatus.LOADED ||
|
||||
model?.status.value === ServerModelStatus.SLEEPING ||
|
||||
false
|
||||
model?.status.value === ServerModelStatus.SLEEPING
|
||||
);
|
||||
}
|
||||
|
||||
@@ -248,6 +202,7 @@ class ModelsStore {
|
||||
|
||||
getModelStatus(modelId: string): ServerModelStatus | null {
|
||||
const model = this.routerModels.find((m) => m.id === modelId);
|
||||
|
||||
return model?.status.value ?? null;
|
||||
}
|
||||
|
||||
@@ -257,6 +212,7 @@ class ModelsStore {
|
||||
|
||||
isModelInUse(modelId: string): boolean {
|
||||
const usage = this.modelUsage.get(modelId);
|
||||
|
||||
return usage !== undefined && usage.size > 0;
|
||||
}
|
||||
|
||||
@@ -269,8 +225,8 @@ class ModelsStore {
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch list of models from server and detect server role
|
||||
* Also fetches modalities for MODEL mode (single model)
|
||||
* 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.inflightFetch) return this.inflightFetch;
|
||||
@@ -293,69 +249,87 @@ class ModelsStore {
|
||||
await serverStore.fetch();
|
||||
}
|
||||
|
||||
const response = await ModelsService.list();
|
||||
const router = isRouterMode();
|
||||
|
||||
const models: ModelOption[] = response.data.map((item: ApiModelDataEntry, index: number) => {
|
||||
const details = response.models?.[index];
|
||||
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
|
||||
const displayNameSource =
|
||||
details?.name && details.name.trim().length > 0 ? details.name : item.id;
|
||||
const displayName = this.toDisplayName(displayNameSource);
|
||||
const modelId = details?.model || item.id;
|
||||
if (router) {
|
||||
const response = await ModelsService.listRouter();
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: displayName,
|
||||
model: modelId,
|
||||
description: details?.description,
|
||||
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
|
||||
details: details?.details,
|
||||
meta: item.meta ?? null,
|
||||
parsedId: ModelsService.parseModelId(modelId),
|
||||
aliases: item.aliases ?? [],
|
||||
tags: item.tags ?? []
|
||||
} satisfies ModelOption;
|
||||
});
|
||||
this.routerModels = response.data;
|
||||
this.models = this.buildModelOptions(response);
|
||||
|
||||
this.models = models;
|
||||
await this.fetchModalitiesForLoadedModels();
|
||||
|
||||
// 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,
|
||||
video: serverProps.modalities.video ?? false
|
||||
};
|
||||
this.modelPropsCache.set(this.models[0].model, serverProps);
|
||||
this.models = this.models.map((model, index) =>
|
||||
index === 0 ? { ...model, modalities } : model
|
||||
);
|
||||
const visible = this.getVisibleModels();
|
||||
|
||||
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
|
||||
this.selectModelById(visible[0].id);
|
||||
}
|
||||
} else {
|
||||
this.models = await this.fetchModelModeInternal();
|
||||
}
|
||||
} catch (error) {
|
||||
this.models = [];
|
||||
this.error = error instanceof Error ? error.message : 'Failed to load models';
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
|
||||
private async fetchModelModeInternal(): Promise<ModelOption[]> {
|
||||
const response = await ModelsService.list();
|
||||
|
||||
return this.buildModelOptions(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch router models with full metadata (ROUTER mode only)
|
||||
* This fetches the /models endpoint which returns status info for each model
|
||||
* Build ModelOption[] from an API response.
|
||||
* Both MODEL and ROUTER modes share the same mapping logic;
|
||||
* they differ only in which endpoint is called.
|
||||
*/
|
||||
private buildModelOptions(
|
||||
response: ApiModelListResponse | ApiRouterModelsListResponse
|
||||
): ModelOption[] {
|
||||
return response.data.map((item: ApiModelDataEntry, index: number) => {
|
||||
const details = response.models?.[index];
|
||||
const rawCapabilities = Array.isArray(details?.capabilities) ? details?.capabilities : [];
|
||||
const displayNameSource =
|
||||
details?.name && details.name.trim().length > 0 ? details.name : item.id;
|
||||
const modelId = details?.model || item.id;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: this.toDisplayName(displayNameSource),
|
||||
model: modelId,
|
||||
description: details?.description,
|
||||
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
|
||||
details: details?.details,
|
||||
meta: item.meta ?? null,
|
||||
parsedId: ModelsService.parseModelId(modelId),
|
||||
aliases: item.aliases ?? [],
|
||||
tags: item.tags ?? []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch router models with full metadata (ROUTER mode only).
|
||||
* No-op in router mode — fetch() already calls listRouter() internally.
|
||||
* Kept for API compatibility (e.g. handleOpenChange dropdown open handler).
|
||||
*/
|
||||
async fetchRouterModels(): Promise<void> {
|
||||
if (!isRouterMode()) return;
|
||||
|
||||
try {
|
||||
const response = await ModelsService.listRouter();
|
||||
this.routerModels = response.data;
|
||||
await this.fetchModalitiesForLoadedModels();
|
||||
|
||||
const o = this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
|
||||
|
||||
if (o.length === 1 && this.isModelLoaded(o[0].model)) {
|
||||
this.selectModelById(o[0].id);
|
||||
const visible = this.getVisibleModels();
|
||||
if (visible.length === 1 && this.isModelLoaded(visible[0].model)) {
|
||||
this.selectModelById(visible[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch router models:', error);
|
||||
@@ -364,10 +338,10 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch props for a specific model from /props endpoint
|
||||
* Uses caching to avoid redundant requests
|
||||
* Fetch props for a specific model from /props endpoint.
|
||||
* Uses caching to avoid redundant requests.
|
||||
*
|
||||
* In ROUTER mode, this will only fetch props if the model is loaded,
|
||||
* In ROUTER mode, this only fetches props if the model is loaded,
|
||||
* since unloaded models return 400 from /props endpoint.
|
||||
*
|
||||
* @param modelId - Model identifier to fetch props for
|
||||
@@ -397,10 +371,7 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch modalities for all loaded models from /props endpoint
|
||||
* This updates the modalities field in models array
|
||||
*/
|
||||
/** Fetch modalities for all loaded models from /props endpoint. */
|
||||
async fetchModalitiesForLoadedModels(): Promise<void> {
|
||||
const loadedModelIds = this.loadedModelIds;
|
||||
if (loadedModelIds.length === 0) return;
|
||||
@@ -410,7 +381,6 @@ class ModelsStore {
|
||||
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;
|
||||
@@ -418,13 +388,7 @@ class ModelsStore {
|
||||
const props = results[modelIndex];
|
||||
if (!props?.modalities) return model;
|
||||
|
||||
const modalities: ModelModalities = {
|
||||
vision: props.modalities.vision ?? false,
|
||||
audio: props.modalities.audio ?? false,
|
||||
video: props.modalities.video ?? false
|
||||
};
|
||||
|
||||
return { ...model, modalities };
|
||||
return { ...model, modalities: this.buildModalities(props.modalities) };
|
||||
});
|
||||
|
||||
this.propsCacheVersion++;
|
||||
@@ -433,17 +397,38 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const props = await this.fetchModelProps(modelId);
|
||||
if (!props?.modalities) return;
|
||||
|
||||
this.models = this.models.map((model) =>
|
||||
model.model === modelId
|
||||
? { ...model, modalities: this.buildModalities(props.modalities!) }
|
||||
: model
|
||||
);
|
||||
|
||||
this.propsCacheVersion++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to models visible in the UI (ui !== false).
|
||||
*/
|
||||
private getVisibleModels(): ModelOption[] {
|
||||
return this.models.filter((option) => this.getModelProps(option.model)?.ui !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the model name from the last assistant message in the active conversation.
|
||||
* Iterates backward through messages to find the most recent message with a model.
|
||||
* Used by both the chat page and settings page to maintain model consistency.
|
||||
* @returns The model name or null if not found
|
||||
*/
|
||||
getModelFromLastAssistantResponse(): string | null {
|
||||
const messages = conversationsStore.activeMessages;
|
||||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
// Iterate backward to find the last message with a model
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].model) {
|
||||
return messages[i].model;
|
||||
@@ -456,22 +441,13 @@ class ModelsStore {
|
||||
/**
|
||||
* Auto-selects the model from the last assistant response if available and loaded.
|
||||
* Returns true if a model was selected, false otherwise.
|
||||
* This is used by the chat page to maintain model consistency across page navigation.
|
||||
*/
|
||||
async selectModelFromLastAssistantResponse(): Promise<boolean> {
|
||||
const lastModel = this.getModelFromLastAssistantResponse();
|
||||
if (!lastModel) return false;
|
||||
|
||||
// Skip if already selected
|
||||
if (this.selectedModelName === lastModel) return false;
|
||||
if (!lastModel || this.selectedModelName === lastModel) return false;
|
||||
|
||||
const matchingModel = this.models.find((option) => option.model === lastModel);
|
||||
if (!matchingModel) return false;
|
||||
|
||||
if (!this.isModelLoaded(lastModel)) {
|
||||
console.log('[modelsStore] last assistant model not loaded:', lastModel);
|
||||
return false;
|
||||
}
|
||||
if (!matchingModel || !this.isModelLoaded(lastModel)) return false;
|
||||
|
||||
try {
|
||||
await this.selectModelById(matchingModel.id);
|
||||
@@ -484,22 +460,17 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-selects the first available model if none is selected, and fetches its props.
|
||||
* Auto-selects the first available model if none is selected.
|
||||
* Prioritizes:
|
||||
* 1. Model from active conversation's last assistant response (if loaded)
|
||||
* 2. Model from active conversation's last assistant response (if not loaded)
|
||||
* 3. First loaded model (not from active conversation)
|
||||
* 4. First available model
|
||||
* This is used to ensure default values are populated in settings pages.
|
||||
*/
|
||||
async ensureFirstModelSelected(): Promise<void> {
|
||||
if (this.selectedModelName) return;
|
||||
|
||||
// Filter models that are visible in the UI
|
||||
const availableModels = this.models.filter(
|
||||
(option) => this.getModelProps(option.model)?.ui !== false
|
||||
);
|
||||
|
||||
const availableModels = this.getVisibleModels();
|
||||
if (availableModels.length === 0) return;
|
||||
|
||||
// Try to select model from last assistant response first
|
||||
@@ -515,7 +486,7 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a loaded model first
|
||||
// Try a loaded model first
|
||||
const loadedModel = availableModels.find((m) => this.isModelLoaded(m.model));
|
||||
if (loadedModel) {
|
||||
await this.selectModelById(loadedModel.id);
|
||||
@@ -524,34 +495,7 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
// Fall back to the first available model
|
||||
const firstModel = availableModels[0];
|
||||
await this.selectModelById(firstModel.id);
|
||||
// Don't fetch props for unloaded models (will fail in ROUTER mode)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
video: props.modalities.video ?? false
|
||||
};
|
||||
|
||||
this.models = this.models.map((model) =>
|
||||
model.model === modelId ? { ...model, modalities } : model
|
||||
);
|
||||
|
||||
this.propsCacheVersion++;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to update modalities for model ${modelId}:`, error);
|
||||
}
|
||||
await this.selectModelById(availableModels[0].id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -562,9 +506,6 @@ class ModelsStore {
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* Select a model for new conversations
|
||||
*/
|
||||
async selectModelById(modelId: string): Promise<void> {
|
||||
if (!modelId || this.updating) return;
|
||||
if (this.selectedModelId === modelId) return;
|
||||
@@ -584,8 +525,7 @@ class ModelsStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a model by its model name (used for syncing with conversation model)
|
||||
* @param modelName - Model name to select (e.g., "ggml-org/GLM-4.7-Flash-GGUF")
|
||||
* Select a model by its model name (used for syncing with conversation model).
|
||||
*/
|
||||
selectModelByName(modelName: string): void {
|
||||
const option = this.models.find((model) => model.model === modelName);
|
||||
@@ -615,7 +555,7 @@ class ModelsStore {
|
||||
/**
|
||||
*
|
||||
*
|
||||
* Loading/Unloading Models
|
||||
* Loading / Unloading Models
|
||||
*
|
||||
*
|
||||
*/
|
||||
@@ -623,27 +563,18 @@ class ModelsStore {
|
||||
/**
|
||||
* 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).
|
||||
* Currently, `/models/load` and `/models/unload` return success before
|
||||
* the operation actually completes on the server.
|
||||
*
|
||||
* 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.
|
||||
* TODO: Remove polling once llama-server properly waits for the operation
|
||||
* to complete before returning success.
|
||||
*/
|
||||
|
||||
/** Polling interval in ms for checking model status */
|
||||
private static readonly STATUS_POLL_INTERVAL = 500;
|
||||
|
||||
/**
|
||||
* Poll for expected model status after load/unload operation.
|
||||
* Keeps polling indefinitely until the model reaches the expected status or fails.
|
||||
*
|
||||
* @param modelId - Model identifier to check
|
||||
* @param expectedStatus - Expected status to wait for
|
||||
* @throws Error if model reaches FAILED status
|
||||
* Keeps polling until the model reaches the expected status or fails.
|
||||
*/
|
||||
private async pollForModelStatus(
|
||||
modelId: string,
|
||||
@@ -654,9 +585,7 @@ class ModelsStore {
|
||||
await this.fetchRouterModels();
|
||||
|
||||
const currentStatus = this.getModelStatus(modelId);
|
||||
if (currentStatus === expectedStatus) {
|
||||
return;
|
||||
}
|
||||
if (currentStatus === expectedStatus) return;
|
||||
|
||||
if (currentStatus === ServerModelStatus.FAILED) {
|
||||
throw new Error(
|
||||
@@ -677,15 +606,8 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a model (ROUTER mode)
|
||||
* @param modelId - Model identifier to load
|
||||
*/
|
||||
async loadModel(modelId: string): Promise<void> {
|
||||
if (this.isModelLoaded(modelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isModelLoaded(modelId)) return;
|
||||
if (this.modelLoadingStates.get(modelId)) return;
|
||||
|
||||
this.modelLoadingStates.set(modelId, true);
|
||||
@@ -694,7 +616,6 @@ class ModelsStore {
|
||||
try {
|
||||
await ModelsService.load(modelId);
|
||||
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
|
||||
|
||||
await this.updateModelModalities(modelId);
|
||||
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
|
||||
} catch (error) {
|
||||
@@ -706,15 +627,8 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a model (ROUTER mode)
|
||||
* @param modelId - Model identifier to unload
|
||||
*/
|
||||
async unloadModel(modelId: string): Promise<void> {
|
||||
if (!this.isModelLoaded(modelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isModelLoaded(modelId)) return;
|
||||
if (this.modelLoadingStates.get(modelId)) return;
|
||||
|
||||
this.modelLoadingStates.set(modelId, true);
|
||||
@@ -722,7 +636,6 @@ class ModelsStore {
|
||||
|
||||
try {
|
||||
await ModelsService.unload(modelId);
|
||||
|
||||
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
|
||||
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
|
||||
} catch (error) {
|
||||
@@ -734,15 +647,8 @@ class ModelsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
if (this.isModelLoaded(modelId)) return;
|
||||
await this.loadModel(modelId);
|
||||
}
|
||||
|
||||
@@ -779,11 +685,9 @@ class ModelsStore {
|
||||
private loadFavoritesFromStorage(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
|
||||
|
||||
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
|
||||
} catch {
|
||||
toast.error('Failed to load favorite models from local storage');
|
||||
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
@@ -799,10 +703,19 @@ class ModelsStore {
|
||||
private toDisplayName(id: string): string {
|
||||
const segments = id.split(/\\|\//);
|
||||
const candidate = segments.pop();
|
||||
|
||||
return candidate && candidate.trim().length > 0 ? candidate : id;
|
||||
}
|
||||
|
||||
private buildModalities(
|
||||
modalities: NonNullable<ApiLlamaCppServerProps['modalities']>
|
||||
): ModelModalities {
|
||||
return {
|
||||
vision: modalities.vision ?? false,
|
||||
audio: modalities.audio ?? false,
|
||||
video: modalities.video ?? false
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.models = [];
|
||||
this.routerModels = [];
|
||||
|
||||
Reference in New Issue
Block a user