Add SLEEPING status to the WebUI model selector (#20949)

* webui: handle sleeping model status, fix favourite -> favorite

* Update tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte

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

* Update tools/server/webui/src/lib/components/app/models/ModelsSelectorOption.svelte

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

* webui: fix optional event parameter in sleeping model onclick

* typo

* webui: restore orange sleeping indicator dot with hover unload

* chore: update webui build output

* webui: move stopPropagation into ActionIcon onclick, remove svelte-ignore

* chore: update webui build output

* webui: fix favourite -> favorite (UK -> US spelling) everywhere

Address review feedback from WhyNotHugo

* chore: update webui build output

---------

Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
This commit is contained in:
Pascal 2026-03-25 11:02:32 +01:00 committed by GitHub
parent 406f4e3f61
commit 062cca58fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 70 additions and 41 deletions

Binary file not shown.

View File

@ -77,7 +77,7 @@
let filteredOptions = $derived(filterModelOptions(options, searchTerm)); let filteredOptions = $derived(filterModelOptions(options, searchTerm));
let groupedFilteredOptions = $derived( let groupedFilteredOptions = $derived(
groupModelOptions(filteredOptions, modelsStore.favouriteModelIds, (m) => groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
modelsStore.isModelLoaded(m) modelsStore.isModelLoaded(m)
) )
); );
@ -353,7 +353,7 @@
{@const { option, flatIndex } = item} {@const { option, flatIndex } = item}
{@const isSelected = currentModel === option.model || activeId === option.id} {@const isSelected = currentModel === option.model || activeId === option.id}
{@const isHighlighted = flatIndex === highlightedIndex} {@const isHighlighted = flatIndex === highlightedIndex}
{@const isFav = modelsStore.favouriteModelIds.has(option.model)} {@const isFav = modelsStore.favoriteModelIds.has(option.model)}
<ModelsSelectorOption <ModelsSelectorOption
{option} {option}

View File

@ -30,7 +30,7 @@
{#snippet defaultOption(item: ModelItem, showOrgName: boolean)} {#snippet defaultOption(item: ModelItem, showOrgName: boolean)}
{@const { option } = item} {@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id} {@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favouriteModelIds.has(option.model)} {@const isFav = modelsStore.favoriteModelIds.has(option.model)}
<ModelsSelectorOption <ModelsSelectorOption
{option} {option}
@ -52,9 +52,9 @@
{/each} {/each}
{/if} {/if}
{#if groups.favourites.length > 0} {#if groups.favorites.length > 0}
<p class={sectionHeaderClass}>Favourite models</p> <p class={sectionHeaderClass}>Favorite models</p>
{#each groups.favourites as item (`fav-${item.option.id}`)} {#each groups.favorites as item (`fav-${item.option.id}`)}
{@render render(item, true)} {@render render(item, true)}
{/each} {/each}
{/if} {/if}

View File

@ -46,7 +46,10 @@
}); });
let isOperationInProgress = $derived(modelsStore.isModelOperationInProgress(option.model)); let isOperationInProgress = $derived(modelsStore.isModelOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED); let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
let isLoaded = $derived(serverStatus === ServerModelStatus.LOADED && !isOperationInProgress); let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
let isLoaded = $derived(
(serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
);
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress); let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
</script> </script>
@ -85,17 +88,17 @@
<ActionIcon <ActionIcon
iconSize="h-2.5 w-2.5" iconSize="h-2.5 w-2.5"
icon={HeartOff} icon={HeartOff}
tooltip="Remove from favourites" tooltip="Remove from favorites"
class="h-3 w-3 hover:text-foreground" class="h-3 w-3 hover:text-foreground"
onclick={() => modelsStore.toggleFavourite(option.model)} onclick={() => modelsStore.toggleFavorite(option.model)}
/> />
{:else} {:else}
<ActionIcon <ActionIcon
iconSize="h-2.5 w-2.5" iconSize="h-2.5 w-2.5"
icon={Heart} icon={Heart}
tooltip="Add to favourites" tooltip="Add to favorites"
class="h-3 w-3 hover:text-foreground" class="h-3 w-3 hover:text-foreground"
onclick={() => modelsStore.toggleFavourite(option.model)} onclick={() => modelsStore.toggleFavorite(option.model)}
/> />
{/if} {/if}
@ -129,6 +132,23 @@
/> />
</div> </div>
</div> </div>
{:else if isSleeping}
<div class="flex w-4 items-center justify-center">
<span class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden"></span>
<div class="hidden group-hover:flex">
<ActionIcon
iconSize="h-2.5 w-2.5"
icon={PowerOff}
tooltip="Unload model"
class="h-3 w-3 text-red-500 hover:text-red-600"
onclick={(e) => {
e?.stopPropagation();
modelsStore.unloadModel(option.model);
}}
/>
</div>
</div>
{:else if isLoaded} {:else if isLoaded}
<div class="flex w-4 items-center justify-center"> <div class="flex w-4 items-center justify-center">
<span class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden"></span> <span class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden"></span>

View File

@ -76,7 +76,7 @@
let filteredOptions = $derived(filterModelOptions(options, searchTerm)); let filteredOptions = $derived(filterModelOptions(options, searchTerm));
let groupedFilteredOptions = $derived( let groupedFilteredOptions = $derived(
groupModelOptions(filteredOptions, modelsStore.favouriteModelIds, (m) => groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
modelsStore.isModelLoaded(m) modelsStore.isModelLoaded(m)
) )
); );

View File

@ -47,7 +47,7 @@ export { default as ModelsSelector } from './ModelsSelector.svelte';
/** /**
* **ModelsSelectorList** - Grouped model options list * **ModelsSelectorList** - Grouped model options list
* *
* Renders grouped model options (loaded, favourites, available) with section * Renders grouped model options (loaded, favorites, available) with section
* headers and org subgroups. Shared between ModelsSelector and ModelsSelectorSheet * headers and org subgroups. Shared between ModelsSelector and ModelsSelectorSheet
* to avoid template duplication. * to avoid template duplication.
* *
@ -59,7 +59,7 @@ export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
/** /**
* **ModelsSelectorOption** - Single model option row * **ModelsSelectorOption** - Single model option row
* *
* Renders a single model option with selection state, favourite toggle, * Renders a single model option with selection state, favorite toggle,
* load/unload actions, status indicators, and an info button. * load/unload actions, status indicators, and an info button.
* Used inside ModelsSelectorList or directly in custom render snippets. * Used inside ModelsSelectorList or directly in custom render snippets.
*/ */

View File

@ -13,7 +13,7 @@ export interface OrgGroup {
export interface GroupedModelOptions { export interface GroupedModelOptions {
loaded: ModelItem[]; loaded: ModelItem[];
favourites: ModelItem[]; favorites: ModelItem[];
available: OrgGroup[]; available: OrgGroup[];
} }
@ -32,7 +32,7 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
export function groupModelOptions( export function groupModelOptions(
filteredOptions: ModelOption[], filteredOptions: ModelOption[],
favouriteIds: Set<string>, favoriteIds: Set<string>,
isModelLoaded: (model: string) => boolean isModelLoaded: (model: string) => boolean
): GroupedModelOptions { ): GroupedModelOptions {
// Loaded models // Loaded models
@ -43,24 +43,24 @@ export function groupModelOptions(
} }
} }
// Favourites (excluding loaded) // Favorites (excluding loaded)
const loadedModelIds = new Set(loaded.map((item) => item.option.model)); const loadedModelIds = new Set(loaded.map((item) => item.option.model));
const favourites: ModelItem[] = []; const favorites: ModelItem[] = [];
for (let i = 0; i < filteredOptions.length; i++) { for (let i = 0; i < filteredOptions.length; i++) {
if ( if (
favouriteIds.has(filteredOptions[i].model) && favoriteIds.has(filteredOptions[i].model) &&
!loadedModelIds.has(filteredOptions[i].model) !loadedModelIds.has(filteredOptions[i].model)
) { ) {
favourites.push({ option: filteredOptions[i], flatIndex: i }); favorites.push({ option: filteredOptions[i], flatIndex: i });
} }
} }
// Available models grouped by org (excluding loaded and favourites) // Available models grouped by org (excluding loaded and favorites)
const available: OrgGroup[] = []; const available: OrgGroup[] = [];
const orgGroups = new SvelteMap<string, ModelItem[]>(); const orgGroups = new SvelteMap<string, ModelItem[]>();
for (let i = 0; i < filteredOptions.length; i++) { for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i]; const option = filteredOptions[i];
if (loadedModelIds.has(option.model) || favouriteIds.has(option.model)) continue; if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
const key = option.parsedId?.orgName ?? ''; const key = option.parsedId?.orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []); if (!orgGroups.has(key)) orgGroups.set(key, []);
@ -71,5 +71,5 @@ export function groupModelOptions(
available.push({ orgName: orgName || null, items }); available.push({ orgName: orgName || null, items });
} }
return { loaded, favourites, available }; return { loaded, favorites, available };
} }

View File

@ -1,4 +1,4 @@
export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config'; export const CONFIG_LOCALSTORAGE_KEY = 'LlamaCppWebui.config';
export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides'; export const USER_OVERRIDES_LOCALSTORAGE_KEY = 'LlamaCppWebui.userOverrides';
export const FAVOURITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favouriteModels'; export const FAVORITE_MODELS_LOCALSTORAGE_KEY = 'LlamaCppWebui.favoriteModels';
export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = 'LlamaCppWebui.mcpDefaultEnabled'; export const MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY = 'LlamaCppWebui.mcpDefaultEnabled';

View File

@ -16,5 +16,6 @@ export enum ServerModelStatus {
UNLOADED = 'unloaded', UNLOADED = 'unloaded',
LOADING = 'loading', LOADING = 'loading',
LOADED = 'loaded', LOADED = 'loaded',
SLEEPING = 'sleeping',
FAILED = 'failed' FAILED = 'failed'
} }

View File

@ -7,7 +7,7 @@ import { TTLCache } from '$lib/utils';
import { import {
MODEL_PROPS_CACHE_TTL_MS, MODEL_PROPS_CACHE_TTL_MS,
MODEL_PROPS_CACHE_MAX_ENTRIES, MODEL_PROPS_CACHE_MAX_ENTRIES,
FAVOURITE_MODELS_LOCALSTORAGE_KEY FAVORITE_MODELS_LOCALSTORAGE_KEY
} from '$lib/constants'; } from '$lib/constants';
/** /**
@ -57,7 +57,7 @@ class ModelsStore {
private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map()); private modelUsage = $state<Map<string, SvelteSet<string>>>(new Map());
private modelLoadingStates = new SvelteMap<string, boolean>(); private modelLoadingStates = new SvelteMap<string, boolean>();
favouriteModelIds = $state<Set<string>>(this.loadFavouritesFromStorage()); favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
/** /**
* Model-specific props cache with TTL * Model-specific props cache with TTL
@ -90,7 +90,11 @@ class ModelsStore {
get loadedModelIds(): string[] { get loadedModelIds(): string[] {
return this.routerModels return this.routerModels
.filter((m) => m.status.value === ServerModelStatus.LOADED) .filter(
(m) =>
m.status.value === ServerModelStatus.LOADED ||
m.status.value === ServerModelStatus.SLEEPING
)
.map((m) => m.id); .map((m) => m.id);
} }
@ -215,7 +219,11 @@ class ModelsStore {
isModelLoaded(modelId: string): boolean { isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId); const model = this.routerModels.find((m) => m.id === modelId);
return model?.status.value === ServerModelStatus.LOADED || false; return (
model?.status.value === ServerModelStatus.LOADED ||
model?.status.value === ServerModelStatus.SLEEPING ||
false
);
} }
isModelOperationInProgress(modelId: string): boolean { isModelOperationInProgress(modelId: string): boolean {
@ -621,17 +629,17 @@ class ModelsStore {
/** /**
* *
* *
* Favourites * Favorites
* *
* *
*/ */
isFavourite(modelId: string): boolean { isFavorite(modelId: string): boolean {
return this.favouriteModelIds.has(modelId); return this.favoriteModelIds.has(modelId);
} }
toggleFavourite(modelId: string): void { toggleFavorite(modelId: string): void {
const next = new SvelteSet(this.favouriteModelIds); const next = new SvelteSet(this.favoriteModelIds);
if (next.has(modelId)) { if (next.has(modelId)) {
next.delete(modelId); next.delete(modelId);
@ -639,22 +647,22 @@ class ModelsStore {
next.add(modelId); next.add(modelId);
} }
this.favouriteModelIds = next; this.favoriteModelIds = next;
try { try {
localStorage.setItem(FAVOURITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next])); localStorage.setItem(FAVORITE_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch { } catch {
toast.error('Failed to save favourite models to local storage'); toast.error('Failed to save favorite models to local storage');
} }
} }
private loadFavouritesFromStorage(): Set<string> { private loadFavoritesFromStorage(): Set<string> {
try { try {
const raw = localStorage.getItem(FAVOURITE_MODELS_LOCALSTORAGE_KEY); const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
return raw ? new Set(JSON.parse(raw) as string[]) : new Set(); return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
} catch { } catch {
toast.error('Failed to load favourite models from local storage'); toast.error('Failed to load favorite models from local storage');
return new Set(); return new Set();
} }
@ -713,4 +721,4 @@ export const loadingModelIds = () => modelsStore.loadingModelIds;
export const propsCacheVersion = () => modelsStore.propsCacheVersion; export const propsCacheVersion = () => modelsStore.propsCacheVersion;
export const singleModelName = () => modelsStore.singleModelName; export const singleModelName = () => modelsStore.singleModelName;
export const selectedModelContextSize = () => modelsStore.selectedModelContextSize; export const selectedModelContextSize = () => modelsStore.selectedModelContextSize;
export const favouriteModelIds = () => modelsStore.favouriteModelIds; export const favoriteModelIds = () => modelsStore.favoriteModelIds;

View File

@ -54,7 +54,7 @@ export interface ApiChatMessageData {
* Model status object from /models endpoint * Model status object from /models endpoint
*/ */
export interface ApiModelStatus { export interface ApiModelStatus {
/** Status value: loaded, unloaded, loading, failed */ /** Status value: loaded, unloaded, loading, sleeping, failed */
value: ServerModelStatus; value: ServerModelStatus;
/** Command line arguments used when loading (only for loaded models) */ /** Command line arguments used when loading (only for loaded models) */
args?: string[]; args?: string[];