ui : add the models manager

A manage models dialog for the local server: a table that folds each repo's
quants into one row, groups them into families, and sorts from its column
headers. Loaded models lead, then favorites, then the local block, then the
hidden one; sections keep their open state in local storage. The toolbar
filters by capability, modality and context, rows act on favorite, delete
and hide, and the manager opens focused on a model from the sidebar or a
download row. The models store gains the recents, hidden and group-open
state, and warms the Hub records for the context and capability columns.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-30 15:31:10 +02:00
parent bdeb855b30
commit ea612b4842
60 changed files with 3351 additions and 130 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",
@@ -0,0 +1,37 @@
<script lang="ts">
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { MODEL_ICON } from '$lib/constants';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380! flex flex-col p-4 pb-0"
onCloseAutoFocus={(event) => event.preventDefault()}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Dialog.Header class="flex flex-row items-center p-2 pr-8">
<Dialog.Title class="flex min-h-7 items-center gap-2">
<MODEL_ICON class="h-5 w-5 shrink-0" />
<span>Models</span>
</Dialog.Title>
</Dialog.Header>
<div class="min-h-0 flex-1">
<ModelsManager class="h-full" />
</div>
</Dialog.Content>
</Dialog.Root>
@@ -537,3 +537,6 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
* ```
*/
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
/** **DialogManageModels** - shell for the models manager. */
export { default as DialogManageModels } from './DialogManageModels.svelte';
@@ -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,89 @@
<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 { nearViewport, 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
);
// Long lists mount hundreds of avatars at once; resolving every base model up
// front means one request per row, so the avatar waits until its row is near the viewport.
let fetchedBaseModelOrg = $state<string | null>(null);
let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
let isNearViewport = $state(false);
$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
use:nearViewport={() => (isNearViewport = true)}
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,56 @@
<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, nearViewport } 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 isNearViewport = $state(false);
let template = $state('');
$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 use:nearViewport={() => (isNearViewport = true)} 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,57 @@
<script lang="ts">
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { nearViewport } from '$lib/utils';
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 isNearViewport = $state(false);
let fetched = $state<number | null>(null);
$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
use:nearViewport={() => (isNearViewport = true)}
class={['text-sm text-muted-foreground', className]}
>
{values.length ? `${values.map((value) => formatParameters(value)).join(' / ')} tokens` : '-'}
</span>
@@ -0,0 +1,25 @@
<script lang="ts">
import { MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
import type { ModelSidecarBadge } from '$lib/types/models';
import { isAuxSidecar } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
/** Draft sidecars available for the model. */
draftSidecars?: ModelSidecarBadge[];
}
let { draftSidecars = [] }: Props = $props();
let kinds = $derived(
[...new SvelteSet(draftSidecars.map((badge) => badge.kind))].filter(
(kind) => !isAuxSidecar(kind)
)
);
</script>
{#each kinds as kind (kind)}
<span class={MODEL_VARIANT_BADGE_CLASS} title={`${kind.toUpperCase()} draft model available`}>
{kind}
</span>
{/each}
@@ -1,13 +1,15 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import ModelDraftSidecars from './ModelDraftSidecars.svelte';
import { Database, ScrollText } from '@lucide/svelte';
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';
import type { ModelModalities } from '$lib/types/models';
import { type ModelSidecarBadge } from '$lib/types/models';
import { isAuxSidecar } from '$lib/utils';
import { formatParameters } from '$lib/utils';
@@ -35,7 +37,8 @@
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
draftSidecars?: ModelSidecar[];
/** Draft sidecars available for the model, badged with their own quant. */
draftSidecars?: ModelSidecarBadge[];
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
class?: string;
@@ -67,12 +70,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;
@@ -88,7 +89,7 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
let uniqueDraftSidecars = $derived([...new Set(draftSidecars)].filter((s) => !isAuxSidecar(s)));
let uniqueDraftSidecars = $derived(draftSidecars.filter((badge) => !isAuxSidecar(badge.kind)));
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
@@ -115,7 +116,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}
@@ -128,18 +133,14 @@
</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}
<ModelDraftSidecars {draftSidecars} />
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class="{badgeClass} max-w-32 truncate" title={parsed.modelName ?? modelId}>
@@ -169,10 +170,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,159 @@
<script lang="ts">
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
import {
groupModelQuants,
type ModalityKey,
modelContextLength,
type ModelQuantGroup,
MODELS_TABLE_GROUP_LABELS,
type ModelsTableGroup,
ModelsTableGroupKind,
modelSupports
} from './utils';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { type Snippet } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface Props {
class?: string;
/** Forwarded to the table's toolbar right end. */
toolbarEnd?: Snippet;
}
let { class: className, toolbarEnd }: Props = $props();
let filter = $state('');
let contextLimit = $state(0);
let modalityFilter = $state<ModalityKey[]>([]);
let capabilityFilter = $state<ModelCapability[]>([]);
let selectedId = $state<string | null>(null);
let allModels = $derived(modelsStore.models);
let isFavorite = $derived((option: ModelOption) =>
modelsStore.favoriteModelIds.has(option.model)
);
let matching = $derived.by(() => {
const term = filter.trim().toLowerCase();
return allModels.filter((option) => {
if (term && !`${option.name} ${option.model}`.toLowerCase().includes(term)) return false;
// every capability asked for has to be there: tools and reasoning are
// features a model either has or does not, unlike modalities
if (
capabilityFilter.length > 0 &&
!capabilityFilter.every((capability) => modelSupports(option, capability))
) {
return false;
}
// a model whose modalities are unknown cannot be shown to match
if (modalityFilter.length > 0 && !modalityFilter.some((key) => option.modalities?.[key])) {
return false;
}
return contextLimit === 0 || modelContextLength(option) >= contextLimit;
});
});
// recently used models lead their section, the rest keep the server's order;
// derived, so a pick made while the manager is open reorders the sections
let rank = $derived.by(() => {
const map = new SvelteMap<string, number>();
modelsStore.recentModelIds.forEach((id, index) => map.set(id, index));
return map;
});
const rankOf = (entry: ModelQuantGroup) =>
Math.min(...entry.quants.map((quant) => rank.get(quant.id) ?? Number.MAX_SAFE_INTEGER));
const byRecency = (list: ModelQuantGroup[]) =>
rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
// one entry per repo, so a model with several quants is a single table row;
// loaded models lead the table, then favorites, then the local block
let entries = $derived(byRecency(groupModelQuants(matching)));
let groups = $derived.by(() => {
// A loaded quant is a model of its own: it moves to the loaded section, and
// the quants of its repo that are not loaded stay behind as that repo.
const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
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 = 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 local = rest.filter((entry) => !claimed.has(entry.key) && !hiddenKeys.has(entry.key));
const ordered: ModelsTableGroup[] = [];
// loaded models lead the table, then favorites, then the local block
const pushSection = (kind: ModelsTableGroupKind, items: ModelQuantGroup[]): void => {
if (items.length === 0) return;
ordered.push({
items,
key: kind === ModelsTableGroupKind.LOCAL ? LOCAL_BACKEND_ID : kind,
kind,
label: MODELS_TABLE_GROUP_LABELS[kind]
});
};
pushSection(ModelsTableGroupKind.LOADED, loaded);
pushSection(ModelsTableGroupKind.FAVORITES, favorites);
pushSection(ModelsTableGroupKind.LOCAL, local);
pushSection(ModelsTableGroupKind.HIDDEN, hidden);
return ordered;
});
// a caller can ask for one model to be revealed, the download rows do
$effect(() => {
const focus = uiStore.manageModelFocus;
if (!focus) return;
const option = allModels.find((model) => model.id === focus || model.model === focus);
if (option) selectedId = option.id;
uiStore.manageModelFocus = null;
});
</script>
<div class={['flex min-h-0 flex-1', className]}>
<div class="min-h-0 min-w-0 flex-1">
<ModelsManagerModelsTable
bind:capabilities={capabilityFilter}
bind:contextLimit
bind:filter
bind:modalities={modalityFilter}
{groups}
{isFavorite}
onSelect={(option) => (selectedId = option.id)}
{selectedId}
{toolbarEnd}
/>
</div>
</div>
@@ -0,0 +1,104 @@
<script lang="ts">
import { MODALITY_KEYS, type ModalityKey } from './utils';
import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
import { ScrollCarousel } from '$lib/components/app';
import * as Select from '$lib/components/ui/select';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import { FILTER_TRIGGER_CLASS } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
interface Props {
/** Capabilities a model must have every one of. */
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
}
let {
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
modalities = $bindable<ModalityKey[]>([])
}: Props = $props();
const CONTEXT_STEPS: { label: string; value: number }[] = [
{ label: 'Any context', value: 0 },
{ label: '8K or more', value: 8192 },
{ label: '32K or more', value: 32768 },
{ label: '128K or more', value: 131_072 },
{ label: '256K or more', value: 262_144 },
{ label: '1M or more', value: 1_048_576 }
];
const CAPABILITY_TOGGLES: { icon: typeof Wrench; label: string; value: ModelCapability }[] = [
{ icon: Wrench, label: 'Tool use', value: ModelCapability.TOOL_USE },
{ icon: Lightbulb, label: 'Reasoning', value: ModelCapability.REASONING }
];
const MODALITY_TOGGLES: { icon: typeof Wrench; label: string; value: ModalityKey }[] = [
{ icon: Image, label: 'Vision', value: 'vision' },
{ icon: Video, label: 'Video', value: 'video' },
{ icon: Mic, label: 'Audio', value: 'audio' }
];
// one group holds everything a model either has or does not: what it can do,
// and what it can accept
const TOGGLES = [...CAPABILITY_TOGGLES, ...MODALITY_TOGGLES];
const CAPABILITY_VALUES = new Set<string>(CAPABILITY_TOGGLES.map((entry) => entry.value));
// the group holds one flat list, so a change splits back into the two filters
function setToggles(values: string[]): void {
capabilities = values.filter((value): value is ModelCapability => CAPABILITY_VALUES.has(value));
modalities = values.filter(
(value): value is ModalityKey =>
!CAPABILITY_VALUES.has(value) && (MODALITY_KEYS as string[]).includes(value)
);
}
const TOGGLE_ITEM_CLASS =
'bg-muted! border-border/30! shadow-none! dark:border-border/20! data-[state=on]:bg-muted-foreground/15! data-[state=on]:text-foreground! dark:data-[state=on]:bg-muted-foreground/25!';
let contextLabel = $derived(
CONTEXT_STEPS.find((step) => step.value === contextLimit)?.label ?? CONTEXT_STEPS[0].label
);
</script>
<ScrollCarousel alwaysShowArrows class="min-w-0 flex-1" gapSize="2" innerClass="items-center">
<Select.Root
onValueChange={(value) => (contextLimit = Number(value))}
type="single"
value={String(contextLimit)}
>
<Select.Trigger class={FILTER_TRIGGER_CLASS} size="sm">
<span class="text-muted-foreground">Context:</span>
{contextLabel}
</Select.Trigger>
<Select.Content>
{#each CONTEXT_STEPS as step (step.value)}
<Select.Item label={step.label} value={String(step.value)}>{step.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<ToggleGroup.Root
class="border border-border/30 bg-muted/60 shadow-sm dark:border-border/20 dark:bg-muted/75"
onValueChange={setToggles}
type="multiple"
value={[...capabilities, ...modalities]}
variant="outline"
>
{#each TOGGLES as toggle (toggle.value)}
<ToggleGroup.Item
aria-label={toggle.label}
class={TOGGLE_ITEM_CLASS}
title={toggle.label}
value={toggle.value}
>
<toggle.icon class="h-3.5 w-3.5" />
</ToggleGroup.Item>
{/each}
</ToggleGroup.Root>
</ScrollCarousel>
@@ -0,0 +1,87 @@
<script lang="ts">
import ModelAvatar from '../ModelAvatar.svelte';
import ModelCapabilities from '../ModelCapabilities.svelte';
import ModelContext from '../ModelContext.svelte';
import ModelId from '../ModelId.svelte';
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
import { modelRowActions } from './row-actions';
import { configuredContext } from './utils';
import { MoreHorizontal } from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
isFavorite: (option: ModelOption) => boolean;
/** Model this row stands for. */
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
selected: boolean;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
function handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ') event.preventDefault();
if (event.key === 'Enter' || event.key === ' ') onSelect(option);
}
</script>
<div
class={[
MODEL_ROW_GRID_CLASS,
'group cursor-pointer rounded-md px-2 py-3 transition',
isHidden && 'opacity-60',
selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
]}
onclick={() => onSelect(option)}
onkeydown={handleKeydown}
role="button"
tabindex="0"
>
<span class="flex min-w-0 items-center gap-3" style="padding-left: {indent}px">
<ModelAvatar
{option}
showBaseModelAvatar={!settingsStore.config.groupModelsByFamily}
showRepoOrgAvatar={settingsStore.config.groupModelsByFamily}
size="size-9"
/>
<span class="flex min-w-0 items-center gap-1.25">
<ModelId
aliases={option.aliases}
class="min-w-0 flex-1"
draftSidecars={option.draftSidecars}
hideCapabilities
hideModalities
modalities={option.modalities}
modelId={option.model}
tags={option.tags}
title={option.model}
/>
<ModelCapabilities {option} />
</span>
</span>
<ModelContext class="justify-self-end" configured={configuredContext(option)} {option} />
<ModelsManagerStatusCell {option} />
<div class="flex items-center justify-center justify-self-center">
<DropdownMenuActions
actions={modelRowActions(option, favorite, isHidden, onDelete)}
align="end"
triggerIcon={MoreHorizontal}
triggerTooltip="Model actions"
/>
</div>
</div>
@@ -0,0 +1,419 @@
<script lang="ts">
import ModelsManagerModelRow from './ModelsManagerModelRow.svelte';
import ModelsManagerQuantRow from './ModelsManagerQuantRow.svelte';
import ModelsManagerRepoRow from './ModelsManagerRepoRow.svelte';
import ModelsManagerTableToolbar from './ModelsManagerTableToolbar.svelte';
import {
isModelRunning,
type ModalityKey,
modelContextLength,
type ModelQuantGroup,
type ModelsTableGroup,
ModelsTableGroupKind,
ModelsTableSortKey
} from './utils';
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronUp,
Download,
EyeOff,
Heart,
Power
} from '@lucide/svelte';
import {
GroupedList,
type GroupedListGroup,
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, MODEL_ROW_GRID_CLASS, MODEL_ROW_WINDOW } from '$lib/constants';
import { ModelCapability, ModelDownloadConfirmAction } from '$lib/enums';
import { 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;
filter?: string;
groups: ModelsTableGroup[];
isFavorite: (option: ModelOption) => boolean;
onSelect: (option: ModelOption) => void;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
selectedId: string | null;
/** Rendered at the toolbar's right end, past the filters. */
toolbarEnd?: Snippet;
}
let {
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
filter = $bindable(''),
groups,
isFavorite,
modalities = $bindable<ModalityKey[]>([]),
onSelect,
selectedId,
toolbarEnd
}: Props = $props();
let isEmpty = $derived(groups.every((group) => group.items.length === 0));
let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
/** In-flight and paused downloads, tracked by the status feed. */
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
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<ModelsTableGroupKind>([
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
};
})
);
/** Families of one section. */
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 a single dialog instance
// serves however many downloads are in flight.
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 === ' ') event.preventDefault();
if (event.key === 'Enter' || event.key === ' ') 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)}
/>
{#if !collapsedQuants.has(entry.key)}
{#each entry.quants as quant (quant.id)}
<ModelsManagerQuantRow
indent={indent + 24}
{isFavorite}
onDelete={requestDelete}
{onSelect}
option={quant}
selected={selectedId === quant.id}
/>
{/each}
{/if}
{:else}
<ModelsManagerModelRow
{indent}
{isFavorite}
onDelete={requestDelete}
{onSelect}
option={entry.base}
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: 'entries' | 'families';
})}
<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 {unit === 'families' ? 'families' : 'models'}
</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:filter
bind:modalities
{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}
<ModelsSection
chevronClass="mr-7"
count={group.items.length}
defaultOpen={group.kind !== ModelsTableGroupKind.HIDDEN}
icon={groupIcon}
label={group.label}
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>
@@ -0,0 +1,70 @@
<script lang="ts">
import ModelContext from '../ModelContext.svelte';
import ModelDraftSidecars from '../ModelDraftSidecars.svelte';
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
import { modelRowActions } from './row-actions';
import { configuredContext } from './utils';
import { MoreHorizontal } from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
isFavorite: (option: ModelOption) => boolean;
/** Quant this row stands for. */
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
selected: boolean;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { indent = 0, isFavorite, onDelete, onSelect, option, selected }: Props = $props();
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
let quant = $derived(option.parsedId?.quantization ?? option.model);
function handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ') event.preventDefault();
if (event.key === 'Enter' || event.key === ' ') onSelect(option);
}
</script>
<div
class={[
MODEL_ROW_GRID_CLASS,
'group cursor-pointer rounded-md px-2 py-2 transition',
isHidden && 'opacity-60',
selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
]}
onclick={() => onSelect(option)}
onkeydown={handleKeydown}
role="button"
tabindex="0"
>
<span class="flex min-w-0 items-center gap-3" style="padding-left: {indent}px">
<Badge class="h-5 shrink-0 px-1.5 text-[10px]" variant="secondary">{quant}</Badge>
<ModelDraftSidecars draftSidecars={option.draftSidecars} />
<span class="truncate text-sm text-muted-foreground">{option.model}</span>
</span>
<ModelContext class="justify-self-end" configured={configuredContext(option)} {option} />
<ModelsManagerStatusCell {option} />
<div class="flex items-center justify-center justify-self-center">
<DropdownMenuActions
actions={modelRowActions(option, favorite, isHidden, onDelete)}
align="end"
triggerIcon={MoreHorizontal}
triggerTooltip="Model actions"
/>
</div>
</div>
@@ -0,0 +1,101 @@
<script lang="ts">
import ModelAvatar from '../ModelAvatar.svelte';
import ModelCapabilities from '../ModelCapabilities.svelte';
import ModelContext from '../ModelContext.svelte';
import ModelId from '../ModelId.svelte';
import type { ModelQuantGroup } from './utils';
import { configuredContext, isModelRunning } from './utils';
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
import { settingsStore } from '$lib/stores';
interface Props {
/** Repo entry the row folds. */
entry: ModelQuantGroup;
expanded: boolean;
onToggle: () => void;
/** Left padding in px, from the nesting depth. */
indent?: number;
}
let { entry, expanded, indent = 0, onToggle }: Props = $props();
let groupLabel = $derived(
entry.kind === 'variants'
? `${entry.quants.length} variants`
: `${entry.quants.length} quants available`
);
let anyLoaded = $derived(entry.quants.some((quant) => isModelRunning(quant)));
// a repo row stands for its quants, so it reports what they agree on
let contextSource = $derived(entry.quants.find((quant) => quant.contextLength) ?? entry.base);
let mediaSource = $derived(entry.quants.find((quant) => quant.modalities) ?? entry.base);
function handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ') event.preventDefault();
if (event.key === 'Enter' || event.key === ' ') onToggle();
}
</script>
<div
class={[
MODEL_ROW_GRID_CLASS,
'cursor-pointer rounded-md px-2 py-2.5 transition hover:bg-muted/40'
]}
onclick={onToggle}
onkeydown={handleKeydown}
role="button"
tabindex="0"
>
<span class="flex min-w-0 items-center gap-3" style="padding-left: {indent}px">
<ModelAvatar
option={entry.base}
showBaseModelAvatar={!settingsStore.config.groupModelsByFamily}
showRepoOrgAvatar={settingsStore.config.groupModelsByFamily}
size="size-9"
/>
<span class="min-w-0">
<span class="flex min-w-0 items-center gap-1.25">
<ModelId
aliases={entry.base.aliases}
class="min-w-0"
draftSidecars={entry.base.draftSidecars}
hideCapabilities
hideModalities
hideQuantization
modalities={mediaSource.modalities}
modelId={entry.base.model}
tags={entry.base.tags}
title={entry.base.model}
/>
<ModelCapabilities option={entry.base} />
</span>
<span class="block text-xs text-muted-foreground">{groupLabel}</span>
</span>
</span>
<ModelContext
class="justify-self-end"
configured={configuredContext(contextSource)}
option={contextSource}
/>
<span class="justify-self-center">
<span
class="block h-2.5 w-2.5 rounded-full {anyLoaded
? 'bg-emerald-500'
: 'border border-muted-foreground/50'}"
></span>
</span>
<span class="flex justify-center">
{#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>
@@ -0,0 +1,29 @@
<script lang="ts">
import ModelLoadControl from '../ModelLoadControl.svelte';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
class?: string;
option: ModelOption;
}
let { class: className = '', option }: Props = $props();
let status = $derived(modelsStore.getModelStatus(option.model));
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isLoaded = $derived(
(status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING) &&
!isOperationInProgress
);
</script>
<ModelLoadControl
class="justify-self-center {className}"
isFailed={status === ServerModelStatus.FAILED}
{isLoaded}
isLoading={status === ServerModelStatus.LOADING || isOperationInProgress}
isSleeping={status === ServerModelStatus.SLEEPING}
{option}
/>
@@ -0,0 +1,84 @@
<script lang="ts">
import ModelsManagerFilters from './ModelsManagerFilters.svelte';
import { type ModalityKey } from './utils';
import { X } from '@lucide/svelte';
import { SearchInput } from '$lib/components/app/forms';
import { Button } from '$lib/components/ui/button';
import { ModelCapability } from '$lib/enums';
import { uiStore } from '$lib/stores';
import type { Snippet } from 'svelte';
interface Props {
/** Capabilities a model must have every one of. */
capabilities?: ModelCapability[];
/** Smallest context a model must support; 0 keeps every model. */
contextLimit?: number;
filter?: string;
/** Modalities a model must support at least one of. */
modalities?: ModalityKey[];
/** Rendered at the toolbar's right end, past the filters. */
toolbarEnd?: Snippet;
}
let {
capabilities = $bindable<ModelCapability[]>([]),
contextLimit = $bindable(0),
filter = $bindable(''),
modalities = $bindable<ModalityKey[]>([]),
toolbarEnd
}: Props = $props();
let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
let filterInput = $state<HTMLInputElement | null>(null);
// the dialog hands focus to its first control, so the filter takes it instead
$effect(() => {
if (!uiStore.manageModelsOpen) return;
let frames = 0;
let handle = requestAnimationFrame(function focusFilter() {
if (filterInput) {
filterInput.focus({ preventScroll: true });
return;
}
if (frames++ < 20) handle = requestAnimationFrame(focusFilter);
});
return () => cancelAnimationFrame(handle);
});
</script>
<div class="flex shrink-0 items-center gap-2 pb-4">
<SearchInput
bind:ref={filterInput}
bind:value={filter}
class="max-w-64"
placeholder="Search your models"
size="sm"
/>
<ModelsManagerFilters bind:capabilities bind:contextLimit bind:modalities />
{#if hasFilters}
<Button
class="gap-1.5 text-muted-foreground"
onclick={() => {
contextLimit = 0;
modalities = [];
capabilities = [];
}}
size="sm"
variant="ghost"
>
<X class="h-3.5 w-3.5" />
Clear filters
</Button>
{/if}
<div class="ml-auto flex items-center gap-2">
{@render toolbarEnd?.()}
</div>
</div>
@@ -0,0 +1,32 @@
import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
export function modelRowActions(
option: ModelOption,
favorite: boolean,
isHidden: boolean,
onDelete: (option: ModelOption) => void
) {
return [
{
icon: favorite ? HeartOff : Heart,
label: favorite ? 'Remove from favorites' : 'Add to favorites',
onclick: () => modelsStore.toggleFavorite(option.model)
},
{
icon: Trash2,
label: 'Delete from disk',
onclick: () => onDelete(option),
separator: true,
variant: 'destructive' as const
},
{
icon: isHidden ? Eye : EyeOff,
label: isHidden ? 'Unhide model' : 'Hide model',
onclick: () => modelsStore.toggleHidden(option.id),
separator: true
}
];
}
@@ -0,0 +1,150 @@
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ModelModalities, ModelOption } from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
/** What a group folds, which decides its label. */
export type ModelGroupKind = 'quants' | 'variants';
/** One repo of the table, with the rows it ships as. */
export interface ModelQuantGroup {
base: ModelOption;
key: string;
kind: ModelGroupKind;
quants: ModelOption[];
}
/**
* Kind of one collapsible block of the manager's table. A plain constant map, so
* provider blocks can extend the union with their own string kinds.
*/
export const ModelsTableGroupKind = {
FAVORITES: 'favorites',
HIDDEN: 'hidden',
LOADED: 'loaded',
LOCAL: 'local'
} as const;
export type ModelsTableGroupKind = (typeof ModelsTableGroupKind)[keyof typeof ModelsTableGroupKind];
/** Column the manager's table can be ordered by. */
export const ModelsTableSortKey = {
CONTEXT: 'context',
NAME: 'name',
STATUS: 'status'
} as const;
export type ModelsTableSortKey = (typeof ModelsTableSortKey)[keyof typeof ModelsTableSortKey];
/** Header label of each manager section. */
export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
[ModelsTableGroupKind.FAVORITES]: 'Favorites',
[ModelsTableGroupKind.HIDDEN]: 'Hidden models',
[ModelsTableGroupKind.LOADED]: 'Loaded models',
[ModelsTableGroupKind.LOCAL]: 'Local models'
};
/** One collapsible block of the manager's table. */
export interface ModelsTableGroup {
/** One entry per repo, its quants hanging off it. */
items: ModelQuantGroup[];
key: string;
kind: ModelsTableGroupKind;
label: string;
}
/** Modalities a model can accept, as the manager filter offers them. */
export type ModalityKey = keyof ModelModalities;
/** Modalities the manager filter offers, in display order. */
export const MODALITY_KEYS: ModalityKey[] = ['vision', 'video', 'audio'];
/** True when the model is loaded (or sleeping) and not mid-operation. */
export function isModelRunning(option: ModelOption): boolean {
const status = modelsStore.getModelStatus(option.model);
return (
(status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING) &&
!modelsStore.status.isOperationInProgress(option.model)
);
}
/** Context the model runs with: what a loaded model reports. */
export function configuredContext(option: ModelOption): number | null {
return isModelRunning(option) ? modelsStore.props.getModelContextSize(option.model) : null;
}
export function modelSupports(option: ModelOption, capability: ModelCapability): boolean {
if (option.capabilities.includes(capability)) return true;
const template = HuggingFaceService.cachedDetails(option.model)?.gguf?.chat_template ?? '';
return capability === ModelCapability.TOOL_USE
? detectToolUseSupport(template)
: detectThinkingSupport(template);
}
/**
* Context a model reports: the provider listing first, then whatever the Hub has
* cached for it. 0 means nothing is known yet.
*/
export function modelContextLength(option: ModelOption): number {
return (
option.contextLength ??
HuggingFaceService.cachedDetails(option.model)?.gguf?.context_length ??
0
);
}
/** Repo a `repo:quant` id belongs to, the id itself when it carries no quant. */
export function modelRepoKey(model: string): string {
const quant = ModelsService.parseModelId(model).quantization;
return quant ? model.slice(0, model.lastIndexOf(':')) : model;
}
/** Fold the quants of one repo into a single entry, so the table shows one row per model. */
export function groupModelQuants(models: ModelOption[]): ModelQuantGroup[] {
const groups = new Map<string, ModelQuantGroup>();
for (const option of models) {
const key = modelRepoKey(option.model);
const group = groups.get(key);
if (group) {
group.quants.push(option);
continue;
}
groups.set(key, { base: option, key, kind: 'quants', quants: [option] });
}
return Array.from(groups.values()).flatMap((group) => {
const kind = groupKind(group.quants);
const modelIds = group.quants.map((option) => option.model);
// the very same id twice is not a quant set; keep those rows apart
if (modelIds.length > 1 && modelIds.every((model) => model === modelIds[0])) {
return group.quants.map((option) => ({
...group,
base: option,
key: `${group.key}::${option.id}`,
kind: 'variants' as const,
quants: [option]
}));
}
return [{ ...group, kind }];
});
}
/** What a group folds: quants or variants of one repo. */
function groupKind(quants: ModelOption[]): ModelGroupKind {
const isQuant = quants.every(
(option) => (option.parsedId ?? ModelsService.parseModelId(option.model)).quantization
);
return isQuant ? 'quants' : 'variants';
}
@@ -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>
@@ -12,11 +12,13 @@
} from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import { MODEL_ID, MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { ModelsService } from '$lib/services/models.service';
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption, ModelSidecarBadge } from '$lib/types/models';
import { modelLoadFraction } from '$lib/utils';
interface Props {
@@ -66,6 +68,27 @@
const reasoning = useReasoningMenu();
/** Draft sidecar as it reads in the trigger tooltip, with its own quant. */
function draftSidecarLabel(baseModel: string, badge: ModelSidecarBadge): string {
const baseRepo = baseModel.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0];
// a sidecar of the model's own repo reads as a bare tag, a foreign one keeps its id
if (badge.repo === baseRepo) {
return `${badge.kind.toUpperCase()}${badge.quant ? `:${badge.quant}` : ''}`;
}
return ModelsService.buildDownloadTag(badge.repo, badge.quant, badge.kind);
}
/** Raw id of the selected model, plus every draft sidecar it pulls. */
function triggerTooltipLabel(option: ModelOption): string {
const drafts = (option.draftSidecars ?? []).map((badge) =>
draftSidecarLabel(option.model, badge)
);
return [option.model, ...drafts].join(' + ');
}
const showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
@@ -229,8 +252,8 @@
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
draftSidecars={selectedOption.draftSidecars}
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
@@ -257,7 +280,7 @@
{#if selectedOption}
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
<p class="font-mono">{triggerTooltipLabel(selectedOption)}</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
@@ -274,8 +297,8 @@
{#if selectedOption}
<ModelId
class="min-w-0 flex-1 overflow-hidden"
draftSidecars={selectedOption.draftSidecars}
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
@@ -381,8 +404,8 @@
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
draftSidecars={selectedOption.draftSidecars}
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{/if}
@@ -400,7 +423,7 @@
{#if selectedOption}
<Tooltip.Content>
<p class="font-mono">{selectedOption.model}</p>
<p class="font-mono">{triggerTooltipLabel(selectedOption)}</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
@@ -87,6 +87,7 @@
<ModelId
aliases={option.aliases}
class="flex-1"
draftSidecars={option.draftSidecars}
{hideOrgName}
{modalities}
modelId={option.model}
@@ -56,7 +56,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,24 @@ export { default as ModelId } from './ModelId.svelte';
* styling stay consistent across every model-id surface.
*/
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
/**
* **ModelDraftSidecars** - draft sidecar badges
*
* The `mtp|Q8_0` style badges for the draft sidecars a model pulls with it,
* shared by ModelId and the manager's quant rows so they render identically
* everywhere.
*/
export { default as ModelDraftSidecars } from './ModelDraftSidecars.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';
@@ -5,6 +5,7 @@
import {
ActionIcon,
DialogConversationRename,
DialogManageModels,
DialogSettingsChat,
Logo,
SidebarNavigationActions,
@@ -389,6 +390,7 @@
bind:searchQuery
class="px-2"
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
onManageModelsClick={() => uiStore.openModelsManager()}
onNewChat={() => {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
@@ -452,6 +454,8 @@
<DialogSettingsChat bind:open={settingsDialogOpen} />
<DialogManageModels bind:open={uiStore.manageModelsOpen} />
<style>
aside {
@media (max-width: 768px) {
@@ -25,6 +25,7 @@
searchQuery: string;
onSearchDeactivated?: () => void;
onSearchClick?: () => void;
onManageModelsClick?: () => void;
onNewChat?: () => void;
onSettingsClick?: () => void;
}
@@ -33,6 +34,7 @@
class: className,
isExpandedMode = false,
isSearchModeActive = $bindable(false),
onManageModelsClick,
onNewChat,
onSearchClick,
onSearchDeactivated,
@@ -46,6 +48,8 @@
const isOnMobile = $derived(deviceStore.isMobile);
const actionsItems = SIDEBAR_ACTIONS_ITEMS;
$effect(() => {
if (isSearchModeActive && searchInputRef) {
searchInputRef.focus();
@@ -57,7 +61,7 @@
setTimeout(() => {
initialized = true;
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * actionsItems.length);
});
function handleSearchModeDeactivate() {
@@ -107,7 +111,7 @@
? 'hidden pointer-events-none'
: ''}"
>
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{#each actionsItems as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
@@ -117,16 +121,18 @@
onNewChat?.();
void conversationsStore.openNewChat();
}
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
: item.action === SidebarAction.MANAGE_MODELS
? () => onManageModelsClick?.()
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -164,7 +170,7 @@
</div>
{:else}
<div class="{className} flex-col gap-1 hidden md:flex">
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{#each actionsItems as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick =
@@ -173,16 +179,18 @@
onNewChat?.();
void conversationsStore.openNewChat();
}
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
: item.action === SidebarAction.MANAGE_MODELS
? () => onManageModelsClick?.()
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -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}
/>
@@ -0,0 +1,2 @@
/** Marks the built-in local llama.cpp backend. */
export const LOCAL_BACKEND_ID = 'local';
@@ -23,6 +23,31 @@ export const DIALOG_SUBMENU_CONTENT = 'w-60';
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
/** 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}
`;
/** Column grid shared by every row of the models manager table. */
export const MODEL_ROW_GRID_CLASS =
'grid grid-cols-[minmax(0,1fr)_11rem_3rem_4.5rem] items-center gap-4';
/** 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';
/** Emphasis model badge: draft sidecars and other markers that must stand out. */
export const MODEL_VARIANT_BADGE_CLASS =
'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';
/** Default Tailwind size class for inline icon components (lucide, etc.). */
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
@@ -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,
+1
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';
@@ -26,9 +26,11 @@ export const SETTINGS_KEYS = {
DYNATEMP_EXPONENT: 'dynatemp_exponent',
DYNATEMP_RANGE: 'dynatemp_range',
ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
ENABLE_DISCOVER_MODELS: 'enableDiscoverModels',
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
FREQUENCY_PENALTY: 'frequency_penalty',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
GROUP_MODELS_BY_FAMILY: 'groupModelsByFamily',
JS_SANDBOX_ENABLED: 'jsSandboxEnabled',
MAX_IMAGE_RESOLUTION: 'maxImageMPixels',
MAX_TOKENS: 'max_tokens',
@@ -125,6 +125,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Enable "Continue" button',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: true,
help: 'Show the Discover Models sidebar action to browse and download HuggingFace GGUF models. Only available in router mode.',
key: SETTINGS_KEYS.ENABLE_DISCOVER_MODELS,
label: 'Enable Discover Models',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: true,
help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
@@ -296,6 +303,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show build version information',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: true,
help: 'Group models into families (e.g. "Qwen") in the model picker and the models manager. Turn it off to list every model on its own.',
key: SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY,
label: 'Group models by family',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Display the full file system path inside file and folder @-mention badges instead of just the file or folder name.',
@@ -27,6 +27,18 @@ export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabled
/** Default disabled tool categories, seeded into newly created conversations */
export const DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolCategories`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
/** Open state the user set for a model list section or one of its families, by id. */
export const MODEL_GROUP_OPEN_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.modelGroupOpen`;
/** Recently used model ids, most recent first, backend-qualified. */
export const RECENT_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.recentModels`;
/** Last use timestamp per backend-qualified model id. */
export const RECENT_MODEL_USAGE_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.recentModelUsage`;
/** Model ids kept out of the selector, backend-qualified. */
export const HIDDEN_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.hiddenModels`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
+18 -3
View File
@@ -1,4 +1,5 @@
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import { MODEL_ICON } from './icons.constants';
import { Search, Settings, SquarePen } from '@lucide/svelte';
import { SidebarAction, ToolSource } from '$lib/enums';
import type { DesktopIconStripItem } from '$lib/types';
@@ -47,8 +48,17 @@ export const DEFAULT_MOBILE_BREAKPOINT = 768;
/** Orgs whose avatar is dark and needs inverting in dark mode. */
export const DARK_INVERT_AVATAR_ORGS = ['openai'];
/** Icon used for the model selector and the `/model` slash command. */
export const MODEL_SELECTOR_ICON = Package;
/**
* Model rows mounted before the list is scrolled. A local catalog can hold
* hundreds of models and mounting them all costs seconds on open.
*/
export const MODEL_ROW_WINDOW = 50;
/** Models of one family shown before the rest fold behind a "show more" row. */
export const FAMILY_ROW_WINDOW = 6;
/** Recently used models kept per browser, most recent first. */
export const RECENT_MODEL_LIMIT = 20;
export const ICON_STRIP_TRANSITION_DURATION = 150;
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
@@ -64,6 +74,11 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
tooltip: 'New chat'
},
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
action: SidebarAction.MANAGE_MODELS,
icon: MODEL_ICON,
tooltip: 'Models'
},
{
action: SidebarAction.SETTINGS,
icon: Settings,
+1
View File
@@ -23,6 +23,7 @@ export enum ScrollCarouselVariant {
* Sidebar icon strip actions handled directly by the sidebar.
*/
export enum SidebarAction {
MANAGE_MODELS = 'manage-models',
NEW_CHAT = 'new-chat',
SETTINGS = 'settings'
}
+124 -36
View File
@@ -72,6 +72,7 @@ import type {
HfModelSibling
} from '$lib/types/huggingface';
import { sidecarFromFileToken } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
/** Fetch failure carrying the HTTP status, so retry logic tests the code instead of the message. */
class HfHttpStatusError extends Error {
@@ -96,6 +97,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).
*/
@@ -135,6 +143,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
@@ -173,8 +198,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.
@@ -330,6 +353,8 @@ export class HuggingFaceService {
return `${Math.floor(diffDays / DAYS_PER_YEAR)} ${YEARS_AGO_LABEL}`;
}
// Model Details & Files
/**
* Format a min-max size range with one shared unit, e.g. `19.0-28.6 GB`.
*/
@@ -355,8 +380,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.
@@ -373,16 +396,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;
@@ -392,11 +419,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;
}
@@ -452,26 +479,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;
}
static getModelUrl(modelId: string): string {
@@ -519,19 +577,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[];
@@ -539,17 +606,38 @@ 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;
}
static async getTrending(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
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
+308 -33
View File
@@ -7,23 +7,130 @@
* {@link ModelsStore.status}; tracks which conversations use which models.
*/
import { FAVORITE_MODELS_LOCALSTORAGE_KEY } from '$lib/constants';
import { browser } from '$app/environment';
import {
FAVORITE_MODELS_LOCALSTORAGE_KEY,
HIDDEN_MODELS_LOCALSTORAGE_KEY,
MODEL_GROUP_OPEN_LOCALSTORAGE_KEY,
MODEL_ID,
MODEL_ROW_WINDOW,
RECENT_MODEL_LIMIT,
RECENT_MODEL_USAGE_LOCALSTORAGE_KEY,
RECENT_MODELS_LOCALSTORAGE_KEY,
SETTINGS_KEYS
} from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { HuggingFaceService } from '$lib/services/huggingface.service';
import { ModelsService } from '$lib/services/models.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
import { type ModelPropsHost, ModelPropsManager } from '$lib/stores/models/props.svelte';
import { type ModelStatusHost, ModelStatusManager } from '$lib/stores/models/status.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { ModelSidecarBadge, ParsedModelId } from '$lib/types/models';
import { getConversationModel } from '$lib/utils/conversation-utils';
import { SvelteSet } from 'svelte/reactivity';
import { isAuxSidecar } from '$lib/utils/sidecars';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
/** Group open states kept before the oldest ones fall off; the map only ever grows. */
const MAX_GROUP_OPEN_ENTRIES = 200;
/** Union of the draft sidecar badges two sources report for one repo. */
function mergedDraftSidecars(
fromListing: ModelSidecarBadge[] | undefined,
fromArgs: ModelSidecarBadge[]
): ModelSidecarBadge[] {
if (!fromListing || fromListing.length === 0) return fromArgs;
if (fromArgs.length === 0) return fromListing;
const merged = [...fromListing];
for (const badge of fromArgs) {
if (!merged.some((b) => b.kind === badge.kind && b.repo === badge.repo)) merged.push(badge);
}
return merged;
}
/** Last use timestamp per backend-qualified model id. */
function loadRecentModelUsage(): Record<string, number> {
if (!browser) return {};
try {
const raw = localStorage.getItem(RECENT_MODEL_USAGE_LOCALSTORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as Record<string, number>) : {};
} catch {
return {};
}
}
/** Models kept out of the selector. */
function loadHiddenModels(): Set<string> {
if (!browser) return new SvelteSet<string>();
try {
const raw = localStorage.getItem(HIDDEN_MODELS_LOCALSTORAGE_KEY);
return raw ? new SvelteSet(JSON.parse(raw) as string[]) : new SvelteSet<string>();
} catch {
return new SvelteSet<string>();
}
}
/** Open state the user set for a section or family of the model lists. */
function loadGroupOpenState(): SvelteMap<string, boolean> {
if (!browser) return new SvelteMap<string, boolean>();
try {
const raw = localStorage.getItem(MODEL_GROUP_OPEN_LOCALSTORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
const entries =
parsed && typeof parsed === 'object' ? Object.entries(parsed as Record<string, unknown>) : [];
return new SvelteMap(
entries.filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean')
);
} catch {
return new SvelteMap<string, boolean>();
}
}
/** Recently used backend-qualified ids, most recent first. */
function loadRecentModels(): string[] {
if (!browser) return [];
try {
const raw = localStorage.getItem(RECENT_MODELS_LOCALSTORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed)
? parsed.filter((id): id is string => typeof id === 'string').slice(0, RECENT_MODEL_LIMIT)
: [];
} catch {
return [];
}
}
class ModelsStore implements ModelPropsHost, ModelStatusHost {
error = $state<string | null>(null);
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
groupOpenState = $state<SvelteMap<string, boolean>>(loadGroupOpenState());
hiddenModelIds = $state<Set<string>>(loadHiddenModels());
loading = $state(false);
models = $state<ModelOption[]>([]);
recentModelIds = $state<string[]>(loadRecentModels());
recentModelUsage = $state<Record<string, number>>(loadRecentModelUsage());
routerModels = $state<ApiModelDataEntry[]>([]);
selectedModelId = $state<string | null>(null);
selectedModelName = $state<string | null>(null);
@@ -36,7 +143,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
/** Load/unload operations and the /models/sse status feed, composed here. */
private _status = new ModelStatusManager(this);
// Dedup concurrent fetch() callers — all awaiters share the same inflight promise.
// Dedup concurrent fetch() callers - all awaiters share the same inflight promise.
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
private inflightFetch: Promise<void> | null = null;
@@ -119,6 +226,15 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
this.selectedModelName = null;
}
/** Family keys folded away under one section, for a list that restores them. */
collapsedGroupsUnder(prefix: string): string[] {
const head = `${prefix}-`;
return [...this.groupOpenState]
.filter(([id, open]) => !open && id.startsWith(head))
.map(([id]) => id.slice(head.length));
}
/**
* Auto-selects the first available model if none is selected.
* Prioritizes:
@@ -162,11 +278,15 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return;
}
// Try loading a favorite model
// Try a favorite model, but only one that exists on this backend: favorites
// are shared across backends, so a stored id may belong to another one
const favorite = this.favoriteModelIds.values().next()?.value;
const favoriteOption = favorite
? availableModels.find((m) => m.id === favorite || m.model === favorite)
: undefined;
if (favorite) {
await this.selectModelById(favorite);
if (favoriteOption) {
await this.selectModelById(favoriteOption.id);
return;
}
@@ -207,6 +327,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
// keep the selector options in sync: a downloaded / deleted model shows
// up here too, not only in the router model rows
this.models = this.buildModelOptions(response);
this.warmHubDetails();
await this.props.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
@@ -254,7 +375,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
getModelStatus(modelId: string): ServerModelStatus | null {
const model = this.routerModels.find((m) => m.id === modelId);
return model?.status.value ?? null;
return (model?.status?.value as ServerModelStatus) ?? null;
}
hasModel(modelName: string): boolean {
@@ -265,19 +386,32 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return this.favoriteModelIds.has(modelId);
}
isModelLoaded(modelId: string): boolean {
const model = this.routerModels.find((m) => m.id === modelId);
return (
model?.status.value === ServerModelStatus.LOADED ||
model?.status.value === ServerModelStatus.SLEEPING
);
isGroupOpen(id: string, fallbackOpen: boolean): boolean {
return this.groupOpenState.get(id) ?? fallbackOpen;
}
async selectModelById(modelId: string): Promise<void> {
isHidden(modelId: string): boolean {
return this.hiddenModelIds.has(modelId);
}
isModelLoaded(modelId: string): boolean {
const status = this.getModelStatus(modelId);
return status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING;
}
/**
* Select a model. `recordRecent` marks a pick the user made in the selector, so
* automatic picks (startup default, conversation sync) stay out of the recency list.
*/
async selectModelById(modelId: string, options?: { recordRecent?: boolean }): Promise<void> {
if (!modelId || this.updating) return;
if (this.selectedModelId === modelId) return;
if (this.selectedModelId === modelId) {
if (options?.recordRecent) this.recordRecentModel(modelId);
return;
}
const option = this.models.find((model) => model.id === modelId);
@@ -289,6 +423,8 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
try {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
if (options?.recordRecent) this.recordRecentModel(modelId);
} finally {
this.updating = false;
}
@@ -301,8 +437,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const option = this.models.find((model) => model.model === modelName);
if (option) {
this.selectedModelId = option.id;
this.selectedModelName = option.model;
void this.selectModelById(option.id);
}
}
@@ -331,20 +466,16 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
return candidate && candidate.trim().length > 0 ? candidate : id;
}
toggleFavorite(modelId: string): void {
/** Add or remove several models at once, e.g. every quant of a family. */
setFavorites(modelIds: string[], favorite: boolean): void {
const next = new SvelteSet(this.favoriteModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
for (const modelId of modelIds) {
if (favorite) {
next.add(modelId);
} else {
next.delete(modelId);
}
}
this.favoriteModelIds = next;
@@ -356,6 +487,86 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
setGroupOpen(id: string, open: boolean): void {
const next = new SvelteMap(this.groupOpenState);
// re-set moves the id to the end, so the oldest states fall off first
next.delete(id);
next.set(id, open);
while (next.size > MAX_GROUP_OPEN_ENTRIES) {
const oldest = next.keys().next();
if (oldest.done) break;
next.delete(oldest.value);
}
this.groupOpenState = next;
try {
localStorage.setItem(
MODEL_GROUP_OPEN_LOCALSTORAGE_KEY,
JSON.stringify(Object.fromEntries(next))
);
} catch {
toast.error('Failed to save the model list state to local storage');
}
}
toDisplayName(id: string): string {
const segments = id.split(/\\|\//);
const candidate = segments.pop();
return candidate && candidate.trim().length > 0 ? candidate : id;
}
toggleFavorite(modelId: string): void {
this.setFavorites([modelId], !this.favoriteModelIds.has(modelId));
}
/** Models hidden from the selector stay in the manager, flagged and unhideable. */
toggleHidden(modelId: string): void {
const next = new SvelteSet(this.hiddenModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
this.hiddenModelIds = next;
try {
localStorage.setItem(HIDDEN_MODELS_LOCALSTORAGE_KEY, JSON.stringify([...next]));
} catch {
toast.error('Failed to save hidden models to local storage');
}
}
/**
* Warm the Hub record for the local repos, so a list can read the context and the
* size the server only reports once a model is loaded. Best effort, and only for
* installs that opted into the Hub.
*/
warmHubDetails(): void {
if (!settingsStore.config[SETTINGS_KEYS.ENABLE_DISCOVER_MODELS]) return;
const repos: string[] = [];
for (const option of this.models) {
const repo = option.model.split(':')[0];
if (repo?.includes('/') && !repos.includes(repo)) repos.push(repo);
}
// a large catalog would fire one request per repo on every load, so this warms
// the ones the lists mount first and lets the rest arrive on demand
for (const repo of repos.slice(0, MODEL_ROW_WINDOW)) {
void HuggingFaceService.getDetails(repo).catch(() => {});
}
}
/**
* Build ModelOption[] from an API response.
* Both MODEL and ROUTER modes share the same mapping logic;
@@ -365,15 +576,43 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const entries: {
details?: ApiModelsListResponse['models'][number];
item: ApiModelDataEntry;
parsed: ParsedModelId;
}[] = response.data.map((item: ApiModelDataEntry, index: number) => ({
details: response.models?.[index],
item
item,
parsed: ModelsService.parseModelId(item.id)
}));
// sidecar entries mark downloaded sidecar files, not loadable models; the
// router lists them under their own `<repo>:<quant>-<sidecar>` id, so pair
// the drafts with their repo here and drop the entries below
const draftSidecarsByRepo = new SvelteMap<string, ModelSidecarBadge[]>();
for (const { item, parsed } of entries) {
if (!parsed.sidecar || isAuxSidecar(parsed.sidecar)) continue;
const repo = item.id.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0];
// the entry id's quant is the sidecar file's own quant
const badge: ModelSidecarBadge = {
kind: parsed.sidecar,
quant: parsed.quantization,
repo
};
const kinds = draftSidecarsByRepo.get(repo);
if (kinds) {
if (!kinds.some((b) => b.kind === badge.kind && b.repo === badge.repo)) {
kinds.push(badge);
}
} else {
draftSidecarsByRepo.set(repo, [badge]);
}
}
return (
entries
// sidecar entries mark downloaded sidecar files, not loadable models
.filter(({ item }) => !ModelsService.isSidecarEntry(item.id))
// the router also lists projector files by filename, which carry the
// sidecar token inside the id rather than after a colon
.filter(({ item, parsed }) => !parsed.sidecar && !ModelsService.isSidecarEntry(item.id))
// in-flight downloads are not usable models yet; the selector tracks
// them in its "Download in progress" section instead
.filter(({ item }) => item.status?.value !== ServerModelStatus.DOWNLOADING)
@@ -382,6 +621,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const displayNameSource =
details?.name && details.name.trim().length > 0 ? details.name : item.id;
const modelId = details?.model || item.id;
const repo = modelId.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0];
return {
aliases: item.aliases ?? [],
@@ -390,6 +630,12 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
),
description: details?.description,
details: details?.details,
draftSidecars: mergedDraftSidecars(
draftSidecarsByRepo.get(repo),
// models the router serves with a draft record it in their
// --model-draft args instead of a listing entry
this._status.getDraftSidecars(repo)
),
id: item.id,
meta: item.meta ?? null,
modalities: this.props.buildArchitectureModalities(item.architecture),
@@ -427,6 +673,35 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
/** Move a model to the front of the recently used list. */
private recordRecentModel(qualifiedId: string): void {
this.recentModelIds = [
qualifiedId,
...this.recentModelIds.filter((id) => id !== qualifiedId)
].slice(0, RECENT_MODEL_LIMIT);
// only the recent ids ever read their usage stamp, prune the rest
const usage: Record<string, number> = {};
for (const id of this.recentModelIds) {
usage[id] = id === qualifiedId ? Date.now() : (this.recentModelUsage[id] ?? 0);
}
this.recentModelUsage = usage;
if (!browser) return;
try {
localStorage.setItem(RECENT_MODELS_LOCALSTORAGE_KEY, JSON.stringify(this.recentModelIds));
localStorage.setItem(
RECENT_MODEL_USAGE_LOCALSTORAGE_KEY,
JSON.stringify(this.recentModelUsage)
);
} catch {
console.warn('[ModelsStore] Failed to persist the recently used models');
}
}
private async runFetch(): Promise<void> {
this.loading = true;
this.error = null;
@@ -20,6 +20,8 @@ import type { ModelPropsManager } from '$lib/stores/models/props.svelte';
import { serverStore } from '$lib/stores/server.svelte';
// explicit type imports: the app.d.ts globals resolve to `any`, so import the real types
import type { ApiModelsSseDownloadProgressData, ModelDownloadProgress } from '$lib/types';
import type { ModelSidecarBadge } from '$lib/types/models';
import { isAuxSidecar } from '$lib/utils/sidecars';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -84,7 +86,57 @@ export class ModelStatusManager {
return result;
});
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
/**
* Draft sidecars the registered models pull with them, keyed by the repo of
* the model that pulls them. Read from the models' `--model-draft` args,
* which is where the router records a sidecar it serves with a model; the
* listing carries no sidecar entries of its own. The badge keeps the
* sidecar's own repo, which may differ from the model's.
*/
private draftSidecarsByRepo = $derived.by(() => {
const result = new SvelteMap<string, ModelSidecarBadge[]>();
for (const m of this.host.routerModels) {
const args = m.status?.args;
if (!args) continue;
const baseRepo = m.id.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0];
for (let i = 0; i < args.length - 1; i++) {
if (args[i] !== CLI_FLAGS.MODEL_DRAFT && args[i] !== CLI_FLAGS.MODEL_DRAFT_SHORT) {
continue;
}
const parsed = HuggingFaceService.parseCachePath(args[i + 1]);
if (!parsed) continue;
const meta = HuggingFaceService.extractQuantMeta(parsed.file);
if (!meta?.sidecar || isAuxSidecar(meta.sidecar)) continue;
const badge: ModelSidecarBadge = {
kind: meta.sidecar,
quant: meta.quant,
repo: parsed.repo
};
const kinds = result.get(baseRepo);
if (kinds) {
if (!kinds.some((b) => b.kind === badge.kind && b.repo === badge.repo)) {
kinds.push(badge);
}
} else {
result.set(baseRepo, [badge]);
}
}
}
return result;
});
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
private failedDownloads = new SvelteSet<string>();
private loadingStates = new SvelteMap<string, boolean>();
@@ -253,6 +305,11 @@ export class ModelStatusManager {
return this.downloadProgress.get(repoWithTag) ?? null;
}
/** Draft sidecars a repo pulls with its model, e.g. `mtp` at `Q8_0`. */
getDraftSidecars(repoId: string): ModelSidecarBadge[] {
return this.draftSidecarsByRepo.get(repoId) ?? [];
}
getLoadProgress(modelId: string): ModelLoadProgress | null {
return this.loadProgress.get(modelId) ?? null;
}
+26
View File
@@ -7,8 +7,34 @@
*/
class UiStore {
/** Set when a flow outside the chat wants the composer focused once it can take it. */
composerFocusRequested = $state(false);
/** Whether the desktop sidebar is expanded (open). */
isSidebarExpanded = $state(false);
/** Model the manager reveals when it opens, a qualified id or a raw model name. */
manageModelFocus = $state<string | null>(null);
/** Open state of the models manager, driven from the sidebar and from model rows. */
manageModelsOpen = $state(false);
/** Read and clear the request, so it fires once. */
consumeComposerFocus(): boolean {
const requested = this.composerFocusRequested;
this.composerFocusRequested = false;
return requested;
}
/** Open the models manager, optionally focused on one model. */
openModelsManager(focus?: string): void {
this.manageModelFocus = focus ?? null;
this.manageModelsOpen = true;
}
/** Ask the composer to take focus, e.g. after a dialog closes onto the chat. */
requestComposerFocus(): void {
this.composerFocusRequested = true;
}
}
export const uiStore = new UiStore();
+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) */
+32
View File
@@ -18,6 +18,10 @@ export interface ModelOption {
model: string;
description?: string;
capabilities: string[];
/** Context size reported by the provider's model listing, when it reports one. */
contextLength?: number;
/** Draft sidecars downloaded alongside the model, e.g. `mtp` or `dflash`. */
draftSidecars?: ModelSidecarBadge[];
modalities?: ModelModalities;
details?: ApiModelDetails['details'];
meta?: ApiModelDataEntry['meta'];
@@ -58,9 +62,37 @@ export interface ParsedModelId {
tags: string[];
}
/**
* Draft sidecar available for a model, with the quantization its file ships
* as and the repo it was pulled from, e.g. `mtp` at `Q8_0` of the model's own
* repo. Shown as a badge pair next to the model id.
*/
export interface ModelSidecarBadge {
kind: ModelSidecar;
/** Quantization of the sidecar file itself; null when the name carries none. */
quant: string | null;
/** Repo the sidecar file belongs to, which may differ from the model's. */
repo: string;
}
/** Modality capabilities for file validation. */
export interface ModalityCapabilities {
hasVision: boolean;
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;
}
+5 -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, sidecarFromTag } from './sidecars';
@@ -360,3 +360,7 @@ 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';
// Svelte actions
export { nearViewport } from './near-viewport';
+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());
}
+35
View File
@@ -0,0 +1,35 @@
import type { ActionReturn } from 'svelte/action';
/** Margin around the viewport that already counts as "near". */
const NEAR_VIEWPORT_MARGIN = '200px';
/**
* Calls back once the element comes near the viewport. Long lists mount
* hundreds of rows at once, so work that costs a request per row (avatars,
* Hub details) waits for this instead of firing on mount.
*/
export function nearViewport(node: HTMLElement, onNear: () => void): ActionReturn {
if (typeof IntersectionObserver === 'undefined') {
onNear();
return {};
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
onNear();
}
},
{ rootMargin: NEAR_VIEWPORT_MARGIN }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};
}