webui: Improvements for Models Selector UI (#20066)

This commit is contained in:
Aleksander Grygier
2026-03-05 08:52:22 +01:00
committed by GitHub
parent 92f7da00b4
commit 5e335ba113
54 changed files with 769 additions and 226 deletions
@@ -28,12 +28,12 @@ import {
findLeafNode,
isAbortError
} from '$lib/utils';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { REASONING_TAGS } from '$lib/constants/agentic';
import {
MAX_INACTIVE_CONVERSATION_STATES,
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS
} from '$lib/constants/cache';
INACTIVE_CONVERSATION_STATE_MAX_AGE_MS,
REASONING_TAGS,
SYSTEM_MESSAGE_PLACEHOLDER
} from '$lib/constants';
import type {
ChatMessageTimings,
ChatMessagePromptProgress,
@@ -1,9 +1,14 @@
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
import { ServerModelStatus, ModelModality } from '$lib/enums';
import { ModelsService, PropsService } from '$lib/services';
import { serverStore } from '$lib/stores/server.svelte';
import { TTLCache } from '$lib/utils';
import { MODEL_PROPS_CACHE_TTL_MS, MODEL_PROPS_CACHE_MAX_ENTRIES } from '$lib/constants/cache';
import {
MODEL_PROPS_CACHE_TTL_MS,
MODEL_PROPS_CACHE_MAX_ENTRIES,
FAVOURITE_MODELS_LOCALSTORAGE_KEY
} from '$lib/constants';
/**
* modelsStore - Reactive store for model management in both MODEL and ROUTER modes
@@ -50,7 +55,9 @@ class ModelsStore {
selectedModelName = $state<string | null>(null);
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
private modelLoadingStates = $state<Map<string, boolean>>(new Map());
private modelLoadingStates = new SvelteMap<string, boolean>();
favouriteModelIds = $state<Set<string>>(this.loadFavouritesFromStorage());
/**
* Model-specific props cache with TTL
@@ -261,15 +268,19 @@ class ModelsStore {
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const displayName = this.toDisplayName(displayNameSource);
const modelId = details?.model || item.id;
return {
id: item.id,
name: displayName,
model: details?.model || item.id,
model: modelId,
description: details?.description,
capabilities: rawCapabilities.filter((value: unknown): value is string => Boolean(value)),
details: details?.details,
meta: item.meta ?? null
meta: item.meta ?? null,
parsedId: ModelsService.parseModelId(modelId),
aliases: item.aliases ?? [],
tags: item.tags ?? []
} satisfies ModelOption;
});
@@ -497,22 +508,21 @@ class ModelsStore {
/** 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.
* 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
* @returns Promise that resolves when expected status is reached
* @throws Error if model reaches FAILED status
*/
private async pollForModelStatus(
modelId: string,
expectedStatus: ServerModelStatus
): Promise<void> {
for (let attempt = 0; attempt < ModelsStore.STATUS_POLL_MAX_ATTEMPTS; attempt++) {
let attempt = 0;
while (true) {
await this.fetchRouterModels();
const currentStatus = this.getModelStatus(modelId);
@@ -520,12 +530,23 @@ class ModelsStore {
return;
}
if (currentStatus === ServerModelStatus.FAILED) {
throw new Error(
`Model failed to ${expectedStatus === ServerModelStatus.LOADED ? 'load' : 'unload'}`
);
}
if (
expectedStatus === ServerModelStatus.LOADED &&
currentStatus === ServerModelStatus.UNLOADED &&
attempt > 2
) {
throw new Error('Model was unloaded unexpectedly during loading');
}
attempt++;
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`
);
}
/**
@@ -547,8 +568,10 @@ class ModelsStore {
await this.pollForModelStatus(modelId, ServerModelStatus.LOADED);
await this.updateModelModalities(modelId);
toast.success(`Model loaded: ${this.toDisplayName(modelId)}`);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to load model';
toast.error(`Failed to load model: ${this.toDisplayName(modelId)}`);
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
@@ -573,8 +596,10 @@ class ModelsStore {
await ModelsService.unload(modelId);
await this.pollForModelStatus(modelId, ServerModelStatus.UNLOADED);
toast.info(`Model unloaded: ${this.toDisplayName(modelId)}`);
} catch (error) {
this.error = error instanceof Error ? error.message : 'Failed to unload model';
toast.error(`Failed to unload model: ${this.toDisplayName(modelId)}`);
throw error;
} finally {
this.modelLoadingStates.set(modelId, false);
@@ -593,6 +618,48 @@ class ModelsStore {
await this.loadModel(modelId);
}
/**
*
*
* Favourites
*
*
*/
isFavourite(modelId: string): boolean {
return this.favouriteModelIds.has(modelId);
}
toggleFavourite(modelId: string): void {
const next = new SvelteSet(this.favouriteModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
this.favouriteModelIds = next;
try {
localStorage.setItem(FAVOURITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
toast.error('Failed to save favourite models to local storage');
}
}
private loadFavouritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVOURITE_MODELS_LOCALSTORAGE_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch {
toast.error('Failed to load favourite models from local storage');
return new Set();
}
}
/**
*
*
@@ -646,3 +713,4 @@ export const loadingModelIds = () => modelsStore.loadingModelIds;
export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName;
export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
export const favouriteModelIds = () => modelsStore.favouriteModelIds;
@@ -32,7 +32,11 @@
*/
import { browser } from '$app/environment';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import {
CONFIG_LOCALSTORAGE_KEY,
SETTING_CONFIG_DEFAULT,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants';
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { serverStore } from '$lib/stores/server.svelte';
import {
@@ -41,10 +45,6 @@ import {
getConfigValue,
setConfigValue
} from '$lib/utils';
import {
CONFIG_LOCALSTORAGE_KEY,
USER_OVERRIDES_LOCALSTORAGE_KEY
} from '$lib/constants/localstorage-keys';
class SettingsStore {
/**