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:
parent
406f4e3f61
commit
062cca58fc
Binary file not shown.
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue