diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte index e5c09fcbb6..7e8f5714cb 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte @@ -79,29 +79,41 @@ // and an entry lands in the first group that claims it let entries = $derived(byRecency(groupModelQuants(visible))); let groups = $derived.by(() => { - // only llama-compat servers report a load state - const isLlamaCompat = (entry: ModelQuantGroup) => - getBackendCapabilities(getBackend(entry.base.backendId)).loadUnload; - const loaded = entries.filter( - (entry) => - isLlamaCompat(entry) && entry.quants.some((q) => modelsStore.isModelLoaded(q.model)) - ); - const claimed = new SvelteSet(loaded.map((entry) => entry.key)); - const favorites = entries.filter( - (entry) => - !claimed.has(entry.key) && - entry.quants.some((q) => modelsStore.favoriteModelIds.has(q.model)) + // 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. 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[] = []; + + for (const entry of entries) { + const remaining = entry.quants.filter((quant) => !isLoaded(quant)); + + for (const quant of entry.quants) { + if (!isLoaded(quant)) continue; + + loaded.push({ ...entry, base: quant, key: quant.id, quants: [quant] }); + } + + if (remaining.length > 0) rest.push({ ...entry, base: remaining[0], quants: remaining }); + } + + const claimed = new SvelteSet(); + const favorites = rest.filter((entry) => + entry.quants.some((q) => modelsStore.favoriteModelIds.has(q.model)) ); for (const entry of favorites) claimed.add(entry.key); - const hidden = entries.filter( + const hidden = rest.filter( (entry) => !claimed.has(entry.key) && entry.quants.some((q) => modelsStore.isHidden(q.id)) ); const hiddenKeys = new SvelteSet(hidden.map((entry) => entry.key)); const byBackend = new SvelteMap(); - for (const entry of entries) { + for (const entry of rest) { if (claimed.has(entry.key) || hiddenKeys.has(entry.key)) continue; const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID; diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte index 7be2523089..956acf20af 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte @@ -3,11 +3,10 @@ import { ChevronDown, Image, Mic, Server, Video } from '@lucide/svelte'; import { Logo } from '$lib/components/app'; import { BackendIcon } from '$lib/components/app/backends'; - import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Select from '$lib/components/ui/select'; import * as ToggleGroup from '$lib/components/ui/toggle-group'; - import { LOCAL_BACKEND_ID } from '$lib/constants'; + import { FILTER_TRIGGER_CLASS, LOCAL_BACKEND_ID } from '$lib/constants'; import type { Backend } from '$lib/types/backend'; interface Props { @@ -74,13 +73,17 @@ {#snippet child({ props })} - + {/snippet} @@ -113,7 +116,7 @@ type="single" value={String(contextLimit)} > - + Context: {contextLabel} @@ -126,9 +129,19 @@ - + {#each MODALITIES as modality (modality.key)} - + {/each} diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte index 26536bd5a7..e2ee57c580 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte @@ -127,20 +127,7 @@ groups.map((group) => { // a flat section lists its models straight, families or not const flat = FLAT_SECTIONS.has(group.kind) || !settingsStore.config.groupModelsByFamily; - // a loaded section lists the quants themselves: the repo a quant came from - // is not what is loaded, so folding them under it says nothing - const entries = - group.kind === 'loaded' - ? group.items.flatMap((entry) => - entry.quants.map((quant) => ({ - ...entry, - base: quant, - key: quant.id, - quants: [quant] - })) - ) - : group.items; - const items = sortEntries(entries); + const items = sortEntries(group.items); return { ...group, diff --git a/tools/ui/src/lib/components/ui/select/select-content.svelte b/tools/ui/src/lib/components/ui/select/select-content.svelte index f8792f8a9d..a8263819a1 100644 --- a/tools/ui/src/lib/components/ui/select/select-content.svelte +++ b/tools/ui/src/lib/components/ui/select/select-content.svelte @@ -88,7 +88,7 @@ {@render children?.()} diff --git a/tools/ui/src/lib/components/ui/select/select-trigger.svelte b/tools/ui/src/lib/components/ui/select/select-trigger.svelte index 483db7b74f..de3abcdf21 100644 --- a/tools/ui/src/lib/components/ui/select/select-trigger.svelte +++ b/tools/ui/src/lib/components/ui/select/select-trigger.svelte @@ -17,20 +17,20 @@ // Super small trigger: fits its selected value, for dense inline use. const xsClasses = - "group flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground"; + "group flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-border/30 bg-muted/60 px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground"; const baseClasses = $derived( variant === 'plain' ? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground" : size === 'xs' ? xsClasses - : "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground" + : "flex w-fit items-center justify-between gap-2 rounded-md border border-border/30 bg-muted/60 px-3 py-2 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground" ); const chevronClasses = $derived( variant === 'plain' || size === 'xs' ? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180' - : 'size-4 opacity-50' + : 'size-3.5 opacity-60 transition-transform group-data-[state=open]:-rotate-180' ); diff --git a/tools/ui/src/lib/constants/css-classes.constants.ts b/tools/ui/src/lib/constants/css-classes.constants.ts index 43315d7de7..c7009e8c06 100644 --- a/tools/ui/src/lib/constants/css-classes.constants.ts +++ b/tools/ui/src/lib/constants/css-classes.constants.ts @@ -29,6 +29,19 @@ export const CHAT_INPUT_FOCUS_SELECTOR = */ export const DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR = '[data-slot="dropdown-menu-content"] input'; +/** Filter controls above the model table: one box, one height, one fill. */ +export const FILTER_TRIGGER_CLASS = ` + h-8 + gap-1.5 + rounded-md + px-3 + text-sm + font-medium + transition-colors + hover:bg-muted/80 dark:hover:bg-muted + ${INPUT_CLASSES} +`; + /** Neutral model badge: params, quantization, tags. */ export const MODEL_BADGE_CLASS = 'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';