mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-29 17:37:39 -05:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
+77
-5
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user