mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-28 17:07:31 -05:00
ui : show every provider's models in the manager
The table grows a section per enabled provider, each with its own mark, error and loading state, and a provider filter with live repo counts. Rows gain their capability gates back, the draft column with its use-as-draft action, and the quant badge names the provider on its rows. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverAvatar from './discover/ModelsDiscoverAvatar.svelte';
|
||||
import { HF_BASE_MODEL_TAG_REGEX } from '$lib/constants';
|
||||
import { BackendIcon } from '$lib/components/app/backends';
|
||||
import { Logo } from '$lib/components/app/misc';
|
||||
import { HF_BASE_MODEL_TAG_REGEX, LOCAL_BACKEND_ID, MODEL_ICON } from '$lib/constants';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { orgOf } from '$lib/utils';
|
||||
import { getBackend } from '$lib/utils/api-base';
|
||||
import { getBackendCapabilities } from '$lib/utils/backend';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -39,6 +43,12 @@
|
||||
|
||||
let parsedId = $derived(ModelsService.parseModelId(option.model));
|
||||
let orgName = $derived(parsedId.orgName);
|
||||
// a llama-compat model whose id carries no `org/name` is not a Hugging Face repo,
|
||||
// so the provider's own mark identifies it better than an initial
|
||||
let isLlamaCompat = $derived(getBackendCapabilities(getBackend(option.backendId)).props);
|
||||
let useProviderIcon = $derived(isLlamaCompat && !orgName);
|
||||
// the bundled server has no favicon to resolve, its mark is the llama.cpp logo
|
||||
let isLocal = $derived(getBackend(option.backendId)?.id === LOCAL_BACKEND_ID);
|
||||
let tagBaseModel = $derived(
|
||||
(option.tags ?? [])
|
||||
.find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
|
||||
@@ -80,6 +90,12 @@
|
||||
|
||||
if (!isNearViewport || !showBaseModelAvatar || !orgName || tagBaseModel) return;
|
||||
|
||||
// external provider ids (`~openai/gpt-...`, `deepseek/deepseek-chat`) are
|
||||
// not HF repos; their org is already the provider slug
|
||||
const backend = getBackend(option.backendId);
|
||||
|
||||
if (backend && !getBackendCapabilities(backend).props) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getBaseModel(option.model)
|
||||
@@ -95,7 +111,19 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if orgName}
|
||||
{#if useProviderIcon}
|
||||
<span class={['inline-flex shrink-0', className]}>
|
||||
<BackendIcon backend={getBackend(option.backendId)} class={size}>
|
||||
{#snippet fallback()}
|
||||
{#if isLocal}
|
||||
<Logo class={size} style="--size: 100%" />
|
||||
{:else}
|
||||
<MODEL_ICON class={size} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</BackendIcon>
|
||||
</span>
|
||||
{:else if orgName}
|
||||
<span bind:this={avatarEl} class={['inline-flex shrink-0', className]}>
|
||||
<ModelsDiscoverAvatar
|
||||
class="mt-0"
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { CircleAlert, Eject, Loader2, Power, RotateCw } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { BackendIcon } from '$lib/components/app/backends';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { backendsModelsStore, modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { getBackend } from '$lib/utils/api-base';
|
||||
|
||||
interface Props {
|
||||
/** Backend can load and unload models, llama-compat servers only. */
|
||||
canLoad: boolean;
|
||||
class?: string;
|
||||
isFailed?: boolean;
|
||||
isLoaded: boolean;
|
||||
@@ -16,9 +20,14 @@
|
||||
revealOnHover?: boolean;
|
||||
/** Renders the state alone, for a caller that moves load and unload elsewhere. */
|
||||
showAction?: 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 {
|
||||
canLoad,
|
||||
class: className = '',
|
||||
isFailed = false,
|
||||
isLoaded,
|
||||
@@ -26,12 +35,35 @@
|
||||
isSleeping = false,
|
||||
option,
|
||||
revealOnHover = true,
|
||||
showAction = true
|
||||
showAction = true,
|
||||
showBackendMark = false,
|
||||
showRemoteMark = false
|
||||
}: Props = $props();
|
||||
|
||||
let backendName = $derived(getBackend(option.backendId)?.name ?? 'Remote provider');
|
||||
/** The provider's listing failed, so nothing it serves is selectable right now. */
|
||||
let isBackendFailed = $derived(
|
||||
Boolean(option.backendId && backendsModelsStore.get(option.backendId).error)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={['flex w-5 shrink-0 items-center justify-center', className]}>
|
||||
{#if isLoading}
|
||||
{#if !canLoad}
|
||||
{#if showBackendMark}
|
||||
<BackendIcon backend={getBackend(option.backendId)} class="h-3.5 w-3.5" />
|
||||
{:else if showRemoteMark}
|
||||
<span
|
||||
class="flex items-center gap-1 {isBackendFailed ? 'opacity-50 grayscale' : ''}"
|
||||
title={isBackendFailed ? `${backendName} is unavailable` : backendName}
|
||||
>
|
||||
<BackendIcon backend={getBackend(option.backendId)} class="h-3.5 w-3.5" />
|
||||
|
||||
{#if isBackendFailed}
|
||||
<CircleAlert class="h-3 w-3 text-destructive" />
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{:else if isLoading}
|
||||
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
<!-- the state dot is what the row shows at rest; the action takes its place on hover -->
|
||||
|
||||
@@ -2,18 +2,25 @@
|
||||
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
|
||||
import {
|
||||
groupModelQuants,
|
||||
loadOverrides,
|
||||
type ModalityKey,
|
||||
modelContextLength,
|
||||
modelDraftsFor,
|
||||
type ModelOverride,
|
||||
type ModelQuantGroup,
|
||||
type ModelsTableGroup,
|
||||
modelSupports
|
||||
modelSupports,
|
||||
saveOverrides
|
||||
} from './utils';
|
||||
import { LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { modelsStore, uiStore } from '$lib/stores';
|
||||
import { backendsStore, modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { getBackend } from '$lib/utils/api-base';
|
||||
import { getBackendCapabilities } from '$lib/utils/backend';
|
||||
import { type Snippet } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
@@ -24,15 +31,20 @@
|
||||
let { class: className, toolbarEnd }: Props = $props();
|
||||
|
||||
let filter = $state('');
|
||||
let providerFilter = $state<string[]>([]);
|
||||
let contextLimit = $state(0);
|
||||
let modalityFilter = $state<ModalityKey[]>([]);
|
||||
let capabilityFilter = $state<ModelCapability[]>([]);
|
||||
let draftFilter = $state(false);
|
||||
let selectedId = $state<string | null>(null);
|
||||
let overrides = $state<Record<string, ModelOverride>>(loadOverrides());
|
||||
|
||||
let allModels = $derived(modelsStore.models);
|
||||
let isFavorite = $derived((option: ModelOption) =>
|
||||
modelsStore.favoriteModelIds.has(option.model)
|
||||
);
|
||||
// every filter but the provider one, so a provider count does not fall to zero
|
||||
// the moment that provider is the one being looked at
|
||||
let matching = $derived.by(() => {
|
||||
const term = filter.trim().toLowerCase();
|
||||
|
||||
@@ -48,6 +60,13 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
draftFilter &&
|
||||
modelDraftsFor(option, overrides[option.id]?.load?.speculativeDecoding).length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// a model whose modalities are unknown cannot be shown to match
|
||||
if (modalityFilter.length > 0 && !modalityFilter.some((key) => option.modalities?.[key])) {
|
||||
return false;
|
||||
@@ -56,6 +75,24 @@
|
||||
return contextLimit === 0 || modelContextLength(option) >= contextLimit;
|
||||
});
|
||||
});
|
||||
let visible = $derived.by(() =>
|
||||
providerFilter.length === 0
|
||||
? matching
|
||||
: matching.filter((option) => providerFilter.includes(option.backendId ?? LOCAL_BACKEND_ID))
|
||||
);
|
||||
// the rail counts follow the active view and filter, so it always says how many
|
||||
// repos each provider contributes to what the table is showing
|
||||
let providerCounts = $derived.by(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const entry of groupModelQuants(matching)) {
|
||||
const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID;
|
||||
|
||||
counts[backendId] = (counts[backendId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
});
|
||||
|
||||
// recently used models lead their section, the rest keep the server's order
|
||||
const rank = new SvelteMap<string, number>();
|
||||
@@ -68,12 +105,16 @@
|
||||
rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
|
||||
|
||||
// one entry per repo, so a model with several quants is a single table row;
|
||||
// loaded models lead the table, then favorites, then the local block
|
||||
let entries = $derived(byRecency(groupModelQuants(matching)));
|
||||
// loaded models lead the table, then favorites, then one block per provider,
|
||||
// and an entry lands in the first group that claims it
|
||||
let entries = $derived(byRecency(groupModelQuants(visible)));
|
||||
let groups = $derived.by(() => {
|
||||
// A loaded quant is a model of its own: it moves to the loaded section, and
|
||||
// the quants of its repo that are not loaded stay behind as that repo.
|
||||
const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
|
||||
// the quants of its repo that are not loaded stay behind as that repo. Only
|
||||
// llama-compat servers report a load state.
|
||||
const isLoaded = (option: ModelOption) =>
|
||||
getBackendCapabilities(getBackend(option.backendId)).loadUnload &&
|
||||
modelsStore.isModelLoaded(option.model);
|
||||
const loaded: ModelQuantGroup[] = [];
|
||||
const rest: ModelQuantGroup[] = [];
|
||||
|
||||
@@ -100,7 +141,18 @@
|
||||
(entry) => !claimed.has(entry.key) && entry.quants.some((q) => modelsStore.isHidden(q.id))
|
||||
);
|
||||
const hiddenKeys = new SvelteSet(hidden.map((entry) => entry.key));
|
||||
const local = rest.filter((entry) => !claimed.has(entry.key) && !hiddenKeys.has(entry.key));
|
||||
const byBackend = new SvelteMap<string, ModelQuantGroup[]>();
|
||||
|
||||
for (const entry of rest) {
|
||||
if (claimed.has(entry.key) || hiddenKeys.has(entry.key)) continue;
|
||||
|
||||
const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID;
|
||||
|
||||
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
|
||||
|
||||
byBackend.get(backendId)!.push(entry);
|
||||
}
|
||||
|
||||
const ordered: ModelsTableGroup[] = [];
|
||||
|
||||
if (loaded.length) {
|
||||
@@ -125,17 +177,36 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (local.length) {
|
||||
const localItems = byBackend.get(LOCAL_BACKEND_ID);
|
||||
|
||||
if (localItems?.length) {
|
||||
ordered.push({
|
||||
backendId: LOCAL_BACKEND_ID,
|
||||
isLocal: true,
|
||||
items: local,
|
||||
items: localItems,
|
||||
key: LOCAL_BACKEND_ID,
|
||||
kind: 'local',
|
||||
label: 'Local models'
|
||||
});
|
||||
}
|
||||
|
||||
for (const backend of backendsStore.enabled) {
|
||||
if (backend.id === LOCAL_BACKEND_ID) continue;
|
||||
|
||||
const items = byBackend.get(backend.id);
|
||||
|
||||
if (items?.length) {
|
||||
ordered.push({
|
||||
backendId: backend.id,
|
||||
isLocal: false,
|
||||
items,
|
||||
key: backend.id,
|
||||
kind: 'provider',
|
||||
label: backend.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hidden.length) {
|
||||
ordered.push({
|
||||
backendId: null,
|
||||
@@ -162,6 +233,24 @@
|
||||
|
||||
uiStore.manageModelFocus = null;
|
||||
});
|
||||
|
||||
/** Point the selected model's load settings at another model as its draft. */
|
||||
function useAsDraft(draft: ModelOption, targetId: string): void {
|
||||
const target = modelsStore.models.find((option) => option.id === targetId);
|
||||
|
||||
if (!target) return;
|
||||
|
||||
saveOverride(target, {
|
||||
...overrides[target.id],
|
||||
load: { ...overrides[target.id]?.load, speculativeDecoding: draft.id }
|
||||
});
|
||||
}
|
||||
|
||||
function saveOverride(option: ModelOption, override: ModelOverride): void {
|
||||
overrides = { ...overrides, [option.id]: override };
|
||||
saveOverrides(overrides);
|
||||
toast.success(`Saved settings for ${option.name}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={['flex min-h-0 flex-1', className]}>
|
||||
@@ -169,11 +258,16 @@
|
||||
<ModelsManagerModelsTable
|
||||
bind:capabilities={capabilityFilter}
|
||||
bind:contextLimit
|
||||
bind:draft={draftFilter}
|
||||
bind:filter
|
||||
bind:modalities={modalityFilter}
|
||||
bind:providers={providerFilter}
|
||||
{groups}
|
||||
{isFavorite}
|
||||
onSelect={(option) => (selectedId = option.id)}
|
||||
onUseAsDraft={useAsDraft}
|
||||
{overrides}
|
||||
{providerCounts}
|
||||
{selectedId}
|
||||
{toolbarEnd}
|
||||
/>
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { ModalityKey } from './utils';
|
||||
import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
|
||||
import { ScrollCarousel } from '$lib/components/app';
|
||||
import { Check, ChevronDown, Image, Lightbulb, Mic, Server, Video, Wrench } from '@lucide/svelte';
|
||||
import { Logo, ScrollCarousel } from '$lib/components/app';
|
||||
import { BackendIcon } from '$lib/components/app/backends';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Toggle } from '$lib/components/ui/toggle';
|
||||
import * as ToggleGroup from '$lib/components/ui/toggle-group';
|
||||
import { FILTER_TRIGGER_CLASS } from '$lib/constants';
|
||||
import { FILTER_TRIGGER_CLASS, LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import type { Backend } from '$lib/types/backend';
|
||||
|
||||
interface Props {
|
||||
backends: Backend[];
|
||||
/** Capabilities a model must have every one of. */
|
||||
capabilities?: ModelCapability[];
|
||||
/** Smallest context a model must support; 0 keeps every model. */
|
||||
contextLimit?: number;
|
||||
/** Keep only models that have a draft sidecar to speculate with. */
|
||||
draft?: boolean;
|
||||
/** Modalities a model must support at least one of. */
|
||||
modalities?: ModalityKey[];
|
||||
/** Backend ids to keep; empty keeps every provider. */
|
||||
providers?: string[];
|
||||
/** Repos each provider contributes to the current search, shown in the menu. */
|
||||
providerCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
let {
|
||||
backends,
|
||||
capabilities = $bindable<ModelCapability[]>([]),
|
||||
contextLimit = $bindable(0),
|
||||
modalities = $bindable<ModalityKey[]>([])
|
||||
draft = $bindable(false),
|
||||
modalities = $bindable<ModalityKey[]>([]),
|
||||
providerCounts = {},
|
||||
providers = $bindable<string[]>([])
|
||||
}: Props = $props();
|
||||
|
||||
const CONTEXT_STEPS: { label: string; value: number }[] = [
|
||||
@@ -58,12 +73,83 @@
|
||||
const TOGGLE_ITEM_CLASS =
|
||||
'bg-muted! border-border/30! shadow-none! dark:border-border/20! data-[state=on]:bg-muted-foreground/15! data-[state=on]:text-foreground! dark:data-[state=on]:bg-muted-foreground/25!';
|
||||
|
||||
// none selected means every provider, so the label names the selection
|
||||
let providerLabel = $derived(
|
||||
providers.length === 0
|
||||
? 'All providers'
|
||||
: providers.length === 1
|
||||
? (backends.find((backend) => backend.id === providers[0])?.name ?? '1 provider')
|
||||
: `${providers.length} providers`
|
||||
);
|
||||
let contextLabel = $derived(
|
||||
CONTEXT_STEPS.find((step) => step.value === contextLimit)?.label ?? CONTEXT_STEPS[0].label
|
||||
);
|
||||
|
||||
function toggleProvider(id: string, checked: boolean | 'indeterminate'): void {
|
||||
providers = checked === true ? [...providers, id] : providers.filter((entry) => entry !== id);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet providerMark(backend: Backend)}
|
||||
{#if backend.id === LOCAL_BACKEND_ID}
|
||||
<BackendIcon {backend} class="h-3.5 w-3.5">
|
||||
{#snippet fallback()}
|
||||
<Logo class="shrink-0" style="--size: 0.875rem" />
|
||||
{/snippet}
|
||||
</BackendIcon>
|
||||
{:else}
|
||||
<BackendIcon {backend} class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<ScrollCarousel alwaysShowArrows class="min-w-0 flex-1" gapSize="2" innerClass="items-center">
|
||||
{#if backends.length > 1}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="inline-flex items-center whitespace-nowrap {FILTER_TRIGGER_CLASS}"
|
||||
type="button"
|
||||
>
|
||||
<Server class="h-3.5 w-3.5" />
|
||||
|
||||
{providerLabel}
|
||||
|
||||
<ChevronDown class="h-3.5 w-3.5 opacity-60" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<DropdownMenu.Content align="start" class="min-w-48">
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>Providers</DropdownMenu.GroupHeading>
|
||||
|
||||
{#each backends as backend (backend.id)}
|
||||
<DropdownMenu.CheckboxItem
|
||||
checked={providers.includes(backend.id)}
|
||||
onCheckedChange={(checked) => toggleProvider(backend.id, checked)}
|
||||
>
|
||||
{@render providerMark(backend)}
|
||||
|
||||
{backend.name}
|
||||
|
||||
<DropdownMenu.Shortcut>{providerCounts[backend.id] ?? 0}</DropdownMenu.Shortcut>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{/each}
|
||||
</DropdownMenu.Group>
|
||||
|
||||
{#if providers.length > 0}
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item onSelect={() => (providers = [])}
|
||||
>Show every provider</DropdownMenu.Item
|
||||
>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
|
||||
<Select.Root
|
||||
onValueChange={(value) => (contextLimit = Number(value))}
|
||||
type="single"
|
||||
@@ -82,6 +168,26 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<!-- a checkbox chip: the whole pill is the control, the box is its indicator -->
|
||||
<Toggle
|
||||
bind:pressed={draft}
|
||||
class="inline-flex items-center whitespace-nowrap {FILTER_TRIGGER_CLASS} px-2!"
|
||||
variant="outline"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-shadow {draft
|
||||
? 'border-border/30 bg-muted-foreground/15 text-foreground dark:border-border/20 dark:bg-muted-foreground/25'
|
||||
: 'border-input bg-background dark:bg-input/30'}"
|
||||
>
|
||||
{#if draft}
|
||||
<Check class="size-3" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
Has draft sidecar
|
||||
</Toggle>
|
||||
|
||||
<ToggleGroup.Root
|
||||
class="border border-border/30 bg-muted/60 shadow-sm dark:border-border/20 dark:bg-muted/75"
|
||||
onValueChange={setToggles}
|
||||
|
||||
+117
-23
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerFilters from './ModelsManagerFilters.svelte';
|
||||
import {
|
||||
isLocalOption,
|
||||
type ModalityKey,
|
||||
modelContextLength,
|
||||
type ModelDraft,
|
||||
modelDraftsFor,
|
||||
type ModelOverride,
|
||||
type ModelQuantGroup,
|
||||
type ModelsTableGroup
|
||||
} from './utils';
|
||||
@@ -19,7 +23,8 @@
|
||||
MoreHorizontal,
|
||||
Power,
|
||||
Trash2,
|
||||
X
|
||||
X,
|
||||
Zap
|
||||
} from '@lucide/svelte';
|
||||
import {
|
||||
DropdownMenuActions,
|
||||
@@ -40,8 +45,16 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { FAMILY_ROW_WINDOW, MODEL_ICON, MODEL_ROW_WINDOW } from '$lib/constants';
|
||||
import { ModelCapability, ModelDownloadConfirmAction, ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
|
||||
import {
|
||||
backendsModelsStore,
|
||||
backendsStore,
|
||||
modelsStore,
|
||||
settingsStore,
|
||||
uiStore
|
||||
} from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { getBackend } from '$lib/utils/api-base';
|
||||
import { getBackendCapabilities } from '$lib/utils/backend';
|
||||
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
@@ -51,12 +64,22 @@
|
||||
capabilities?: ModelCapability[];
|
||||
/** Smallest context a model must support; 0 keeps every model. */
|
||||
contextLimit?: number;
|
||||
/** Keep only models that have a draft sidecar to speculate with. */
|
||||
draft?: boolean;
|
||||
filter?: string;
|
||||
groups: ModelsTableGroup[];
|
||||
isFavorite: (option: ModelOption) => boolean;
|
||||
onSelect: (option: ModelOption) => void;
|
||||
/** Per-model load and inference overrides, keyed by backend-qualified id. */
|
||||
/** Modalities a model must support at least one of. */
|
||||
modalities?: ModalityKey[];
|
||||
overrides: Record<string, ModelOverride>;
|
||||
/** Backend ids to keep; empty keeps every provider. */
|
||||
providers?: string[];
|
||||
/** Repos each provider contributes to the current search, for the filter menu. */
|
||||
providerCounts?: Record<string, number>;
|
||||
/** Called when a row is set as the draft of the selected model. */
|
||||
onUseAsDraft?: (draft: ModelOption, targetId: string) => void;
|
||||
selectedId: string | null;
|
||||
/** Rendered at the toolbar's right end, past the filters. */
|
||||
toolbarEnd?: Snippet;
|
||||
@@ -65,17 +88,28 @@
|
||||
let {
|
||||
capabilities = $bindable<ModelCapability[]>([]),
|
||||
contextLimit = $bindable(0),
|
||||
draft = $bindable(false),
|
||||
filter = $bindable(''),
|
||||
groups,
|
||||
isFavorite,
|
||||
modalities = $bindable<ModalityKey[]>([]),
|
||||
onSelect,
|
||||
onUseAsDraft,
|
||||
overrides,
|
||||
providerCounts = {},
|
||||
providers = $bindable<string[]>([]),
|
||||
selectedId,
|
||||
toolbarEnd
|
||||
}: Props = $props();
|
||||
|
||||
let isEmpty = $derived(groups.every((group) => group.items.length === 0));
|
||||
let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
|
||||
let hasFilters = $derived(
|
||||
providers.length > 0 ||
|
||||
contextLimit > 0 ||
|
||||
modalities.length > 0 ||
|
||||
capabilities.length > 0 ||
|
||||
draft
|
||||
);
|
||||
let filterInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
// the dialog hands focus to its first control, so the filter takes it instead
|
||||
@@ -153,20 +187,38 @@
|
||||
}
|
||||
|
||||
/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
|
||||
function rowActions(option: ModelOption, favorite: boolean, isHidden: boolean) {
|
||||
function rowActions(option: ModelOption, canLoad: boolean, favorite: boolean, isHidden: boolean) {
|
||||
// a draft only makes sense for the model the configuration pane has open, and
|
||||
// only when both ends can load and unload at all
|
||||
const canBeDraft = canLoad && selectedId !== null && selectedId !== option.id && selectedName;
|
||||
|
||||
return [
|
||||
...(canBeDraft
|
||||
? [
|
||||
{
|
||||
icon: Zap,
|
||||
label: `Use as draft for ${selectedName}`,
|
||||
onclick: () => onUseAsDraft?.(option, selectedId as string),
|
||||
separator: true
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: favorite ? HeartOff : Heart,
|
||||
label: favorite ? 'Remove from favorites' : 'Add to favorites',
|
||||
onclick: () => modelsStore.toggleFavorite(option.model)
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete from disk',
|
||||
onclick: () => requestDelete(option),
|
||||
separator: true,
|
||||
variant: 'destructive' as const
|
||||
},
|
||||
...(canLoad
|
||||
? [
|
||||
{
|
||||
icon: Trash2,
|
||||
label: 'Delete from disk',
|
||||
onclick: () => requestDelete(option),
|
||||
separator: true,
|
||||
variant: 'destructive' as const
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: isHidden ? Eye : EyeOff,
|
||||
label: isHidden ? 'Unhide model' : 'Hide model',
|
||||
@@ -181,9 +233,25 @@
|
||||
return modelsStore.getModelStatus(option.model);
|
||||
}
|
||||
|
||||
/** Context the model runs with: what a loaded model reports. */
|
||||
/** Drafts to show on a row: the configured one first, then this repo's own sidecars. */
|
||||
function draftsFor(option: ModelOption): ModelDraft[] {
|
||||
return modelDraftsFor(option, overrides[option.id]?.load?.speculativeDecoding);
|
||||
}
|
||||
|
||||
/** Name of the model the configuration pane has open, for the draft action label. */
|
||||
let selectedName = $derived(
|
||||
modelsStore.models.find((option) => option.id === selectedId)?.name ?? null
|
||||
);
|
||||
|
||||
/** Context the model runs with: the stored override, else what a loaded model reports. */
|
||||
function configuredContext(option: ModelOption): number | null {
|
||||
return isLoadedOption(option) ? modelsStore.props.getModelContextSize(option.model) : null;
|
||||
const override = overrides[option.id]?.load?.contextLength;
|
||||
|
||||
if (override) return override;
|
||||
|
||||
return isLocalOption(option) && isLoadedOption(option)
|
||||
? modelsStore.props.getModelContextSize(option.model)
|
||||
: null;
|
||||
}
|
||||
|
||||
type SortKey = 'context' | 'name' | 'status';
|
||||
@@ -260,12 +328,14 @@
|
||||
{@const isSleeping = status === ServerModelStatus.SLEEPING}
|
||||
|
||||
<ModelLoadControl
|
||||
canLoad={getBackendCapabilities(getBackend(option.backendId)).loadUnload}
|
||||
class="justify-self-center"
|
||||
{isFailed}
|
||||
isLoaded={isLoadedOption(option)}
|
||||
{isLoading}
|
||||
{isSleeping}
|
||||
{option}
|
||||
showRemoteMark
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -290,6 +360,7 @@
|
||||
|
||||
{#snippet row(option: ModelOption, indent = 0)}
|
||||
{@const favorite = isFavorite(option)}
|
||||
{@const canLoad = getBackendCapabilities(getBackend(option.backendId)).loadUnload}
|
||||
{@const isHidden = modelsStore.isHidden(option.id)}
|
||||
|
||||
<!-- <div class="px-2"> -->
|
||||
@@ -317,6 +388,7 @@
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="min-w-0 flex-1"
|
||||
drafts={draftsFor(option)}
|
||||
hideCapabilities
|
||||
hideModalities
|
||||
modalities={option.modalities}
|
||||
@@ -335,7 +407,7 @@
|
||||
|
||||
<div class="flex items-center justify-center justify-self-center">
|
||||
<DropdownMenuActions
|
||||
actions={rowActions(option, favorite, isHidden)}
|
||||
actions={rowActions(option, canLoad, favorite, isHidden)}
|
||||
align="end"
|
||||
triggerIcon={MoreHorizontal}
|
||||
triggerTooltip="Model actions"
|
||||
@@ -347,10 +419,13 @@
|
||||
|
||||
{#snippet repoRow(entry: ModelQuantGroup, indent = 0)}
|
||||
{@const isExpanded = !collapsedQuants.has(entry.key)}
|
||||
{@const providerCount = new Set(entry.quants.map((option) => option.backendId ?? '')).size}
|
||||
{@const groupLabel =
|
||||
entry.kind === 'variants'
|
||||
? `${entry.quants.length} variants`
|
||||
: `${entry.quants.length} quants available`}
|
||||
entry.kind === 'providers'
|
||||
? `${providerCount} provider${providerCount === 1 ? '' : 's'}`
|
||||
: entry.kind === 'variants'
|
||||
? `${entry.quants.length} variants`
|
||||
: `${entry.quants.length} quants available`}
|
||||
{@const anyLoaded = entry.quants.some(isLoadedOption)}
|
||||
|
||||
<!-- a repo row stands for its quants, so it reports what they agree on -->
|
||||
@@ -377,6 +452,7 @@
|
||||
<ModelId
|
||||
aliases={entry.base.aliases}
|
||||
class="min-w-0"
|
||||
drafts={draftsFor(entry.base)}
|
||||
hideCapabilities
|
||||
hideModalities
|
||||
hideQuantization
|
||||
@@ -417,8 +493,9 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet quantRow(option: ModelOption, indent = 0)}
|
||||
{#snippet quantRow(option: ModelOption, indent = 0, showProvider = false)}
|
||||
{@const favorite = isFavorite(option)}
|
||||
{@const canLoad = getBackendCapabilities(getBackend(option.backendId)).loadUnload}
|
||||
{@const isHidden = modelsStore.isHidden(option.id)}
|
||||
{@const quant = option.parsedId?.quantization ?? option.model}
|
||||
|
||||
@@ -435,7 +512,9 @@
|
||||
tabindex="0"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-3" style="padding-left: {indent}px">
|
||||
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
|
||||
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">
|
||||
{showProvider ? (getBackend(option.backendId)?.name ?? quant) : quant}
|
||||
</Badge>
|
||||
|
||||
<span class="truncate text-sm text-muted-foreground">{option.model}</span>
|
||||
</span>
|
||||
@@ -446,7 +525,7 @@
|
||||
|
||||
<div class="flex items-center justify-center justify-self-center">
|
||||
<DropdownMenuActions
|
||||
actions={rowActions(option, favorite, isHidden)}
|
||||
actions={rowActions(option, canLoad, favorite, isHidden)}
|
||||
align="end"
|
||||
triggerIcon={MoreHorizontal}
|
||||
triggerTooltip="Model actions"
|
||||
@@ -461,7 +540,7 @@
|
||||
|
||||
{#if !collapsedQuants.has(entry.key)}
|
||||
{#each entry.quants as quant (quant.id)}
|
||||
{@render quantRow(quant, indent + 24)}
|
||||
{@render quantRow(quant, indent + 24, entry.kind === 'providers')}
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -568,15 +647,25 @@
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<ModelsManagerFilters bind:capabilities bind:contextLimit bind:modalities />
|
||||
<ModelsManagerFilters
|
||||
bind:capabilities
|
||||
bind:contextLimit
|
||||
bind:draft
|
||||
bind:modalities
|
||||
bind:providers
|
||||
backends={backendsStore.enabled}
|
||||
{providerCounts}
|
||||
/>
|
||||
|
||||
{#if hasFilters}
|
||||
<Button
|
||||
class="gap-1.5 text-muted-foreground"
|
||||
onclick={() => {
|
||||
providers = [];
|
||||
contextLimit = 0;
|
||||
modalities = [];
|
||||
capabilities = [];
|
||||
draft = false;
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@@ -633,12 +722,17 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{@const backendState = group.backendId ? backendsModelsStore.get(group.backendId) : null}
|
||||
|
||||
<ModelsSection
|
||||
backendId={group.kind === 'provider' ? (group.backendId ?? undefined) : undefined}
|
||||
chevronClass="mr-7"
|
||||
count={group.items.length}
|
||||
defaultOpen={group.kind !== 'hidden'}
|
||||
icon={groupIcon}
|
||||
error={Boolean(backendState?.error)}
|
||||
icon={group.kind === 'provider' ? undefined : groupIcon}
|
||||
label={group.label}
|
||||
loading={Boolean(backendState?.loading)}
|
||||
persistKey={group.key}
|
||||
revealChevronOnHover
|
||||
sticky
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
|
||||
import { CollapsibleSection } from '$lib/components/app';
|
||||
import { BackendIcon } from '$lib/components/app/backends';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { getBackend } from '$lib/utils/api-base';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Section rows. */
|
||||
children: Snippet;
|
||||
/** Renders the backend's logo in the header. */
|
||||
backendId?: string;
|
||||
/** Extra classes for the chevron, to line it up with a row's own control. */
|
||||
chevronClass?: string;
|
||||
/** Number shown next to the label, omitted when undefined. */
|
||||
@@ -31,6 +35,7 @@
|
||||
}
|
||||
|
||||
let {
|
||||
backendId,
|
||||
chevronClass = '',
|
||||
children,
|
||||
count,
|
||||
@@ -81,6 +86,8 @@
|
||||
|
||||
{#if icon}
|
||||
{@render icon()}
|
||||
{:else if backendId}
|
||||
<BackendIcon backend={getBackend(backendId)} class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
<span class="truncate">{label}</span>
|
||||
|
||||
Reference in New Issue
Block a user