ui : add context and last used columns, hide and delete actions

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 12:10:11 +02:00
parent aa7ef145cf
commit 2e4131d0de
9 changed files with 252 additions and 10 deletions
@@ -0,0 +1,72 @@
<script lang="ts">
import { isLocalOption } from './ModelsManager/utils';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { formatParameters } from '$lib/utils/formatters';
interface Props {
option: ModelOption;
}
let { option }: Props = $props();
// a listing that reports a context window (OpenRouter, Groq, HF) needs no lookup
let reported = $derived(option.contextLength ?? null);
let el = $state<HTMLElement | null>(null);
let isNearViewport = $state(false);
let fetched = $state<number | null>(null);
$effect(() => {
if (isNearViewport || !el) return;
if (typeof IntersectionObserver === 'undefined') {
isNearViewport = true;
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
isNearViewport = true;
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(el);
return () => observer.disconnect();
});
$effect(() => {
fetched = null;
if (reported || !isNearViewport || !isLocalOption(option)) return;
// a local GGUF carries its trained context in the model metadata
const repo = option.model.split(':')[0] ?? '';
if (!repo) return;
let cancelled = false;
void HuggingFaceService.getDetails(repo)
.then((details) => {
if (!cancelled) fetched = details?.gguf?.context_length ?? null;
})
// best-effort: offline or a repo we cannot read keeps the dash
.catch(() => {});
return () => {
cancelled = true;
};
});
let context = $derived(reported ?? fetched);
</script>
<span class="text-sm text-muted-foreground">
{context ? `${formatParameters(context)} tokens` : '—'}
</span>
@@ -1,5 +1,5 @@
<script lang="ts">
import { CircleAlert, Loader2, Power, RotateCw, Upload } from '@lucide/svelte';
import { CircleAlert, Cloud, Loader2, Power, RotateCw, Upload } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
@@ -19,6 +19,8 @@
revealOnHover?: boolean;
/** Non-loadable rows show the provider mark, which identifies them in a flat list. */
showBackendMark?: boolean;
/** Table rows mark a remote provider, which this UI cannot load or unload. */
showRemoteMark?: boolean;
}
let {
@@ -29,7 +31,8 @@
isSleeping = false,
option,
revealOnHover = true,
showBackendMark = false
showBackendMark = false,
showRemoteMark = false
}: Props = $props();
</script>
@@ -37,6 +40,10 @@
{#if !canLoad}
{#if showBackendMark}
<BackendIcon backend={getBackend(option.backendId)} class="h-3.5 w-3.5" />
{:else if showRemoteMark}
<span class="text-muted-foreground" title="Served remotely"
><Cloud class="h-3.5 w-3.5" /></span
>
{/if}
{:else if isLoading}
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
@@ -183,6 +183,7 @@
{groups}
{isFavorite}
onSelect={(option) => (selectedId = option.id)}
onToggleLoad={(option) => void toggleLoad(option)}
{selectedId}
{summary}
/>
@@ -1,16 +1,21 @@
<script lang="ts">
import type { ModelsTableGroup } from './utils';
import { Heart, Power } from '@lucide/svelte';
import { formatLastUsed } from './utils';
import { EyeOff, Heart, MoreHorizontal, Power } from '@lucide/svelte';
import {
Logo,
ModelAvatar,
ModelContext,
ModelId,
ModelLoadControl,
ModelRowActions,
ModelsSection
} from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Input } from '$lib/components/ui/input';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { ModelCapability, ModelDownloadConfirmAction, ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
@@ -21,6 +26,7 @@
groups: ModelsTableGroup[];
isFavorite: (option: ModelOption) => boolean;
onSelect: (option: ModelOption) => void;
onToggleLoad: (option: ModelOption) => void;
selectedId: string | null;
summary: string;
}
@@ -30,12 +36,20 @@
groups,
isFavorite,
onSelect,
onToggleLoad,
selectedId,
summary
}: Props = $props();
let isEmpty = $derived(groups.every((group) => group.items.length === 0));
const rowGrid = 'grid grid-cols-[minmax(0,1fr)_3rem_4.5rem] items-center gap-3';
let pendingDelete = $state('');
let deleteOpen = $state(false);
function requestDelete(option: ModelOption): void {
pendingDelete = option.model;
deleteOpen = true;
}
const rowGrid = 'grid grid-cols-[minmax(0,1fr)_7rem_5rem_3rem_4.5rem] items-center gap-3';
function stateOf(option: ModelOption): ServerModelStatus | null {
const model = modelsStore.routerModels.find((m) => m.id === option.model);
@@ -54,12 +68,15 @@
{@const isFailed = status === ServerModelStatus.FAILED}
{@const isSleeping = status === ServerModelStatus.SLEEPING}
{@const favorite = isFavorite(option)}
{@const canLoad = getBackendCapabilities(getBackend(option.backendId)).loadUnload}
{@const isHidden = modelsStore.isHidden(option.id)}
<div>
<div
class={[
rowGrid,
'cursor-pointer rounded-md px-2 py-2.5 transition',
isHidden && 'opacity-60',
selectedId === option.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
]}
onclick={() => onSelect(option)}
@@ -70,6 +87,10 @@
<span class="flex min-w-0 items-center gap-3">
<ModelAvatar {option} showBaseModelAvatar size="size-9" />
{#if isHidden}
<EyeOff class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{/if}
<ModelId
aliases={option.aliases}
class="min-w-0 flex-1"
@@ -82,23 +103,70 @@
/>
</span>
<ModelContext {option} />
<span class="text-sm text-muted-foreground">
{formatLastUsed(modelsStore.recentModelUsage[option.id])}
</span>
<ModelLoadControl
canLoad={getBackendCapabilities(getBackend(option.backendId)).loadUnload}
{canLoad}
{isFailed}
{isLoaded}
{isLoading}
{isSleeping}
{option}
revealOnHover={false}
showRemoteMark
/>
<span class="flex items-center justify-end">
<span class="flex items-center justify-end gap-1">
<ModelRowActions isFav={favorite} {isLoaded} {option} revealOnHover={false} />
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button
{...props}
aria-label="Model actions"
class="h-7 w-7 text-muted-foreground"
onclick={(event) => event.stopPropagation()}
size="icon"
variant="ghost"
>
<MoreHorizontal class="h-3.5 w-3.5" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if canLoad}
<DropdownMenu.Item onclick={() => onToggleLoad(option)}>
{isLoaded ? 'Unload model' : 'Load model'}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => requestDelete(option)}>
Delete from disk
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onclick={() => modelsStore.toggleHidden(option.id)}>
{isHidden ? 'Show in selector' : 'Hide from selector'}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</span>
</div>
</div>
{/snippet}
<DialogConfirmDownload
action={ModelDownloadConfirmAction.DELETE}
onClose={() => (deleteOpen = false)}
open={deleteOpen}
repoWithTag={pendingDelete}
/>
<div class="flex h-full min-h-0 flex-col">
<div class="flex shrink-0 items-center gap-2 py-4">
<Input bind:value={filter} class="h-8 max-w-64 text-sm" placeholder="Filter models..." />
@@ -111,6 +179,10 @@
>
<span>Model</span>
<span>Context</span>
<span>Last used</span>
<span class="text-center">State</span>
<span class="text-right">Actions</span>
@@ -158,6 +158,23 @@ export async function resolveModelSize(option: ModelOption): Promise<string | nu
return file?.size ? formatFileSize(file.size) : null;
}
/** Compact "last used" label: minutes, hours, then days. */
export function formatLastUsed(timestamp?: number): string {
if (!timestamp) return '—';
const minutes = Math.floor((Date.now() - timestamp) / 60_000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/** Extra args the router applies when this model is loaded. */
export function loadExtraArgs(override?: ModelOverride): string[] {
const load = override?.load;
@@ -47,6 +47,7 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* aliases, and tags. Supports raw mode to show the unprocessed model name.
* Respects the user's `showRawModelNames` setting.
*/
export { default as ModelContext } from './ModelContext.svelte';
export { default as ModelId } from './ModelId.svelte';
export { default as ModelAvatar } from './ModelAvatar.svelte';
export { default as ModelLoadControl } from './ModelLoadControl.svelte';
@@ -39,6 +39,12 @@ export const SELECTED_MODEL_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.selectedMode
/** Recently used model ids, most recent first, backend-qualified. */
export const RECENT_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.recentModels`;
/** Last use timestamp per backend-qualified model id. */
export const RECENT_MODEL_USAGE_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.recentModelUsage`;
/** Model ids kept out of the selector, backend-qualified. */
export const HIDDEN_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.hiddenModels`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
@@ -127,10 +127,14 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
let showModelDialog = $state(false);
let infoModelId = $state<string | null>(null);
const visibleOptions = $derived(allOptions.filter((option) => !modelsStore.isHidden(option.id)));
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
// favorites span every backend, so they come from the full option list
const favoriteItems = $derived(
groupFavoriteOptions(filterModelOptions(allOptions, searchTerm), modelsStore.favoriteModelIds)
groupFavoriteOptions(
filterModelOptions(visibleOptions, searchTerm),
modelsStore.favoriteModelIds
)
);
const remoteProviders = $derived(
backendsStore.enabled
@@ -154,7 +158,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const loadedItems = $derived.by(() => {
if (isProviderView) return [];
return filterModelOptions(allOptions, searchTerm)
return filterModelOptions(visibleOptions, searchTerm)
.map((option, flatIndex) => ({ flatIndex, option }))
.filter(({ option }) => isLoadedLlamaCompat(option));
});
+63 -1
View File
@@ -10,7 +10,9 @@
import { browser } from '$app/environment';
import {
FAVORITE_MODELS_LOCALSTORAGE_KEY,
HIDDEN_MODELS_LOCALSTORAGE_KEY,
RECENT_MODEL_LIMIT,
RECENT_MODEL_USAGE_LOCALSTORAGE_KEY,
RECENT_MODELS_LOCALSTORAGE_KEY,
SELECTED_MODEL_LOCALSTORAGE_KEY
} from '$lib/constants';
@@ -50,6 +52,36 @@ function loadStoredSelection(): { id: string; model: string | null } | null {
const storedSelection = loadStoredSelection();
/** Last use timestamp per backend-qualified model id. */
function loadRecentModelUsage(): Record<string, number> {
if (!browser) return {};
try {
const raw = localStorage.getItem(RECENT_MODEL_USAGE_LOCALSTORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as Record<string, number>) : {};
} catch {
return {};
}
}
/** Models kept out of the selector. */
function loadHiddenModels(): Set<string> {
if (!browser) return new SvelteSet<string>();
try {
const raw = localStorage.getItem(HIDDEN_MODELS_LOCALSTORAGE_KEY);
return raw ? new SvelteSet(JSON.parse(raw) as string[]) : new SvelteSet<string>();
} catch {
return new SvelteSet<string>();
}
}
/** Recently used backend-qualified ids, most recent first. */
function loadRecentModels(): string[] {
if (!browser) return [];
@@ -73,6 +105,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
activeModels = $state<ModelOption[]>([]);
error = $state<string | null>(null);
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
hiddenModelIds = $state<Set<string>>(loadHiddenModels());
loading = $state(false);
/**
* Every selectable model across enabled backends. The active backend's
@@ -117,6 +150,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return merged;
});
recentModelIds = $state<string[]>(loadRecentModels());
recentModelUsage = $state<Record<string, number>>(loadRecentModelUsage());
routerModels = $state<ApiModelDataEntry[]>([]);
selectedModelId = $state<string | null>(storedSelection?.id ?? null);
@@ -390,6 +424,10 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return this.favoriteModelIds.has(modelId);
}
isHidden(modelId: string): boolean {
return this.hiddenModelIds.has(modelId);
}
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
@@ -550,6 +588,25 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
/** Models hidden from the selector stay in the manager, flagged and unhideable. */
toggleHidden(modelId: string): void {
const next = new SvelteSet(this.hiddenModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
this.hiddenModelIds = next;
try {
localStorage.setItem(HIDDEN_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
toast.error('Failed to save hidden models to local storage');
}
}
/**
* Build ModelOption[] from an API response.
* Both MODEL and ROUTER modes share the same mapping logic;
@@ -601,7 +658,6 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
})
);
}
/** Fetch models in MODEL mode (single model, standard OpenAI-compatible). */
private async fetchModelModeInternal(): Promise<ModelOption[]> {
const response = await ModelsService.list();
@@ -617,6 +673,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
(option) => this.props.getModelProps(option.model)?.ui !== false
);
}
private loadFavoritesFromStorage(): Set<string> {
try {
const raw = localStorage.getItem(FAVORITE_MODELS_LOCALSTORAGE_KEY);
@@ -652,11 +709,16 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
qualifiedId,
...this.recentModelIds.filter((id) => id !== qualifiedId)
].slice(0, RECENT_MODEL_LIMIT);
this.recentModelUsage = { ...this.recentModelUsage, [qualifiedId]: Date.now() };
if (!browser) return;
try {
localStorage.setItem(RECENT_MODELS_LOCALSTORAGE_KEY, JSON.stringify(this.recentModelIds));
localStorage.setItem(
RECENT_MODEL_USAGE_LOCALSTORAGE_KEY,
JSON.stringify(this.recentModelUsage)
);
} catch {
console.warn('[ModelsStore] Failed to persist the recently used models');
}