mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-02 19:07:25 -05:00
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
479 lines
13 KiB
Svelte
479 lines
13 KiB
Svelte
<script lang="ts">
|
|
import ModelsManagerModelRow from './ModelsManagerModelRow.svelte';
|
|
import ModelsManagerQuantRow from './ModelsManagerQuantRow.svelte';
|
|
import ModelsManagerRepoRow from './ModelsManagerRepoRow.svelte';
|
|
import ModelsManagerTableToolbar from './ModelsManagerTableToolbar.svelte';
|
|
import { type ModelRowDraftTarget } from './row-actions';
|
|
import {
|
|
isModelRunning,
|
|
modelContextLength,
|
|
type ModelOverride,
|
|
type ModelQuantGroup,
|
|
type ModelsTableGroup
|
|
} from './utils';
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Download,
|
|
EyeOff,
|
|
Heart,
|
|
Power
|
|
} from '@lucide/svelte';
|
|
import {
|
|
CollapsibleRegion,
|
|
GroupedList,
|
|
type GroupedListGroup,
|
|
GroupedListUnit,
|
|
Logo,
|
|
ModelAvatar,
|
|
ModelsSection
|
|
} from '$lib/components/app';
|
|
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
|
import ModelsSelectorDownloadItem from '$lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte';
|
|
import {
|
|
FAMILY_ROW_WINDOW,
|
|
type ModalityKey,
|
|
MODEL_ROW_GRID_CLASS,
|
|
MODEL_ROW_WINDOW,
|
|
ModelsTableGroupKind,
|
|
ModelsTableProviderKind,
|
|
ModelsTableSortKey
|
|
} from '$lib/constants';
|
|
import { KeyboardKey, ModelCapability, ModelDownloadConfirmAction } from '$lib/enums';
|
|
import { backendsModelsStore, modelsStore, settingsStore } from '$lib/stores';
|
|
import type { ModelOption } from '$lib/types/models';
|
|
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
|
|
import type { Snippet } from 'svelte';
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
|
|
interface Props {
|
|
/** 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;
|
|
filter?: string;
|
|
groups: ModelsTableGroup[];
|
|
isFavorite: (option: ModelOption) => boolean;
|
|
onSelect: (option: ModelOption) => void;
|
|
/** Modalities a model must support at least one of. */
|
|
modalities?: ModalityKey[];
|
|
/** Per-model load and inference overrides, keyed by backend-qualified id. */
|
|
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;
|
|
}
|
|
|
|
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(
|
|
providers.length > 0 ||
|
|
contextLimit > 0 ||
|
|
modalities.length > 0 ||
|
|
capabilities.length > 0 ||
|
|
draft
|
|
);
|
|
|
|
/** Model the configuration pane has open, when a row can be set as its draft. */
|
|
let draftTarget = $derived.by<ModelRowDraftTarget | null>(() => {
|
|
const selected = modelsStore.models.find((option) => option.id === selectedId);
|
|
|
|
return selected ? { id: selected.id, label: selected.name } : null;
|
|
});
|
|
|
|
/** In-flight and paused downloads, tracked by the status feed. */
|
|
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
|
|
|
|
/** Noun the show-more row counts in, per unit of the grouped list. */
|
|
const SHOW_MORE_NOUNS: Record<GroupedListUnit, string> = {
|
|
[GroupedListUnit.ENTRIES]: 'models',
|
|
[GroupedListUnit.GROUPS]: 'families'
|
|
};
|
|
let pendingCancel = $state('');
|
|
let cancelOpen = $state(false);
|
|
let pendingDelete = $state('');
|
|
let deleteOpen = $state(false);
|
|
/** Repos whose quants are folded away; the rest show them. */
|
|
const collapsedQuants = new SvelteSet<string>();
|
|
/** Sections that list their models straight, without folding them into families. */
|
|
const FLAT_SECTIONS = new Set<ModelsTableGroup['kind']>([
|
|
ModelsTableGroupKind.FAVORITES,
|
|
ModelsTableGroupKind.LOADED
|
|
]);
|
|
let sections = $derived(
|
|
groups.map((group) => {
|
|
// a flat section lists its models straight, families or not
|
|
const flat = FLAT_SECTIONS.has(group.kind) || !settingsStore.config.groupModelsByFamily;
|
|
const items = sortEntries(group.items);
|
|
|
|
return {
|
|
...group,
|
|
families: flat ? [] : groupModelFamilies(items, (entry) => entry.base.model),
|
|
flat,
|
|
items
|
|
};
|
|
})
|
|
);
|
|
|
|
function familyGroups(
|
|
group: (typeof sections)[number]
|
|
): GroupedListGroup<ModelFamilyGroup<ModelQuantGroup>, ModelQuantGroup>[] {
|
|
return group.families.map((family) => ({
|
|
entries: family.entries,
|
|
group: family,
|
|
key: `${group.key}::${family.key}`
|
|
}));
|
|
}
|
|
|
|
// cancel is confirmed once for the whole list, so one dialog serves every row
|
|
function requestCancel(repoWithTag: string): void {
|
|
pendingCancel = repoWithTag;
|
|
cancelOpen = true;
|
|
}
|
|
|
|
function requestDelete(option: ModelOption): void {
|
|
pendingDelete = option.model;
|
|
deleteOpen = true;
|
|
}
|
|
|
|
function toggleQuants(key: string): void {
|
|
if (collapsedQuants.has(key)) {
|
|
collapsedQuants.delete(key);
|
|
} else {
|
|
collapsedQuants.add(key);
|
|
}
|
|
}
|
|
|
|
/** Column the table is ordered by; unset keeps the manager's own order. */
|
|
let sortKey = $state<ModelsTableSortKey | null>(null);
|
|
let sortAsc = $state(true);
|
|
|
|
function compareEntries(left: ModelQuantGroup, right: ModelQuantGroup): number {
|
|
const a = left.base;
|
|
const b = right.base;
|
|
|
|
switch (sortKey) {
|
|
case ModelsTableSortKey.CONTEXT:
|
|
return modelContextLength(a) - modelContextLength(b);
|
|
case ModelsTableSortKey.NAME:
|
|
return a.model.localeCompare(b.model);
|
|
case ModelsTableSortKey.STATUS:
|
|
return Number(isModelRunning(b)) - Number(isModelRunning(a));
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function sortEntries(entries: ModelQuantGroup[]): ModelQuantGroup[] {
|
|
if (!sortKey) return entries;
|
|
|
|
const direction = sortAsc ? 1 : -1;
|
|
|
|
return [...entries].sort((a, b) => direction * compareEntries(a, b));
|
|
}
|
|
|
|
/** A click on a new column sorts lowest first, then highest first, then clears. */
|
|
function toggleSort(key: ModelsTableSortKey): void {
|
|
if (sortKey !== key) {
|
|
sortKey = key;
|
|
sortAsc = true;
|
|
|
|
return;
|
|
}
|
|
|
|
if (sortAsc) {
|
|
sortAsc = false;
|
|
|
|
return;
|
|
}
|
|
|
|
sortKey = null;
|
|
}
|
|
|
|
function sortTitle(key: ModelsTableSortKey, label: string): string {
|
|
const name = label.toLowerCase();
|
|
|
|
if (sortKey !== key) return `Sort by ${name}, lowest first`;
|
|
|
|
return sortAsc ? `Sort by ${name}, highest first` : `Stop sorting by ${name}`;
|
|
}
|
|
|
|
function handleFamilyKeydown(event: KeyboardEvent, toggle: () => void): void {
|
|
if (event.key === KeyboardKey.SPACE) event.preventDefault();
|
|
|
|
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) toggle();
|
|
}
|
|
</script>
|
|
|
|
{#snippet sortHeader(key: ModelsTableSortKey, label: string)}
|
|
<button
|
|
class="inline-flex cursor-pointer items-center gap-1 uppercase transition hover:text-foreground focus:outline-none"
|
|
onclick={() => toggleSort(key)}
|
|
title={sortTitle(key, label)}
|
|
type="button"
|
|
>
|
|
{label}
|
|
|
|
{#if sortKey === key}
|
|
{#if sortAsc}
|
|
<ArrowUp class="h-3 w-3" />
|
|
{:else}
|
|
<ArrowDown class="h-3 w-3" />
|
|
{/if}
|
|
{/if}
|
|
</button>
|
|
{/snippet}
|
|
|
|
{#snippet entryTree(entry: ModelQuantGroup, indent = 0)}
|
|
{#if entry.quants.length > 1}
|
|
<ModelsManagerRepoRow
|
|
{entry}
|
|
expanded={!collapsedQuants.has(entry.key)}
|
|
{indent}
|
|
onToggle={() => toggleQuants(entry.key)}
|
|
{overrides}
|
|
/>
|
|
|
|
<CollapsibleRegion open={!collapsedQuants.has(entry.key)}>
|
|
{#each entry.quants as quant (quant.id)}
|
|
<ModelsManagerQuantRow
|
|
{draftTarget}
|
|
indent={indent + 24}
|
|
{isFavorite}
|
|
onDelete={requestDelete}
|
|
{onSelect}
|
|
{onUseAsDraft}
|
|
option={quant}
|
|
{overrides}
|
|
selected={selectedId === quant.id}
|
|
showProvider={entry.kind === 'providers'}
|
|
/>
|
|
{/each}
|
|
</CollapsibleRegion>
|
|
{:else}
|
|
<ModelsManagerModelRow
|
|
{draftTarget}
|
|
{indent}
|
|
{isFavorite}
|
|
onDelete={requestDelete}
|
|
{onSelect}
|
|
{onUseAsDraft}
|
|
option={entry.base}
|
|
{overrides}
|
|
selected={selectedId === entry.base.id}
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet familyRow({
|
|
expanded,
|
|
group: family,
|
|
toggle
|
|
}: {
|
|
expanded: boolean;
|
|
group: ModelFamilyGroup<ModelQuantGroup>;
|
|
toggle: () => void;
|
|
})}
|
|
{@const countLabel = `${family.entries.length} model${family.entries.length === 1 ? '' : 's'}`}
|
|
|
|
<div
|
|
class="{MODEL_ROW_GRID_CLASS} group cursor-pointer rounded-md px-2 py-1 transition hover:bg-muted/40"
|
|
onclick={toggle}
|
|
onkeydown={(event) => handleFamilyKeydown(event, toggle)}
|
|
role="button"
|
|
tabindex="0"
|
|
>
|
|
<span class="flex min-w-0 items-center gap-3">
|
|
<ModelAvatar
|
|
option={family.entries[0].base}
|
|
showBaseModelAvatar
|
|
showQuantBadge={false}
|
|
size="size-6"
|
|
/>
|
|
|
|
<span class="truncate text-sm font-medium">{family.label}</span>
|
|
|
|
<span class="text-sm text-muted-foreground">{countLabel}</span>
|
|
</span>
|
|
|
|
<span></span>
|
|
|
|
<span></span>
|
|
|
|
<span
|
|
class="flex justify-center {expanded
|
|
? 'opacity-0 group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100'
|
|
: ''}"
|
|
>
|
|
{#if expanded}
|
|
<ChevronUp class="h-3.5 w-3.5 text-muted-foreground" />
|
|
{:else}
|
|
<ChevronDown class="h-3.5 w-3.5 text-muted-foreground" />
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet listItem({ depth, entry }: { depth: number; entry: ModelQuantGroup })}
|
|
{@render entryTree(entry, depth > 0 ? 16 : 0)}
|
|
{/snippet}
|
|
|
|
{#snippet showMore({
|
|
count,
|
|
onMore,
|
|
unit
|
|
}: {
|
|
count: number;
|
|
onMore: () => void;
|
|
unit: GroupedListUnit;
|
|
})}
|
|
<div class="px-2">
|
|
<button
|
|
class="w-full cursor-pointer rounded-md px-2 py-2 text-left text-xs text-muted-foreground transition hover:bg-muted/40"
|
|
onclick={onMore}
|
|
type="button"
|
|
>
|
|
Show {count} more {SHOW_MORE_NOUNS[unit]}
|
|
</button>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<DialogConfirmDownload
|
|
action={ModelDownloadConfirmAction.CANCEL}
|
|
onClose={() => (cancelOpen = false)}
|
|
open={cancelOpen}
|
|
repoWithTag={pendingCancel}
|
|
/>
|
|
|
|
<DialogConfirmDownload
|
|
action={ModelDownloadConfirmAction.DELETE}
|
|
onClose={() => (deleteOpen = false)}
|
|
open={deleteOpen}
|
|
repoWithTag={pendingDelete}
|
|
/>
|
|
|
|
<div class="flex h-full min-h-0 flex-col">
|
|
<ModelsManagerTableToolbar
|
|
bind:capabilities
|
|
bind:contextLimit
|
|
bind:draft
|
|
bind:filter
|
|
bind:modalities
|
|
bind:providers
|
|
{providerCounts}
|
|
{toolbarEnd}
|
|
/>
|
|
|
|
<div
|
|
class="{MODEL_ROW_GRID_CLASS} shrink-0 border-y border-border/40 px-2 py-2 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
|
>
|
|
<span>{@render sortHeader(ModelsTableSortKey.NAME, 'Model')}</span>
|
|
|
|
<span class="text-right whitespace-nowrap">
|
|
{@render sortHeader(ModelsTableSortKey.CONTEXT, 'Context')}
|
|
</span>
|
|
|
|
<span class="justify-self-center">
|
|
{@render sortHeader(ModelsTableSortKey.STATUS, 'Status')}
|
|
</span>
|
|
|
|
<span class="text-center">Actions</span>
|
|
</div>
|
|
|
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
{#if downloadEntries.length > 0}
|
|
<ModelsSection count={downloadEntries.length} label="Download in progress" sticky>
|
|
{#snippet icon()}
|
|
<Download class="h-3.5 w-3.5 shrink-0" />
|
|
{/snippet}
|
|
|
|
{#each downloadEntries as entry (entry.repoWithTag)}
|
|
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} />
|
|
{/each}
|
|
</ModelsSection>
|
|
{/if}
|
|
|
|
{#each sections as group (group.key)}
|
|
{#if group.items.length > 0}
|
|
{#snippet groupIcon()}
|
|
{#if group.kind === ModelsTableGroupKind.FAVORITES}
|
|
<Heart class="h-3.5 w-3.5 shrink-0" />
|
|
{:else if group.kind === ModelsTableGroupKind.LOADED}
|
|
<Power class="h-3.5 w-3.5 shrink-0" />
|
|
{:else if group.kind === ModelsTableGroupKind.HIDDEN}
|
|
<EyeOff class="h-3.5 w-3.5 shrink-0" />
|
|
{:else if group.kind === ModelsTableGroupKind.LOCAL}
|
|
<Logo class="shrink-0" style="--size: 0.875rem" />
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{@const backendState = group.backendId ? backendsModelsStore.get(group.backendId) : null}
|
|
|
|
<ModelsSection
|
|
backendId={group.kind === ModelsTableProviderKind.PROVIDER
|
|
? (group.backendId ?? undefined)
|
|
: undefined}
|
|
chevronClass="mr-7"
|
|
count={group.items.length}
|
|
defaultOpen={group.kind !== ModelsTableGroupKind.HIDDEN}
|
|
error={Boolean(backendState?.error)}
|
|
icon={group.kind === ModelsTableProviderKind.PROVIDER ? undefined : groupIcon}
|
|
label={group.label}
|
|
loading={Boolean(backendState?.loading)}
|
|
persistKey={group.key}
|
|
revealChevronOnHover
|
|
sticky
|
|
stickyClass="sticky z-10 bg-muted/90 backdrop-blur-lg"
|
|
>
|
|
<GroupedList
|
|
group={familyRow}
|
|
groupStateKey={group.key}
|
|
groupWindow={FAMILY_ROW_WINDOW}
|
|
groups={group.flat ? null : familyGroups(group)}
|
|
item={listItem}
|
|
items={group.flat ? group.items : []}
|
|
keyOf={(entry) => entry.key}
|
|
more={showMore}
|
|
sectionWindow={MODEL_ROW_WINDOW}
|
|
stickyClass="bg-muted/30 backdrop-blur-lg"
|
|
stickyStyle="top: calc(2.25rem - 1px)"
|
|
weightOf={(entry) => entry.quants.length}
|
|
/>
|
|
</ModelsSection>
|
|
{/if}
|
|
{/each}
|
|
|
|
{#if isEmpty}
|
|
<p class="px-4 py-10 text-center text-sm text-muted-foreground">
|
|
{hasFilters ? 'No models match these filters.' : 'No models found.'}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|