ui : add the shared model row and list primitives

The manager and later surfaces share one set of row parts: the collapsible
section and grouped list containers, the avatar with its org and quant
badges, the capability and context columns with their Hub fallbacks, the
load control, the shared row action set, and the download row with its
progress bar. The toggle and toggle-group controls and the tertiary button
variant arrive with them.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 16:48:49 +02:00
committed by GitHub
parent 31220d5c7f
commit bd03fbcb99
34 changed files with 1587 additions and 65 deletions
+4 -4
View File
@@ -12,7 +12,7 @@
"@eslint/compat": "1.4.1",
"@eslint/js": "9.39.2",
"@internationalized/date": "3.12.2",
"@lucide/svelte": "1.25.0",
"@lucide/svelte": "1.46.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.5.6",
@@ -3225,9 +3225,9 @@
}
},
"node_modules/@lucide/svelte": {
"version": "1.25.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
"integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
"version": "1.46.0",
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.46.0.tgz",
"integrity": "sha512-Ru8jDjoCVLJ7J7pPRUdY+odSBV2BI6rj68JTmT+ENq/ZwYOPGPAiKVvKz+XLyAoK4HAwlRTzZHpsMvrHfsetqg==",
"dev": true,
"license": "ISC",
"peerDependencies": {
+1 -1
View File
@@ -32,7 +32,7 @@
"@eslint/compat": "1.4.1",
"@eslint/js": "9.39.2",
"@internationalized/date": "3.12.2",
"@lucide/svelte": "1.25.0",
"@lucide/svelte": "1.46.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "10.5.6",
@@ -11,6 +11,8 @@
onClose?: () => void;
onKeyDown?: (event: KeyboardEvent) => void;
class?: string;
/** Input height, matching the small size of the other controls. */
size?: 'sm' | 'default';
id?: string;
ref?: HTMLInputElement | null;
isCancelAlwaysVisible?: boolean;
@@ -26,6 +28,7 @@
onKeyDown,
placeholder = 'Search...',
ref = $bindable(null),
size = 'default',
value = $bindable('')
}: Props = $props();
@@ -58,7 +61,7 @@
bind:ref
bind:value
{autofocus}
class="pl-9 {showClearButton ? 'pr-9' : ''}"
class="pl-9 {showClearButton ? 'pr-9' : ''} {size === 'sm' ? 'h-8' : ''}"
{id}
oninput={handleInput}
onkeydown={onKeyDown}
@@ -0,0 +1,87 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
open: boolean;
}
let { children, open }: Props = $props();
// rows stay mounted through the collapse transition so it can play
const EXPAND_TRANSITION_MS = 200;
// the initial state only: the effect below owns it afterwards
// svelte-ignore state_referenced_locally
let contentMounted = $state(open);
$effect(() => {
if (open) {
contentMounted = true;
return;
}
const timer = setTimeout(() => (contentMounted = false), EXPAND_TRANSITION_MS);
return () => clearTimeout(timer);
});
</script>
<div class="collapsible-region" data-expanded={open}>
{#if contentMounted}
<div class="collapsible-region-content">
{@render children()}
</div>
{/if}
</div>
<style>
/*
* The region animates between height 0 and height auto. `interpolate-size`
* lets the auto keyword take part in the interpolation, so the content needs
* no measured height to stay in sync with. Browsers without it fall back to
* an interpolating grid row, which snaps only in the oldest engines.
*/
.collapsible-region {
height: 0;
/* clip, not hidden: hidden would make the region a scrollport, and the
sticky rows inside it would then never leave their opening position */
overflow: clip;
visibility: hidden;
interpolate-size: allow-keywords;
transition:
height 200ms cubic-bezier(0.23, 1, 0.32, 1),
visibility 200ms;
}
.collapsible-region[data-expanded='true'] {
height: auto;
visibility: visible;
}
@supports not (interpolate-size: allow-keywords) {
.collapsible-region {
display: grid;
/* minmax(0, 1fr) pins the column to the region width: a plain auto
column would size to the content and push wide rows out of the list */
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 0fr;
/* the row owns the height here: leaving height: 0 in place would snap
the region shut before the row could interpolate */
height: auto;
transition:
grid-template-rows 200ms cubic-bezier(0.23, 1, 0.32, 1),
visibility 200ms;
}
.collapsible-region[data-expanded='true'] {
grid-template-rows: 1fr;
}
.collapsible-region-content {
min-height: 0;
min-width: 0;
overflow: clip;
}
}
</style>
@@ -0,0 +1,116 @@
<script lang="ts">
import CollapsibleRegion from './CollapsibleRegion.svelte';
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import type { Snippet } from 'svelte';
interface Props {
/** Collapsible content. */
children: Snippet;
/** Trigger content; this component appends the chevron. */
trigger: Snippet;
/** Render the trigger as a dropdown menu item, so menu keyboard navigation reaches it. */
inMenu?: boolean;
/** Start expanded. The section owns the state, so a parent rebuild cannot
* snap it back open. Bind `open` instead to control it from outside. */
defaultOpen?: boolean;
/** Controlled open state, for a caller that binds it. */
open?: boolean;
/** Called on every toggle, for a caller that persists the state. */
ontoggle?: ((open: boolean) => void) | null;
/** Extra classes for the chevron, to line it up with a row's own control. */
chevronClass?: string;
/** Hide the chevron while expanded, until the trigger is hovered. */
revealChevronOnHover?: boolean;
/** Where the trigger sits relative to the content. */
triggerPosition?: 'bottom' | 'top';
triggerClass?: string;
triggerStyle?: string;
}
let {
chevronClass = '',
children,
defaultOpen = true,
inMenu = false,
ontoggle = null,
open = $bindable(defaultOpen),
revealChevronOnHover = false,
trigger,
triggerClass = '',
triggerPosition = 'top',
triggerStyle = ''
}: Props = $props();
function toggle(): void {
open = !open;
ontoggle?.(open);
}
</script>
{#snippet chevron()}
<span
class="ml-auto shrink-0 text-muted-foreground {chevronClass} {open && revealChevronOnHover
? 'opacity-0 group-hover:opacity-100'
: ''}"
>
{#if open}
<ChevronUp class={ICON_CLASS_DEFAULT} />
{:else}
<ChevronDown class={ICON_CLASS_DEFAULT} />
{/if}
</span>
{/snippet}
{#snippet triggerButton()}
{#if inMenu}
<!-- A menu item (for keyboard nav) wrapping the trigger via `child`;
closeOnSelect keeps the menu open while the section toggles. -->
<DropdownMenu.Item
class="group w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left text-sm"
closeOnSelect={false}
>
{#snippet child({ props })}
<!-- No `class` here: a static attribute would override the spread props.class. -->
<button {...props} aria-expanded={open} onclick={toggle} style={triggerStyle} type="button">
{@render trigger()}
{@render chevron()}
</button>
{/snippet}
</DropdownMenu.Item>
{:else}
<button
aria-expanded={open}
class="group {triggerClass}"
onclick={toggle}
style={triggerStyle}
type="button"
>
{@render trigger()}
{@render chevron()}
</button>
{/if}
{/snippet}
{#snippet region()}
<!-- Custom expand region instead of bits-ui Collapsible (whose conditional
rendering kills the transition). -->
{#snippet body()}
{@render children()}
{/snippet}
<CollapsibleRegion {open}>{@render body()}</CollapsibleRegion>
{/snippet}
{#if triggerPosition === 'top'}
{@render triggerButton()}
{@render region()}
{:else}
{@render region()}
{@render triggerButton()}
{/if}
@@ -0,0 +1,167 @@
<script lang="ts" module>
/** One group of a grouped list, rendered under its own heading. */
export interface GroupedListGroup<G, E> {
entries: E[];
group: G;
key: string;
}
</script>
<script generics="G, E" lang="ts">
import CollapsibleRegion from './CollapsibleRegion.svelte';
import { modelsStore } from '$lib/stores';
import type { Snippet } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface Props {
/** Heading of one group. Omit it for a list whose rows need no heading. */
group?: Snippet<
[{ depth: number; expanded: boolean; group: G; key: string; toggle: () => void }]
>;
/** Groups to render. Omit to render `items` as a flat list. */
groups?: GroupedListGroup<G, E>[] | null;
/** Entries of one group shown before its show more row. 0 shows every entry. */
groupWindow?: number;
/** Namespace of the persisted open state, e.g. a section key. Omit to keep it in memory. */
groupStateKey?: string;
/** One row. Depth is 1 under a group, 0 in a flat list. */
item: Snippet<[{ depth: number; entry: E }]>;
/** Identity of a row, used for the keyed each. */
keyOf: (entry: E) => string;
/** The show more row. Without it the rest of a window stays hidden. */
more?: Snippet<[{ count: number; onMore: () => void; unit: 'entries' | 'families' }]>;
/** Flat entries, rendered when the list has no groups. */
items?: E[];
/** Entries of the list shown before its show more row. 0 shows every entry. */
sectionWindow?: number;
/** Sticky class of a group heading, so a surface can shade it differently. */
stickyClass?: string;
/** Sticky offset of a group heading, e.g. `top: 2.25rem`. Empty keeps it scrolling. */
stickyStyle?: string;
/** Weight of one entry against a window, e.g. the rows it renders. */
weightOf?: (entry: E) => number;
}
let {
group,
groups = null,
groupStateKey,
groupWindow = 0,
item,
items = [],
keyOf,
more,
sectionWindow = 0,
stickyClass = 'bg-popover',
stickyStyle = '',
weightOf
}: Props = $props();
// groups start open; this tracks the ones the user folded away, and a list with
// a state key opens with the ones the user had folded away before the reload
// the initial namespace only
// svelte-ignore state_referenced_locally
const collapsed = new SvelteSet<string>(
groupStateKey ? modelsStore.collapsedGroupsUnder(groupStateKey) : []
);
// windows grow one step at a time, per group and per list
const groupSteps = new SvelteMap<string, number>();
let sectionSteps = $state(0);
function toggleGroup(key: string): void {
if (collapsed.has(key)) {
collapsed.delete(key);
} else {
collapsed.add(key);
}
if (groupStateKey) modelsStore.setGroupOpen(`${groupStateKey}-${key}`, !collapsed.has(key));
}
function growGroup(key: string): void {
groupSteps.set(key, (groupSteps.get(key) ?? 0) + 1);
}
let groupCap = $derived((key: string) =>
groupWindow > 0 ? groupWindow * (1 + (groupSteps.get(key) ?? 0)) : Infinity
);
let weight = $derived(weightOf ?? (() => 1));
/** Groups with their entries cut to the window, and what each cut leaves behind. */
let windowed = $derived.by((): Array<{ group: G; hidden: number; key: string; rows: E[] }> => {
const shown: Array<{ group: G; hidden: number; key: string; rows: E[] }> = [];
const cap = sectionWindow > 0 ? sectionWindow + sectionSteps : Infinity;
let used = 0;
for (const entry of groups ?? []) {
if (used >= cap) break;
const rows = entry.entries.slice(0, groupCap(entry.key));
shown.push({
group: entry.group,
hidden: entry.entries.length - rows.length,
key: entry.key,
rows
});
used += rows.reduce((sum, row) => sum + weight(row), 0);
}
return shown;
});
let flatRows = $derived(sectionWindow > 0 ? items.slice(0, sectionWindow + sectionSteps) : items);
</script>
{#if groups}
{#each windowed as entry (entry.key)}
{@const expanded = !collapsed.has(entry.key)}
{#if group}
<div class="sticky z-10 {stickyClass}" style={stickyStyle}>
{@render group({
depth: 0,
expanded,
group: entry.group,
key: entry.key,
toggle: () => toggleGroup(entry.key)
})}
</div>
{/if}
<CollapsibleRegion open={expanded}>
{#each entry.rows as row (keyOf(row))}
{@render item({ depth: 1, entry: row })}
{/each}
{#if entry.hidden > 0 && more}
{@render more({
count: Math.min(groupWindow, entry.hidden),
onMore: () => growGroup(entry.key),
unit: 'entries'
})}
{/if}
</CollapsibleRegion>
{/each}
{#if windowed.length < (groups?.length ?? 0) && more}
{@render more({
count: Math.min(sectionWindow, (groups?.length ?? 0) - windowed.length),
onMore: () => (sectionSteps += 1),
unit: 'families'
})}
{/if}
{:else}
{#each flatRows as row (keyOf(row))}
{@render item({ depth: 0, entry: row })}
{/each}
{#if flatRows.length < items.length && more}
{@render more({
count: Math.min(sectionWindow, items.length - flatRows.length),
onMore: () => (sectionSteps += 1),
unit: 'entries'
})}
{/if}
{/if}
@@ -59,3 +59,12 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
* `class` and `style` props and inherits color via `currentColor`.
*/
export { default as Logo } from './Logo.svelte';
/**
* **CollapsibleSection** - Section with a trigger that expands in place
*
* The trigger can sit above or below the content, and the chevron can stay out
* of sight while the section is open. Collapsed content unmounts after the
* transition, so hidden rows never reach menu keyboard navigation.
*/
export { default as CollapsibleSection } from './CollapsibleSection.svelte';
export { default as GroupedList, type GroupedListGroup } from './GroupedList.svelte';
@@ -0,0 +1,111 @@
<script lang="ts">
import ModelsDiscoverAvatar from './discover/ModelsDiscoverAvatar.svelte';
import { HF_BASE_MODEL_TAG_REGEX } from '$lib/constants';
import { HuggingFaceService, ModelsService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { orgOf } from '$lib/utils';
import type { Snippet } from 'svelte';
interface Props {
class?: string;
/** Rendered when the model has neither an org avatar nor a provider mark. */
fallback?: Snippet;
option: ModelOption;
quantPositionClass?: string;
quantSize?: string;
/** Show the base model's org as the main image and the repo (quantizer) org as the
* corner badge. Resolving the base org costs one Hugging Face request per repo, so
* it waits until the row is near the viewport. */
showBaseModelAvatar?: boolean;
/** Show the repo's own org as the main image, skipping the base model.
* Used inside a heading that already carries the base org. */
showRepoOrgAvatar?: boolean;
/** Keep the quantizer badge off, e.g. when a row stands for a whole family. */
showQuantBadge?: boolean;
size?: string;
}
let {
class: className = '',
fallback,
option,
quantPositionClass = '-bottom-1 -right-1',
quantSize = 'h-3 w-3',
showBaseModelAvatar = false,
showQuantBadge = true,
showRepoOrgAvatar = false,
size = 'size-5'
}: Props = $props();
let parsedId = $derived(ModelsService.parseModelId(option.model));
let orgName = $derived(parsedId.orgName);
let tagBaseModel = $derived(
(option.tags ?? [])
.find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
?.match(HF_BASE_MODEL_TAG_REGEX)?.[1] ?? null
);
let fetchedBaseModelOrg = $state<string | null>(null);
let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
let avatarEl = $state<HTMLElement | null>(null);
let isNearViewport = $state(false);
// Long lists mount hundreds of avatars at once; resolving every base model up
// front means one request per row, so wait until a row is actually near the viewport.
$effect(() => {
if (isNearViewport || !avatarEl) 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(avatarEl);
return () => observer.disconnect();
});
$effect(() => {
fetchedBaseModelOrg = null;
if (!isNearViewport || !showBaseModelAvatar || !orgName || tagBaseModel) return;
let cancelled = false;
void HuggingFaceService.getBaseModel(option.model)
.then((base) => {
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
})
// best-effort lookup: offline or unknown repos keep the repo org
.catch(() => {});
return () => {
cancelled = true;
};
});
</script>
{#if orgName}
<span bind:this={avatarEl} class={['inline-flex shrink-0', className]}>
<ModelsDiscoverAvatar
class="mt-0"
org={showRepoOrgAvatar ? orgName : (baseModelOrg ?? orgName)}
quantOrg={showBaseModelAvatar && showQuantBadge && !showRepoOrgAvatar ? orgName : undefined}
{quantPositionClass}
{quantSize}
{size}
/>
</span>
{:else}
{@render fallback?.()}
{/if}
@@ -1,8 +1,8 @@
<script lang="ts">
import ModelId from './ModelId.svelte';
import { Package } from '@lucide/svelte';
import { ActionIconCopyToClipboard, BadgeInfo } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { MODEL_ICON } from '$lib/constants';
import { modelsStore, serverStore } from '$lib/stores';
interface Props {
@@ -29,7 +29,7 @@
{#snippet badgeContent(triggerProps?: Record<string, unknown>)}
<BadgeInfo {...triggerProps ?? {}} class={className} {onclick}>
{#snippet icon()}
<Package class="h-3 w-3" />
<MODEL_ICON class="h-3 w-3" />
{/snippet}
{#if model}
@@ -0,0 +1,81 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import { ModelCapability } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
interface Props {
class?: string;
option: ModelOption;
}
let { class: className = '', option }: Props = $props();
// a listing that declares its capabilities (Ollama-compatible backends) needs no lookup
let declared = $derived(option.capabilities);
let el = $state<HTMLElement | null>(null);
let isNearViewport = $state(false);
let template = $state('');
$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(() => {
template = '';
if (!isNearViewport) return;
// the listing says nothing about tools and reasoning; only the model's chat
// template does, and the Hub carries that template for a local GGUF whether
// it is loaded or not
const repo = option.model.split(':')[0] ?? '';
if (!repo.includes('/')) return;
let cancelled = false;
void HuggingFaceService.getDetails(repo)
.then((details) => {
if (!cancelled) template = details?.gguf?.chat_template ?? '';
})
// best-effort: offline or a repo we cannot read leaves the icons off
.catch(() => {});
return () => {
cancelled = true;
};
});
let supportsThinking = $derived(
declared.includes(ModelCapability.REASONING) || detectThinkingSupport(template)
);
let supportsToolUse = $derived(
declared.includes(ModelCapability.TOOL_USE) || detectToolUseSupport(template)
);
</script>
<span bind:this={el} class={className}>
<ModelCapabilityIcons modalities={option.modalities} {supportsThinking} {supportsToolUse} />
</span>
@@ -52,7 +52,7 @@
{/if}
{#if hasModalityIcons && !hideModalities}
<span class="inline-flex items-center text-muted-foreground">
<span class="inline-flex items-center {gapClass} text-muted-foreground">
{#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
@@ -0,0 +1,78 @@
<script lang="ts">
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { formatParameters } from '$lib/utils/formatters';
interface Props {
class?: string;
/** Context the model is set to run with; paired with the supported one. */
configured?: number | null;
option: ModelOption;
}
let { class: className = '', configured = null, 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) 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);
// `configured / supported`, or whichever of the two is known
let values = $derived(
[configured, context].filter((value): value is number => typeof value === 'number')
);
</script>
<span bind:this={el} class={['text-sm text-muted-foreground', className]}>
{values.length ? `${values.map((value) => formatParameters(value)).join(' / ')} tokens` : '—'}
</span>
@@ -4,6 +4,7 @@
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { type ModelSidecar } from '$lib/constants';
import { MODEL_BADGE_CLASS, MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
@@ -67,12 +68,10 @@
...rest
}: Props = $props();
const badgeClass =
'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';
const badgeClass = MODEL_BADGE_CLASS;
const tagBadgeClass =
'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 text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
const variantBadgeClass =
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
const variantBadgeClass = MODEL_VARIANT_BADGE_CLASS;
/** Alias badges beyond this many collapse into a single `+x more` badge. */
const MAX_ALIAS_BADGES = 2;
@@ -114,7 +113,11 @@
{/if}
{#if hasBadges}
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
<!-- the badges do not shrink, so the group has to clip them: without this
they paint over the row actions when the row is narrow -->
<span
class="inline-flex min-w-0 items-center gap-1 overflow-hidden {wrap ? 'flex-wrap' : ''}"
>
{#if parsed.sidecar}
<span class={variantBadgeClass} title={`${parsed.sidecar.toUpperCase()} draft model`}>
{parsed.sidecar}
@@ -127,18 +130,18 @@
</span>
{/if}
{#each uniqueDraftSidecars as sidecar (sidecar)}
<span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
{sidecar}
</span>
{/each}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#each uniqueDraftSidecars as sidecar (sidecar)}
<span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
{sidecar}
</span>
{/each}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class="{badgeClass} max-w-32 truncate" title={parsed.modelName ?? modelId}>
@@ -168,10 +171,12 @@
{/if}
{/snippet}
<!-- overflow-hidden bounds the whole id: the badges, tags and capability icons do
not shrink, so without it they spill past the row and over its actions -->
<span
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
? 'flex-col items-start'
: ''} {className}"
class="flex min-w-0 items-center gap-1.5 overflow-hidden {wrap
? 'flex-wrap'
: ''} {iconsOnNewLine ? 'flex-col items-start' : ''} {className}"
{title}
{...rest}
>
@@ -0,0 +1,108 @@
<script lang="ts">
import { CircleAlert, Eject, Loader2, Power, RotateCw } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
class?: string;
isFailed?: boolean;
isLoaded: boolean;
isLoading?: boolean;
isSleeping?: boolean;
option: ModelOption;
/** Table rows keep the load action visible, selector rows reveal it on hover. */
revealOnHover?: boolean;
/** Renders the state alone, for a caller that moves load and unload elsewhere. */
showAction?: boolean;
}
let {
class: className = '',
isFailed = false,
isLoaded,
isLoading = false,
isSleeping = false,
option,
revealOnHover = true,
showAction = true
}: Props = $props();
</script>
<div class={['flex w-5 shrink-0 items-center justify-center', className]}>
{#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 -->
{#if showAction && revealOnHover}
{#if isFailed}
<CircleAlert
class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
/>
{:else}
<span
class="h-2 w-2 rounded-full group-hover:hidden [@media(pointer:coarse)]:hidden {isSleeping
? 'bg-orange-400'
: isLoaded
? 'bg-green-500'
: 'bg-muted-foreground/50'}"
></span>
{/if}
{:else}
{#if isFailed}
<CircleAlert class="h-3.5 w-3.5 text-red-500" />
{:else}
<span
class="h-2 w-2 rounded-full {isSleeping
? 'bg-orange-400'
: isLoaded
? 'bg-green-500'
: 'bg-muted-foreground/50'}"
></span>
{/if}
{/if}
<!-- the wrapper has to pick one display value: flex and hidden together
leave the winner to stylesheet order -->
<div
class={!showAction
? 'hidden'
: revealOnHover
? 'hidden group-hover:flex [@media(pointer:coarse)]:flex'
: 'flex'}
>
{#if isFailed}
<ActionIcon
class="h-5 w-5 text-red-500 hover:text-foreground"
icon={RotateCw}
iconSize="h-4 w-4"
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
tooltip="Retry loading model"
tooltipAsTitle
/>
{:else if isLoaded || isSleeping}
<ActionIcon
class="h-5 w-5 hover:text-foreground"
icon={Eject}
iconSize="h-4 w-4"
onclick={() => modelsStore.status.unload(option.model)}
stopPropagationOnClick
tooltip="Unload model"
tooltipAsTitle
/>
{:else}
<ActionIcon
class="h-5 w-5 hover:text-foreground"
icon={Power}
iconSize="h-4 w-4"
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
tooltip="Load model"
tooltipAsTitle
/>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import { Heart, HeartOff, Info } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import { modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
isFav: boolean;
option: ModelOption;
/** Selector rows reveal the actions on hover, table rows keep them visible. */
revealOnHover?: boolean;
}
let { isFav, option, revealOnHover = true }: Props = $props();
</script>
<div
class={[
'flex items-center justify-center gap-1',
revealOnHover
? 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100'
: ''
]}
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
role="presentation"
>
<ActionIcon
class="h-5 w-5 hover:text-foreground"
icon={Info}
iconSize="h-4 w-4"
onclick={() => uiStore.openModelsManager(option.id)}
tooltip="Manage model"
tooltipAsTitle
/>
{#if isFav}
<span class="flex h-5 w-5 items-center justify-center">
<span class="flex group-hover:hidden [@media(pointer:coarse)]:hidden">
<ActionIcon
class="h-5 w-5 text-rose-500 hover:text-foreground"
icon={Heart}
iconSize="h-4 w-4"
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip="Remove from favorites"
tooltipAsTitle
/>
</span>
<span class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-5 w-5 hover:text-foreground"
icon={HeartOff}
iconSize="h-4 w-4"
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip="Remove from favorites"
tooltipAsTitle
/>
</span>
</span>
{:else}
<ActionIcon
class="h-5 w-5 hover:text-foreground"
icon={Heart}
iconSize="h-4 w-4"
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip="Add to favorites"
tooltipAsTitle
/>
{/if}
</div>
@@ -0,0 +1,100 @@
<script lang="ts">
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
import { CollapsibleSection } from '$lib/components/app';
import { modelsStore } from '$lib/stores';
import type { Snippet } from 'svelte';
interface Props {
/** Section rows. */
children: Snippet;
/** 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. */
count?: number;
error?: boolean;
/** Overrides the backend logo, used for sections without a backend. */
icon?: Snippet;
label: string;
loading?: boolean;
/** Renders the back control, for a drilled-in provider. */
onBack?: () => void;
/** Persists the open state under this id, e.g. a section key. */
persistKey?: string;
/** Start expanded; the manager collapses its hidden block. */
defaultOpen?: boolean;
revealChevronOnHover?: boolean;
sectionHeaderClass?: string;
/** Sticky class of the header, so a surface can shade it differently. */
stickyClass?: string;
/** Sticks the header to the top of the scrollport. */
sticky?: boolean;
}
let {
chevronClass = '',
children,
count,
defaultOpen = true,
error = false,
icon,
label,
loading = false,
onBack,
persistKey,
revealChevronOnHover = false,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground select-none',
sticky = false,
stickyClass = 'sticky z-10 bg-popover'
}: Props = $props();
let triggerClass = $derived(
`${sectionHeaderClass} flex w-full cursor-pointer items-center gap-1.5 text-left${sticky ? ` ${stickyClass}` : ''}`
);
// the dropdown publishes its search block height, surfaces without one fall back to 0
let triggerStyle = $derived(sticky ? 'top: var(--dropdown-sticky-height, 0px)' : '');
</script>
<CollapsibleSection
{chevronClass}
defaultOpen={persistKey ? modelsStore.isGroupOpen(persistKey, defaultOpen) : defaultOpen}
ontoggle={persistKey ? (open: boolean) => modelsStore.setGroupOpen(persistKey, open) : null}
{revealChevronOnHover}
{triggerClass}
{triggerStyle}
>
{#snippet trigger()}
{#if onBack}
<button
aria-label="Back to all providers"
class="-ml-1 inline-flex shrink-0 cursor-pointer items-center rounded-sm p-0.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
onclick={(event) => {
// the surrounding trigger toggles the section, the back control
// must not collapse the list it is leaving
event.stopPropagation();
onBack?.();
}}
type="button"
>
<ChevronLeft class="h-3.5 w-3.5" />
</button>
{/if}
{#if icon}
{@render icon()}
{/if}
<span class="truncate">{label}</span>
{#if loading}
<Loader2 class="h-3 w-3 shrink-0 animate-spin" />
{:else if error}
<CircleAlert class="h-3 w-3 shrink-0 text-destructive" />
{/if}
{#if count !== undefined}
<span class="shrink-0 text-xs text-muted-foreground/70">{count}</span>
{/if}
{/snippet}
{@render children()}
</CollapsibleSection>
@@ -0,0 +1,135 @@
<script lang="ts">
import ModelsDiscoverAvatar from '../discover/ModelsDiscoverAvatar.svelte';
import ModelsDiscoverDownloadProgressBar from '../discover/ModelsDiscoverDownloadProgressBar.svelte';
import { Info, Loader2, Pause, Play, X } from '@lucide/svelte';
import { ModelId } from '$lib/components/app';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore, uiStore } from '$lib/stores';
import type { ModelDownloadProgress } from '$lib/types';
interface Props {
/** One entry from the status feed: an in-flight or paused download. */
entry: { isPaused: boolean; progress: ModelDownloadProgress | null; repoWithTag: string };
/**
* Ask the list to confirm cancelling this download. The row owns no dialog;
* the list renders a single shared confirmation.
*/
onRequestCancel?: (repoWithTag: string) => void;
/** Show the organization name in the repo id, as the other selector rows do. */
showOrgName?: boolean;
}
let { entry, onRequestCancel, showOrgName = true }: Props = $props();
let percent = $derived(
entry.progress && entry.progress.totalBytes > 0
? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
: null
);
let actionText = $derived(entry.isPaused ? 'Resume downloading' : 'Pause downloading');
// Avatar: the repo org with the quantizer org corner badge, as in the model
// option rows; the base model org resolves lazily via HF when unknown
let orgName = $derived(ModelsService.parseModelId(entry.repoWithTag).orgName);
let fetchedBaseModelOrg = $state<string | null>(null);
$effect(() => {
const repoWithTag = entry.repoWithTag;
fetchedBaseModelOrg = null;
let cancelled = false;
void HuggingFaceService.getBaseModel(repoWithTag)
.then((base) => {
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
})
// best-effort lookup: offline or unknown repos keep the repo org
.catch(() => {});
return () => {
cancelled = true;
};
});
</script>
<!-- One in-flight download; same actions as the discover quant chips: the row
itself pauses / resumes, the trailing X cancels (stops and discards the
partial files). Both affordances fade in on hover, the slots are reserved
so the list never reflows. -->
<div
class="group relative flex items-center gap-2 rounded-sm p-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<button
aria-label={actionText}
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
onclick={() => {
if (entry.isPaused) void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
else void modelsStore.status.pauseDownload(entry.repoWithTag);
}}
type="button"
>
{#if orgName}
<ModelsDiscoverAvatar
org={fetchedBaseModelOrg ?? orgName}
quantOrg={orgName}
quantPositionClass="-bottom-1 -right-1"
quantSize="h-3 w-3"
size="h-6 w-6"
/>
{/if}
<ModelId class="flex-1" hideOrgName={!showOrgName} modelId={entry.repoWithTag} showRawTooltip />
{#if percent !== null}
<span class="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
{:else if entry.isPaused}
<span class="shrink-0 text-xs text-muted-foreground">Paused</span>
{/if}
<!-- status action: spinner -> pause on hover while in flight, play on hover
when paused; opacity only for the spinner, the spin owns the transform -->
<span class="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
{#if entry.isPaused}
<Play
class="absolute h-4 w-4 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
/>
{:else}
<Loader2
class="absolute h-4 w-4 animate-spin text-muted-foreground transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
/>
<Pause
class="absolute h-4 w-4 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
/>
{/if}
</span>
</button>
<button
aria-label="Manage model"
class="inline-flex h-4 w-4 shrink-0 scale-75 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-foreground group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
onclick={() => uiStore.openModelsManager(entry.repoWithTag)}
type="button"
>
<Info class="h-4 w-4" />
</button>
<button
aria-label="Cancel downloading"
class="inline-flex h-4 w-4 shrink-0 scale-75 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
onclick={() => onRequestCancel?.(entry.repoWithTag)}
type="button"
>
<X class="h-4 w-4" />
</button>
{#if entry.progress && entry.progress.totalBytes > 0}
<ModelsDiscoverDownloadProgressBar
downloadedBytes={entry.progress.downloadedBytes}
overlay
totalBytes={entry.progress.totalBytes}
/>
{/if}
</div>
@@ -59,7 +59,7 @@
});
</script>
<span class="relative mt-0.5 inline-flex shrink-0 {className}">
<span class="relative inline-flex shrink-0 {className}">
{#if orgAvatarFailed}
<span
aria-hidden="true"
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
downloadedBytes: number;
totalBytes: number;
overlay?: boolean;
}
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
let fraction = $derived.by(() => {
if (totalBytes <= 0) return 0;
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
});
let percent = $derived(Math.round(fraction * 100));
</script>
{#if overlay}
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{:else}
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{/if}
@@ -118,3 +118,16 @@ export { default as ModelId } from './ModelId.svelte';
* styling stay consistent across every model-id surface.
*/
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
/**
* **ModelId** - Parsed model identifier display
*
* Displays a model ID with optional org name, parameter badges, quantization,
* aliases, and tags. Supports raw mode to show the unprocessed model name.
* Respects the user's `showRawModelNames` setting.
*/
export { default as ModelCapabilities } from './ModelCapabilities.svelte';
export { default as ModelContext } from './ModelContext.svelte';
export { default as ModelAvatar } from './ModelAvatar.svelte';
export { default as ModelLoadControl } from './ModelLoadControl.svelte';
export { default as ModelRowActions } from './ModelRowActions.svelte';
export { default as ModelsSection } from './ModelsSection.svelte';
@@ -27,7 +27,9 @@
outline:
'shadow-sm hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm dark:border-input border',
secondary:
'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25'
'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25',
tertiary:
'bg-muted/60 dark:bg-muted/75 shadow-sm border border-border/30 text-foreground hover:bg-muted/80 dark:border-border/20 dark:hover:bg-muted'
}
}
});
@@ -0,0 +1,10 @@
import Root from './toggle-group.svelte';
import Item from './toggle-group-item.svelte';
export {
Root,
Item,
//
Root as ToggleGroup,
Item as ToggleGroupItem
};
@@ -0,0 +1,35 @@
<script lang="ts">
import { getToggleGroupCtx } from './toggle-group.svelte';
import { type ToggleVariants, toggleVariants } from '$lib/components/ui/toggle/index.js';
import { cn } from '$lib/components/ui/utils.js';
import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
let {
class: className,
ref = $bindable(null),
size,
value = $bindable(),
variant,
...restProps
}: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
const ctx = getToggleGroupCtx();
</script>
<ToggleGroupPrimitive.Item
bind:ref
class={cn(
'group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-md group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-md group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-md group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-md shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t',
toggleVariants({
size: ctx.size || size,
variant: ctx.variant || variant
}),
className
)}
data-size={ctx.size || size}
data-slot="toggle-group-item"
data-spacing={ctx.spacing}
data-variant={ctx.variant || variant}
{value}
{...restProps}
/>
@@ -0,0 +1,75 @@
<script lang="ts" module>
import { toggleVariants } from '$lib/components/ui/toggle/index.js';
import { getContext, setContext } from 'svelte';
import type { VariantProps } from 'tailwind-variants';
type ToggleVariants = VariantProps<typeof toggleVariants>;
interface ToggleGroupContext extends ToggleVariants {
spacing?: number;
orientation?: 'horizontal' | 'vertical';
}
export function setToggleGroupCtx(props: ToggleGroupContext) {
setContext('toggleGroup', props);
}
export function getToggleGroupCtx() {
return getContext<Required<ToggleGroupContext>>('toggleGroup');
}
</script>
<script lang="ts">
import { cn } from '$lib/components/ui/utils.js';
import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
let {
class: className,
orientation = 'horizontal',
ref = $bindable(null),
size = 'default',
spacing = 0,
value = $bindable(),
variant = 'default',
...restProps
}: ToggleGroupPrimitive.RootProps &
ToggleVariants & {
spacing?: number;
orientation?: 'horizontal' | 'vertical';
} = $props();
setToggleGroupCtx({
get orientation() {
return orientation;
},
get size() {
return size;
},
get spacing() {
return spacing;
},
get variant() {
return variant;
}
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<ToggleGroupPrimitive.Root
bind:ref
bind:value={value as never}
class={cn(
'rounded-md group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch',
className
)}
data-size={size}
data-slot="toggle-group"
data-spacing={spacing}
data-variant={variant}
{orientation}
style={`--gap: ${spacing}`}
{...restProps}
/>
@@ -0,0 +1,13 @@
import Root from './toggle.svelte';
export {
toggleVariants,
type ToggleSize,
type ToggleVariant,
type ToggleVariants
} from './toggle.svelte';
export {
Root,
//
Root as Toggle
};
@@ -0,0 +1,54 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const toggleVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive gap-1 rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] inline-flex items-center justify-center whitespace-nowrap disabled:pointer-events-none disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 group/toggle",
defaultVariants: {
size: 'default',
variant: 'default'
},
variants: {
size: {
default:
'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
sm: "h-7 min-w-7 rounded-md px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5"
},
variant: {
default:
'bg-transparent hover:bg-muted-foreground/10 hover:text-accent-foreground backdrop-blur-sm data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
outline:
'border bg-background shadow-sm hover:bg-muted-foreground/10 hover:text-accent-foreground backdrop-blur-sm data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:border-input'
}
}
});
export type ToggleVariant = VariantProps<typeof toggleVariants>['variant'];
export type ToggleSize = VariantProps<typeof toggleVariants>['size'];
export type ToggleVariants = VariantProps<typeof toggleVariants>;
</script>
<script lang="ts">
import { cn } from '$lib/components/ui/utils.js';
import { Toggle as TogglePrimitive } from 'bits-ui';
let {
class: className,
pressed = $bindable(false),
ref = $bindable(null),
size = 'default',
variant = 'default',
...restProps
}: TogglePrimitive.RootProps & {
variant?: ToggleVariant;
size?: ToggleSize;
} = $props();
</script>
<TogglePrimitive.Root
bind:pressed
bind:ref
class={cn(toggleVariants({ size, variant }), className)}
data-slot="toggle"
{...restProps}
/>
@@ -1,5 +1,5 @@
/**
* Icon mappings for file types and model modalities
* Icon mappings for file types, models and model modalities
* Centralized configuration to ensure consistent icon usage across the app
*/
@@ -10,6 +10,7 @@ import {
Image as ImageIcon,
Lightbulb as ReasoningIcon,
Mic as AudioIcon,
Package as ModelIcon,
Video as VideoIcon,
Wrench as ToolUseIcon
} from '@lucide/svelte';
@@ -27,6 +28,12 @@ export const FILE_TYPE_ICONS = {
export const DEFAULT_FILE_ICON = FileIcon;
/** The mark for a model, wherever one is listed. */
export const MODEL_ICON = ModelIcon;
/** Alias kept for the selector until it adopts MODEL_ICON. */
export const MODEL_SELECTOR_ICON = ModelIcon;
export const MODALITY_ICONS = {
[ModelModality.AUDIO]: AudioIcon,
[ModelModality.VIDEO]: VideoIcon,
+2
View File
@@ -4,6 +4,7 @@
export * from './agentic.constants';
export * from './api-endpoints.constants';
export * from './app.constants';
export * from './backend.constants';
export * from './chat-tabs.constants';
export * from './database.constants';
export * from './reasoning-effort.constants';
@@ -46,6 +47,7 @@ export * from './path-display.constants';
export * from './model-id.constants';
export * from './model-loading.constants';
export * from './models-discover.constants';
export * from './models-discover-download.constants';
export * from './model-compatibility.constants';
export * from './huggingface.constants';
export * from './precision.constants';
@@ -0,0 +1,54 @@
/**
* Models Discover - download options constants.
*
* Shared values backing the download-options area of the details pane: the
* serve `--spec-type` mapping, the bit-depth buckets, and the standalone
* command builder. Kept here (not component-local) so they sit with the rest
* of the discover domain constants.
*/
import type { ModelSidecar } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
/**
* `--spec-type` value for each draft sidecar; aux sidecars (mmproj, imatrix)
* carry none and stay empty.
*/
export const SPEC_TYPE: Record<ModelSidecar, string> = {
[ModelAuxSidecar.IMATRIX]: '',
[ModelAuxSidecar.MMPROJ]: '',
[ModelDraftSidecar.DFLASH]: 'draft-dflash',
[ModelDraftSidecar.DSPARK]: 'draft-dspark',
[ModelDraftSidecar.EAGLE3]: 'draft-eagle3',
[ModelDraftSidecar.MTP]: 'draft-mtp'
};
/** Bit-depth bucket for files that carry no quant token; rendered as "Other". */
export const OTHER_BIT_DEPTH = 99;
/** Label for the OTHER_BIT_DEPTH bucket. */
export const OTHER_BIT_DEPTH_LABEL = 'Other';
/** Suffix appended after a bit-depth number to label a row, e.g. `4-bit`. */
export const BIT_DEPTH_LABEL_SUFFIX = '-bit';
/**
* Fixed tokens of the standalone `llama serve` command preview, so the command
* builder and its rendered spans share one source of the CLI spelling.
*/
export const SERVE_COMMAND = {
BIN: 'llama',
/** Draft model repo:tag argument (download sidecar / draft weights). */
DRAFT_FLAG: '-hfd',
/** Main model repo:tag argument. */
MODEL_FLAG: '-hf',
/** Speculative-decoding type argument for the draft. */
SPEC_TYPE_FLAG: '--spec-type',
SUBCOMMAND: 'serve'
} as const;
/** Bit depth preferred for the command's default base quant. */
export const DEFAULT_BASE_BIT_DEPTH = 4;
/** Label shown for a draft-sidecar chip whose quant is unknown. */
export const DRAFT_FILE_LABEL = 'draft';
+126 -38
View File
@@ -72,6 +72,7 @@ import type {
HfModelSibling
} from '$lib/types/huggingface';
import { sidecarFromFileToken } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
/**
* HuggingFaceService - Service for browsing and searching GGUF models on Hugging Face Hub
@@ -88,6 +89,13 @@ export class HuggingFaceService {
Promise<{ org: string; name: string } | null>
>();
// Model details and file trees fetched this session, keyed by repo id. The
// Hub rate limits aggressively, so each repo costs at most one request per
// app load; failed fetches are not cached so the next mount can retry.
private static detailsCache = new SvelteMap<string, HfModelDetailInfo | null>();
private static detailsPending = new Map<string, Promise<HfModelDetailInfo | null>>();
/**
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
*/
@@ -127,6 +135,23 @@ export class HuggingFaceService {
Q8_0: 8
};
private static treeCache = new Map<string, HfModelSibling[]>();
private static treePending = new Map<string, Promise<HfModelSibling[]>>();
/**
* Details already fetched for a model, for a caller that cannot await, such as a
* table ordering its rows. `undefined` means no lookup has happened yet.
*/
static cachedDetails(modelId: string): HfModelDetailInfo | null | undefined {
// llama.cpp model ids carry the quant tag after a colon
const [hfRepoId] = modelId.split(':');
return HuggingFaceService.detailsCache.get(hfRepoId);
}
// GGUF Model Browsing
/**
* Collapse split GGUF shard sets (`-00001-of-00015.gguf`, ...) to their first
* shard, summing every shard's size so the kept entry reflects the whole
@@ -166,8 +191,6 @@ export class HuggingFaceService {
return result;
}
// GGUF Model Browsing
/**
* Extract the GGUF quantization token (e.g. `Q4_K_M`) and any sidecar type
* (`mtp`, `dflash`, `mmproj`, ...) from a `.gguf` filename. The sidecar token
@@ -345,6 +368,8 @@ export class HuggingFaceService {
return `${Math.floor(diffDays / DAYS_PER_YEAR)} ${YEARS_AGO_LABEL}`;
}
// Model Details & Files
/**
* Format a min-max size range with a single shared unit and no spaces
* around the dash, e.g. `19.0-28.6 GB`.
@@ -371,8 +396,6 @@ export class HuggingFaceService {
return `${fmt(min)}-${fmt(max)} ${unit}`;
}
// Model Details & Files
/**
* Avatar URL for an author (org or user). 404s when the author does not
* exist, so callers should provide a fallback.
@@ -389,16 +412,20 @@ export class HuggingFaceService {
* base model. Results are cached per repo.
*/
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
const cached = this.baseModelCache.get(repoId);
// llama.cpp model ids carry the quant tag after a colon
// (`org/repo:Q4_K_XL`); the HF repo id is the part before it, and all
// quants of one repo share the cached lookup
const [hfRepoId] = repoId.split(':');
const cached = this.baseModelCache.get(hfRepoId);
if (cached !== undefined) return Promise.resolve(cached);
const pending = this.baseModelPending.get(repoId);
const pending = this.baseModelPending.get(hfRepoId);
if (pending) return pending;
const promise = (async () => {
const details = await this.getDetails(repoId);
const details = await this.getDetails(hfRepoId);
const base = this.getBaseModels(details)[0];
if (!base) return null;
@@ -408,11 +435,11 @@ export class HuggingFaceService {
return { name: rest.join(PATH_SEPARATOR), org };
})();
this.baseModelPending.set(repoId, promise);
this.baseModelPending.set(hfRepoId, promise);
promise
.then((result) => this.baseModelCache.set(repoId, result))
.finally(() => this.baseModelPending.delete(repoId));
.then((result) => this.baseModelCache.set(hfRepoId, result))
.finally(() => this.baseModelPending.delete(hfRepoId));
return promise;
}
@@ -476,26 +503,57 @@ export class HuggingFaceService {
return (await response.json()) as HfCatalogEntry[];
}
static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
// Do not encode the modelId, it contains slashes for author/name.
// `full=true` includes cardData (description, base_model) and safetensors.
const url = `${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}?${HF_FULL_DETAIL_PARAM}`;
static getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
const cached = HuggingFaceService.detailsCache.get(modelId);
try {
const response = await fetch(url);
if (cached !== undefined) return Promise.resolve(cached);
if (response.status === HF_HTTP_NOT_FOUND) return null;
const pending = HuggingFaceService.detailsPending.get(modelId);
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
if (pending) return pending;
const data = (await response.json()) as HfModelDetailInfo;
const promise = (async () => {
// Do not encode the modelId, it contains slashes for author/name.
// `full=true` includes cardData (description, base_model) and safetensors.
const url = `${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}?${HF_FULL_DETAIL_PARAM}`;
return data;
} catch (error) {
console.error(`Error fetching details for ${modelId}:`, error);
try {
const response = await fetch(url);
return null;
}
// a missing model is a definitive answer, cache it
if (response.status === HF_HTTP_NOT_FOUND) {
HuggingFaceService.detailsCache.set(modelId, null);
return null;
}
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
// the hub answers with a list of provider entries for one repo, and the
// gguf and card data sit on the entry that carries them
const payload = (await response.json()) as HfModelDetailInfo | HfModelDetailInfo[];
const entries = Array.isArray(payload) ? payload : [payload];
const data = entries.find((entry) => entry?.gguf ?? entry?.cardData) ?? entries[0];
if (!data) return null;
HuggingFaceService.detailsCache.set(modelId, data);
return data;
} catch (error) {
// not cached: a rate limited or failed fetch should retry on the
// next mount instead of hiding the model for the whole session
console.error(`Error fetching details for ${modelId}:`, error);
return null;
} finally {
HuggingFaceService.detailsPending.delete(modelId);
}
})();
HuggingFaceService.detailsPending.set(modelId, promise);
return promise;
}
/**
@@ -505,8 +563,6 @@ export class HuggingFaceService {
return `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}`;
}
// Utility Methods
/**
* Get most liked GGUF models
*/
@@ -514,6 +570,8 @@ export class HuggingFaceService {
return this.search({ limit, sort: HfModelSort.LIKES });
}
// Utility Methods
/**
* Get newly released GGUF models
*/
@@ -555,19 +613,28 @@ export class HuggingFaceService {
* repos that keep quants in per-quant subdirectories (e.g. `UD-Q4_K_XL/`)
* are included; follows cursor pagination for repos over one page.
*/
static async getTree(modelId: string): Promise<HfModelSibling[]> {
const files: HfModelSibling[] = [];
const firstUrl =
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
static getTree(modelId: string): Promise<HfModelSibling[]> {
const cached = HuggingFaceService.treeCache.get(modelId);
let url: string | null = firstUrl;
if (cached) return Promise.resolve(cached);
const pending = HuggingFaceService.treePending.get(modelId);
if (pending) return pending;
const promise = (async () => {
const files: HfModelSibling[] = [];
const firstUrl =
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
let url: string | null = firstUrl;
try {
for (let page = 0; url && page < HF_TREE_MAX_PAGES; page++) {
const response: Response = await fetch(url);
if (!response.ok) return files;
if (!response.ok)
throw new Error(`Failed to fetch tree for ${modelId}: ${response.status}`);
const data = (await response.json()) as HfModelSibling[];
@@ -575,11 +642,24 @@ export class HuggingFaceService {
url = HuggingFaceService.parseNextPageUrl(response.headers.get(HF_LINK_HEADER));
}
} catch {
// Return whatever was fetched before the failure.
}
return files;
HuggingFaceService.treeCache.set(modelId, files);
return files;
})()
.catch((error: unknown) => {
// not cached: a rate limited or failed fetch should retry on the
// next mount; an empty tree makes the store fall back to the
// catalog's advertised sizes
console.error(`Error fetching tree for ${modelId}:`, error);
return [] as HfModelSibling[];
})
.finally(() => HuggingFaceService.treePending.delete(modelId));
HuggingFaceService.treePending.set(modelId, promise);
return promise;
}
/**
@@ -589,6 +669,14 @@ export class HuggingFaceService {
return this.search({ limit, sort: HfModelSort.TRENDING_SCORE });
}
/**
* Parameter count a repo reports: a GGUF repo spells it out in its metadata,
* a transformers repo reports it in its SafeTensors index.
*/
static parameterCount(details?: HfModelDetailInfo | null): number | null {
return details?.gguf?.total ?? details?.safetensors?.total ?? null;
}
/**
* Parse a local HF cache file path
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
+7
View File
@@ -154,6 +154,13 @@ export interface HfModelDetailInfo extends HfModelInfo {
cardData?: HfModelCardData;
/** GGUF metadata (only present when full=true for GGUF repos) */
gguf?: HfModelGguf;
/** SafeTensors counts (only present when full=true for transformers repos) */
safetensors?: {
/** Total parameter count */
total?: number;
/** Per dtype parameter counts */
parameters?: Record<string, number>;
};
/** Model config (only present when full=true) */
config?: Record<string, unknown>;
/** Total repo storage in bytes (only present when full=true) */
+17
View File
@@ -18,6 +18,8 @@ export interface ModelOption {
model: string;
description?: string;
capabilities: string[];
/** Context size reported by the provider's model listing, when it reports one. */
contextLength?: number;
modalities?: ModelModalities;
details?: ApiModelDetails['details'];
meta?: ApiModelDataEntry['meta'];
@@ -64,3 +66,18 @@ export interface ModalityCapabilities {
hasAudio: boolean;
hasVideo: boolean;
}
/**
* A draft sidecar file a model listing reports as its own entry. The router lists a
* downloaded sidecar as a model, so this is what pairs it back with its model.
*/
export interface ModelSidecarFile {
id: string;
kind: ModelSidecar;
/** Repo the sidecar belongs to, e.g. `ggml-org/Qwen3.6-35B-A3B-GGUF`. */
model: string;
/** Parameter count the sidecar reports, e.g. `35B-A3B`. */
params: string | null;
/** Quantization of the sidecar file, e.g. `Q4_0`. */
quant: string | null;
}
+2 -1
View File
@@ -105,7 +105,7 @@ export {
} from './modality-file-validation';
// Model name utilities
export { normalizeModelName, isValidModelName } from './model-names';
export { isValidModelName, normalizeModelName, orgOf } from './model-names';
// Sidecar token utilities
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
@@ -360,3 +360,4 @@ export { remToPx } from './css';
// Audio format helper (used by agentic store and chat service)
export { getAudioInputFormat } from './audio-format';
export { groupModelFamilies, modelFamilyKey, type ModelFamilyGroup } from './model-families';
+41
View File
@@ -0,0 +1,41 @@
/** One family of a model list, and the entries it covers. */
export interface ModelFamilyGroup<T> {
entries: T[];
key: string;
label: string;
}
/**
* Family a repo belongs to, from its name. The version is dropped, so `Qwen3.5`,
* `Qwen3.8-27B` and `Qwen3.8-Flash-Next` all read as `Qwen`. A name that does not
* start with letters keeps its first segment.
*/
export function modelFamilyKey(repo: string): string {
const name = repo.split('/').pop() ?? repo;
const letters = name.match(/^[A-Za-z]+/);
return letters ? letters[0] : (name.split(/[-_.]/)[0] ?? name);
}
/** Fold entries into families, so `Qwen` collects its sizes and variants. */
export function groupModelFamilies<T>(
entries: T[],
modelOf: (entry: T) => string
): ModelFamilyGroup<T>[] {
const families = new Map<string, ModelFamilyGroup<T>>();
for (const entry of entries) {
const key = modelFamilyKey(modelOf(entry));
const family = families.get(key);
if (family) {
family.entries.push(entry);
continue;
}
families.set(key, { entries: [entry], key, label: key });
}
return Array.from(families.values());
}