mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-03 19:37:29 -05:00
ui : give the model filters one box, and the loaded section its own rows
The filter controls filled differently between themes: the search box and the toggle group drew their own border, and the select filled only in dark. They now share the app's box, the select trigger wears the same chevron as its neighbours, and the select popover matches the dropdown menu around it. The loaded section also lists loaded quants only, since a repo was claimed whole when a single quant of it was loaded, and the rest of the repo now stays with its provider. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -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<string>();
|
||||
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<string, ModelQuantGroup[]>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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 @@
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} class="gap-1.5" size="sm" variant="outline">
|
||||
<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>
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
@@ -113,7 +116,7 @@
|
||||
type="single"
|
||||
value={String(contextLimit)}
|
||||
>
|
||||
<Select.Trigger class="gap-1.5 shadow-sm" size="sm">
|
||||
<Select.Trigger class={FILTER_TRIGGER_CLASS} size="sm">
|
||||
<span class="text-muted-foreground">Context:</span>
|
||||
|
||||
{contextLabel}
|
||||
@@ -126,9 +129,19 @@
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
<ToggleGroup.Root bind:value={modalities} type="multiple" variant="outline">
|
||||
<ToggleGroup.Root
|
||||
bind:value={modalities}
|
||||
class="bg-muted/60 dark:bg-muted/75"
|
||||
type="multiple"
|
||||
variant="outline"
|
||||
>
|
||||
{#each MODALITIES as modality (modality.key)}
|
||||
<ToggleGroup.Item aria-label={modality.label} title={modality.label} value={modality.key}>
|
||||
<ToggleGroup.Item
|
||||
aria-label={modality.label}
|
||||
class="bg-transparent! border-border/30! dark:border-border/20!"
|
||||
title={modality.label}
|
||||
value={modality.key}
|
||||
>
|
||||
<modality.icon class="h-3.5 w-3.5" />
|
||||
</ToggleGroup.Item>
|
||||
{/each}
|
||||
|
||||
+1
-14
@@ -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,
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
'relative z-[var(--layer-popover,1000000)] max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'relative z-[var(--layer-popover,1000000)] max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover dark:border-border/20 text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="select-content"
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
|
||||
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1.5'
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user