feat: WIP

This commit is contained in:
Aleksander Grygier
2026-08-25 09:30:23 +02:00
parent 73a07248c9
commit 21c61123a9
33 changed files with 974 additions and 670 deletions
@@ -1,5 +1,12 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Info, LoaderCircle, PencilRuler } from '@lucide/svelte';
import {
Check,
ChevronDown,
ChevronRight,
Info,
LoaderCircle,
PencilRuler
} from '@lucide/svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -55,6 +55,7 @@
import { detectIncompleteCodeBlock, highlightCode, type IncompleteCodeBlock } from '$lib/utils';
import { sanitizeSvg } from '$lib/utils/sanitize-svg';
import { mountSvgShadow } from '$lib/utils/svg-shadow';
import DOMPurify from 'dompurify';
import type { Root as HastRoot, RootContent as HastRootContent } from 'hast';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
@@ -77,6 +78,8 @@
content: string;
class?: string;
disableMath?: boolean;
/** Render raw HTML found in the markdown (sanitized) instead of escaping it. */
allowHtml?: boolean;
}
interface MarkdownBlock {
@@ -85,7 +88,13 @@
contentHash?: string;
}
let { attachments, class: className = '', content, disableMath = false }: Props = $props();
let {
allowHtml = false,
attachments,
class: className = '',
content,
disableMath = false
}: Props = $props();
let containerRef = $state<HTMLDivElement>();
let renderedBlocks = $state<MarkdownBlock[]>([]);
@@ -148,6 +157,7 @@
let processor = $derived(() => {
void attachments;
void allowHtml;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let proc: any = remark().use(remarkGfm); // GitHub Flavored Markdown
@@ -155,10 +165,15 @@
proc = proc.use(remarkMath); // Parse $inline$ and $$block$$ math
}
proc = proc
.use(remarkBreaks) // Convert line breaks to <br>
.use(remarkLiteralHtml) // Treat raw HTML as literal text with preserved indentation
.use(remarkRehype); // Convert Markdown AST to rehype
proc = proc.use(remarkBreaks); // Convert line breaks to <br>
if (!allowHtml) {
// Treat raw HTML as literal text with preserved indentation
proc = proc.use(remarkLiteralHtml);
}
// Convert Markdown AST to rehype. Keep raw HTML as-is when allowHtml is set.
proc = proc.use(remarkRehype, allowHtml ? { allowDangerousHtml: true } : undefined);
if (!disableMath) {
proc = proc.use(rehypeKatex); // Render math using KaTeX
@@ -261,10 +276,11 @@
const singleNodeRoot = { children: [node], type: 'root' };
const transformedRoot = (await processorInstance.run(singleNodeRoot as MdastRoot)) as HastRoot;
const html = processorInstance.stringify(transformedRoot);
const safeHtml = allowHtml ? (DOMPurify.sanitize(html) as unknown as string) : html;
transformCache.set(hash, html);
transformCache.set(hash, safeHtml);
return { hash, html };
return { hash, html: safeHtml };
}
/**
@@ -463,6 +479,10 @@
)) as HastRoot;
unstableHtml = processorInstance.stringify(transformedRoot);
if (allowHtml) {
unstableHtml = DOMPurify.sanitize(unstableHtml) as unknown as string;
}
}
renderedBlocks = nextBlocks;
@@ -1,7 +1,6 @@
<script lang="ts">
import { Boxes } from '@lucide/svelte';
import { SearchInput } from '$lib/components/app';
import { ModelsDiscoverList, ModelsDiscoverDetails } from '$lib/components/app/models/discover';
import { ModelsDiscoverDetails, ModelsDiscoverList } from '$lib/components/app/models/discover';
import * as Dialog from '$lib/components/ui/dialog';
import { modelsHubStore } from '$lib/stores';
import { untrack } from 'svelte';
@@ -57,42 +56,43 @@
<Dialog.Root {open} onOpenChange={handleOpenChange}>
<Dialog.Content
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!" style="grid-template-columns: auto 1fr;"
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!"
style="grid-template-columns: auto 1fr;"
>
<aside
class="sticky top-0 w-100 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1 md:max-h-239.5!"
>
<div class="p-2 sticky top-0 z-99">
<SearchInput
class=""
bind:value={searchQuery}
placeholder="Search models..."
onInput={handleSearchInput}
<aside
class="w-108 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
>
<div class="p-2 sticky top-0 z-99">
<SearchInput
class=""
bind:value={searchQuery}
placeholder="Search models..."
onInput={handleSearchInput}
/>
</div>
<div>
{#if modelsHubStore.loading}
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
{:else if modelsHubStore.error}
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
{:else if modelsHubStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsDiscoverList
models={modelsHubStore.models}
activeId={selectedId}
showBaseModelAvatar
onSelect={(id) => (selectedId = id)}
/>
</div>
<div>
{#if modelsHubStore.loading}
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
{:else if modelsHubStore.error}
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
{:else if modelsHubStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsDiscoverList
models={modelsHubStore.models}
activeId={selectedId}
showBaseModelAvatar
onSelect={(id) => (selectedId = id)}
/>
{/if}
</div>
</aside>
<main>
{#if selectedId}
<ModelsDiscoverDetails modelId={selectedId} />
{/if}
</main>
</div>
</aside>
<main class="overflow-y-auto">
{#if selectedId}
<ModelsDiscoverDetails modelId={selectedId} />
{/if}
</main>
</Dialog.Content>
</Dialog.Root>
@@ -1,23 +1,36 @@
<script lang="ts">
import { Image, Lightbulb, Mic, Video } from '@lucide/svelte';
import { type DraftVariant } from '$lib/constants';
import { Database, Image, Lightbulb, Mic, ScrollText, Video, Wrench } from '@lucide/svelte';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { type DraftVariant } 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 { formatParameters } from '$lib/utils';
interface Props {
modelId: string;
hideOrgName?: boolean;
hideName?: boolean;
hideModalities?: boolean;
hideReasoning?: boolean;
hideParameters?: boolean;
showRaw?: boolean;
showRawTooltip?: boolean;
hideQuantization?: boolean;
hideTags?: boolean;
aliases?: string[];
tags?: string[];
/** Render the capability/modality/context icons on a second row. */
iconsOnNewLine?: boolean;
modalities?: ModelModalities;
supportsThinking?: boolean;
supportsToolUse?: boolean;
/** Context length in tokens; renders a context icon when set. */
contextLength?: number;
/** Min/max GGUF file size (main + draft) across quants; renders a range when set. */
sizeRange?: { min: number; max: number } | null;
draftVariants?: DraftVariant[];
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
@@ -27,15 +40,23 @@
let {
aliases,
class: className = '',
contextLength,
draftVariants,
hideModalities = false,
hideName = false,
hideOrgName = false,
hideParameters = false,
hideQuantization,
hideReasoning = false,
hideTags,
iconsOnNewLine = false,
modalities,
modelId,
showRaw = undefined,
showRawTooltip = false,
sizeRange,
supportsThinking = false,
supportsToolUse = false,
tags,
wrap = false,
...rest
@@ -60,9 +81,7 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
let uniqueDraftVariants = $derived([...new Set(draftVariants ?? [])]);
let hasModalityIcons = $derived(
supportsThinking || modalities?.vision || modalities?.video || modalities?.audio
);
let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
@@ -72,9 +91,11 @@
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
{:else}
{#snippet nameAndBadges()}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{#if !hideName}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{/if}
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
{#if parsed.variant}
@@ -83,18 +104,18 @@
</span>
{/if}
{#if parsed.params && !hideParameters}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#each uniqueDraftVariants as variant (variant)}
<span class={variantBadgeClass} title={`${variant.toUpperCase()} draft model available`}>
{variant}
</span>
{/each}
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
@@ -119,71 +140,103 @@
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {className}" {...rest}>
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<span
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
? 'flex-col items-start'
: ''} {className}"
{...rest}
>
<span class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''}">
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
{#if hasModalityIcons}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#if supportsThinking}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#if supportsToolUse}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if supportsThinking && !hideReasoning}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#if hasModalityIcons && !hideModalities}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.video}
<Tooltip.Root>
<Tooltip.Trigger>
<Video class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Video</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.video}
<Tooltip.Root>
<Tooltip.Trigger>
<Video class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#if modalities?.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<Mic class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Audio</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
</span>
<Tooltip.Content>
<p>Video</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<span class="inline-flex items-center gap-1.5">
{#if contextLength}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<ScrollText class="h-3 w-3" />
<span class="text-xs">{formatParameters(contextLength)}</span>
</span>
{/if}
{#if modalities?.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<Mic class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Audio</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
{#if sizeRange}
<span class="inline-flex items-center gap-1 text-muted-foreground">
<Database class="h-3 w-3" />
<span class="text-xs"
>{HuggingFaceService.formatSizeRange(sizeRange.min, sizeRange.max)}</span
>
</span>
{/if}
</span>
</span>
{/if}
@@ -1,16 +1,31 @@
<script lang="ts">
import { HuggingFaceService } from '$lib/services';
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
import * as Tooltip from '$lib/components/ui/tooltip';
import { DARK_INVERT_AVATAR_ORGS } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
interface Props {
/** Org whose avatar is shown (may differ from the repo's org for base models). */
org: string;
/** Repo's own org, shown as a small corner badge when provided. */
quantOrg?: string;
/** Tailwind size classes for the main avatar (default `h-9 w-9`). */
size?: string;
/** Extra classes appended to the base (main) image. */
baseImageClass?: string;
/** Size classes for the quant corner badge image (default `h-full w-full`). */
quantImageClass?: string;
/** Positioning classes for the quant corner badge (default `-bottom-0.75 -right-0.75`). */
quantPositionClass?: string;
}
let { org, quantOrg }: Props = $props();
let {
baseImageClass = '',
org,
quantImageClass = 'h-full w-full',
quantOrg,
quantPositionClass = '-bottom-0.75 -right-0.75',
size = 'h-9 w-9'
}: Props = $props();
let avatarError = $state(false);
let quantError = $state(false);
@@ -30,6 +45,7 @@
let quantHue = $derived.by(() => {
const name = quantOrg ?? '';
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
@@ -41,28 +57,28 @@
<span class="relative mt-0.5 inline-flex shrink-0">
{#if avatarError}
<span
class="flex h-9 w-9 items-center justify-center rounded-md text-sm font-semibold text-white"
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
style="background-color: hsl({hue} 60% 45%)"
aria-hidden="true"
>
{org.charAt(0).toUpperCase()}
</span>
{:else}
<div class="rounded-md">
<img
src={HuggingFaceService.getAvatarUrl(org)}
onerror={() => (avatarError = true)}
class="h-9 w-9 rounded-md {invertAvatar ? 'dark:invert' : ''}"
alt=""
loading="lazy"
/>
</div>
<div class="rounded-md">
<img
src={HuggingFaceService.getAvatarUrl(org)}
onerror={() => (avatarError = true)}
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
alt=""
loading="lazy"
/>
</div>
{/if}
{#if quantOrg && quantOrg !== org}
<Tooltip.Root>
<Tooltip.Trigger
class="absolute -bottom-0.75 -right-0.75 h-4.25 w-4.25 overflow-hidden rounded-full border border-background bg-muted "
class="absolute {quantPositionClass} h-4.25 w-4.25 overflow-hidden rounded-full border border-background bg-muted "
>
{#if quantError}
<span
@@ -76,7 +92,7 @@
<img
src={HuggingFaceService.getAvatarUrl(quantOrg)}
onerror={() => (quantError = true)}
class="h-full w-full rounded-full {invertQuant ? 'dark:invert' : ''}"
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
alt=""
loading="lazy"
/>
@@ -1,13 +1,12 @@
<script lang="ts">
import { ExternalLink } from '@lucide/svelte';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
import { SvelteMap } from 'svelte/reactivity';
import ModelsDiscoverDetailsDownloadOptions from './ModelsDiscoverDetailsDownloadOptions.svelte';
import ModelsDiscoverDetailsHeader from './ModelsDiscoverDetailsHeader.svelte';
import ModelsDiscoverDetailsReadme from './ModelsDiscoverDetailsReadme.svelte';
import TerminalCommands from './TerminalCommands.svelte';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
@@ -44,7 +43,7 @@
// Draft sidecar variants (mtp, dflash, dspark, eagle3) present in the repo,
// e.g. speculative-decoding drafts. mmproj is excluded: it is vision.
let draftVariants = $derived.by<DraftVariant[]>(() => {
const set = new Set<DraftVariant>();
const set = new SvelteSet<DraftVariant>();
for (const file of files) {
const variant = HuggingFaceService.extractQuantMeta(file.path)?.variant;
@@ -1,11 +1,11 @@
<script lang="ts">
import { Check, MessageSquareCode } from '@lucide/svelte';
import { HuggingFaceService, ModelsService } from '$lib/services';
import type { GgufVariantTagInput } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { HfModelSibling } from '$lib/types/huggingface';
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check, MessageSquareCode } from '@lucide/svelte';
import type { GgufVariantTagInput } from '$lib/services';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { HfModelSibling } from '$lib/types/huggingface';
interface Props {
modelId: string;
@@ -28,7 +28,9 @@
{#if bitDepthRows.length}
<section class="space-y-3">
<h2 class="flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
<h2
class="flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>
<MessageSquareCode class="h-3.5 w-3.5" />
Download options
</h2>
@@ -62,8 +64,8 @@
onclick={() =>
(pendingDownload = {
filePath: file.path,
sizeBytes: file.size ?? null,
quant: meta?.quant ?? null,
sizeBytes: file.size ?? null,
variant: meta?.variant ?? null
})}
title={isDownloading
@@ -82,12 +84,16 @@
<Check class="h-3 w-3 text-foreground/70" />
{/if}
{#if isFailed && !isDownloading && !isDownloaded}
<span class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase">
<span
class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
>
Failed
</span>
{/if}
{#if meta?.variant}
<span class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase">
<span
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
>
{meta.variant}
</span>
{/if}
@@ -1,9 +1,10 @@
<script lang="ts">
import { Download, ExternalLink, Heart, Image, Lightbulb, Wrench } from '@lucide/svelte';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverDetailsName from './ModelsDiscoverDetailsName.svelte';
import { Download, ExternalLink, Heart, Image, Lightbulb, Wrench } from '@lucide/svelte';
import { HuggingFaceService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
interface Props {
modelId: string;
@@ -16,16 +17,8 @@
hasReasoning: boolean;
}
let {
baseModels,
details,
gguf,
hasReasoning,
hasTools,
hasVision,
licenseTag,
modelId
}: Props = $props();
let { baseModels, details, gguf, hasReasoning, hasTools, hasVision, licenseTag, modelId }: Props =
$props();
// Avatar shows the base model's org (e.g. the Qwen logo for a ggml-org GGUF)
// with the quant org as a corner badge when they differ.
@@ -33,12 +26,17 @@
let baseOrg = $derived(baseModels[0]?.split('/')[0]);
let avatarOrg = $derived(baseOrg || repoOrg);
let quantOrg = $derived(baseOrg && baseOrg !== repoOrg ? repoOrg : undefined);
// Catalog family description when curated, else the HF card description.
let description = $derived(
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
);
</script>
<header class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<ModelsDiscoverAvatar org={avatarOrg} quantOrg={quantOrg} />
<ModelsDiscoverAvatar org={avatarOrg} {quantOrg} />
<ModelsDiscoverDetailsName modelId={details.id ?? modelId} {baseModels} />
</div>
<a
@@ -70,8 +68,8 @@
{/if}
</div>
{#if details.cardData?.description}
<p class="text-sm text-muted-foreground">{details.cardData.description}</p>
{#if description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
<!-- Metadata chips -->
@@ -82,7 +80,9 @@
</span>
{/if}
{#if gguf?.architecture}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground capitalize">
<span
class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground capitalize"
>
{gguf.architecture.replace(/_/g, ' ')}
</span>
{/if}
@@ -97,7 +97,9 @@
</span>
{/if}
{#if details.gated === true}
<span class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400">
<span
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
>
gated
</span>
{/if}
@@ -107,19 +109,25 @@
{#if hasVision || hasTools || hasReasoning}
<div class="flex flex-wrap items-center gap-1.5">
{#if hasVision}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<span
class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Image class="h-3 w-3" />
Vision
</span>
{/if}
{#if hasTools}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<span
class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Wrench class="h-3 w-3" />
Tool use
</span>
{/if}
{#if hasReasoning}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<span
class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Lightbulb class="h-3 w-3" />
Reasoning
</span>
@@ -9,10 +9,8 @@
</script>
{#if readme}
<section class="space-y-2">
<section class="space-y-2 bg-muted/50 p-3 rounded-xl">
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">README</h2>
<article class="rounded-lg border bg-card p-4">
<MarkdownContent content={readme} class="prose-sm max-w-none" />
</article>
<MarkdownContent content={readme} allowHtml class="prose-sm max-w-none" />
</section>
{/if}
@@ -1,11 +1,12 @@
<script lang="ts">
import { Download, Heart } from '@lucide/svelte';
import ModelId from '../ModelId.svelte';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { formatParameters } from '$lib/utils';
import type { ModelModalities } from '$lib/types/models';
import { modelsHubStore } from '$lib/stores';
import type { HfModelInfo } from '$lib/types/huggingface';
import ModelId from '../ModelId.svelte';
import type { ModelModalities } from '$lib/types/models';
import { detectToolUseSupport } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
model: HfModelInfo;
@@ -20,15 +21,16 @@
Boolean(model.gguf?.chat_template && /think|reasoning/i.test(model.gguf.chat_template))
);
// Tool use support from the chat template.
let supportsToolUse = $derived(detectToolUseSupport(model.gguf?.chat_template ?? ''));
// Modalities derived from HF metadata: vision from an mmproj sidecar or a
// multimodal pipeline tag, audio/video from their pipeline tags.
let modalities = $derived.by<ModelModalities>(() => {
const tag = model.pipeline_tag ?? '';
const vision =
['image-text-to-text', 'image-to-text', 'text-to-image', 'image-to-video'].includes(tag) ||
Boolean(model.siblings?.some((s) => s.rfilename.toLowerCase().includes('mmproj')));
const audio = [
'audio-classification',
'audio-to-audio',
@@ -36,17 +38,16 @@
'text-to-speech',
'voice-activity-detection'
].includes(tag);
const video = ['text-to-video', 'image-to-video', 'video-to-video'].includes(tag);
return { vision, audio, video };
return { audio, video, vision };
});
// Draft sidecars (mtp, dflash, dspark, eagle3) present in the repo, e.g.
// speculative-decoding drafts. mmproj is excluded: it is vision, already
// conveyed by the modalities.
let draftVariants = $derived.by<DraftVariant[]>(() => {
const set = new Set<DraftVariant>();
const set = new SvelteSet<DraftVariant>();
for (const sibling of model.siblings ?? []) {
const variant = HuggingFaceService.extractQuantMeta(sibling.rfilename)?.variant;
@@ -56,6 +57,50 @@
return [...set];
});
// Combined min/max size: the catalog gives main-model sizes per quant, and
// the repo file tree carries draft sidecar sizes (the detail siblings do
// not). Min = smallest main + smallest draft, max = largest main + largest
// draft, so the stored model fits within the range.
let sizeRange = $state<{ min: number; max: number } | null>(null);
$effect(() => {
const base = modelsHubStore.sizeRangeFor(model.id);
let cancelled = false;
if (draftVariants.length === 0) {
sizeRange = base ?? null;
return;
}
void HuggingFaceService.getTree(model.id).then((tree) => {
if (cancelled) return;
const drafts = tree
.filter((f) => {
const variant = HuggingFaceService.extractQuantMeta(f.path)?.variant;
return variant && variant !== 'mmproj';
})
.map((f) => f.size ?? 0)
.filter((size) => size > 0);
if (base && drafts.length > 0) {
sizeRange = {
max: base.max + Math.max(...drafts),
min: base.min + Math.min(...drafts)
};
} else {
sizeRange = base ?? null;
}
});
return () => {
cancelled = true;
};
});
</script>
<span class="min-w-0 flex-1">
@@ -64,24 +109,12 @@
hideOrgName
{modalities}
{supportsThinking}
{supportsToolUse}
{contextLength}
{sizeRange}
{draftVariants}
iconsOnNewLine
wrap
class="min-w-0"
/>
<span class="mt-0.5 block truncate text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
<Download class="h-3 w-3" />
{HuggingFaceService.formatDownloads(model.downloads)}
</span>
<span class="ml-2.5 inline-flex items-center gap-1">
<Heart class="h-3 w-3" />
{HuggingFaceService.formatLikes(model.likes)}
</span>
{#if contextLength}
<span class="ml-2.5 inline-flex items-center gap-1">
{formatParameters(contextLength)} ctx
</span>
{/if}
</span>
</span>
@@ -1,8 +1,8 @@
<script lang="ts">
import { HuggingFaceService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverInfo from './ModelsDiscoverInfo.svelte';
import { HuggingFaceService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
interface Props {
model: HfModelInfo;
@@ -12,7 +12,7 @@
onSelect?: (modelId: string) => void;
}
let { model, active = false, showBaseModelAvatar = false, onSelect }: Props = $props();
let { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
let org = $derived(model.id.split('/')[0] ?? model.id);
@@ -14,11 +14,11 @@
// llama.cpp --spec-type value for each draft variant.
const SPEC_TYPE: Record<DraftVariant, string> = {
mtp: 'draft-mtp',
dflash: 'draft-dflash',
dspark: 'draft-dspark',
eagle3: 'eagle3',
mmproj: ''
mmproj: '',
mtp: 'draft-mtp'
};
let copiedIndex = $state<number | null>(null);
@@ -33,7 +33,6 @@
// draft sidecar, or just the base command when none is present.
let boxes = $derived.by(() => {
const variants = draftVariants.filter((v) => v !== 'mmproj');
const build = (bin: string) => {
const base = `llama ${bin} -hf ${modelId}`;
@@ -43,8 +42,8 @@
};
return [
{ title: 'Serve', icon: Server, commands: build('serve') },
{ title: 'CLI', icon: SquareTerminal, commands: build('cli') }
{ commands: build('serve'), icon: Server, title: 'Serve' },
{ commands: build('cli'), icon: SquareTerminal, title: 'CLI' }
];
});
</script>
@@ -65,7 +64,9 @@
<div class="space-y-1 p-2">
{#each box.commands as cmd, i (cmd)}
<div class="group flex items-center justify-between gap-2 rounded px-2 py-1 font-mono text-xs">
<div
class="group flex items-center justify-between gap-2 rounded px-2 py-1 font-mono text-xs"
>
<span class="truncate text-foreground/90">{cmd}</span>
<button
type="button"
@@ -1,7 +1,7 @@
<script lang="ts">
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import type { ModelItem } from '../utils';
import { Boxes, ChevronDown, Lightbulb, LoaderCircle, PackageSearch, Plus } from '@lucide/svelte';
import { ChevronDown, Lightbulb, LoaderCircle, PackageSearch } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
@@ -98,9 +98,7 @@
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) order.push(item.option.id);
}
for (const item of ms.groupedFilteredOptions.available) order.push(item.option.id);
return order;
});
@@ -214,7 +212,7 @@
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
@@ -230,10 +228,10 @@
<span class="flex min-w-0 items-center gap-1">
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideQuantization
hideOrgName={!showOrgNameInTrigger}
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
@@ -266,7 +264,7 @@
<DropdownMenu.Content
align="end"
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
class="w-full md:min-w-64 max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenu.Sub bind:open={modelSubOpen}>
@@ -275,37 +273,37 @@
{#if selectedOption}
<ModelId
modelId={selectedOption.model}
class="min-w-0 flex-1 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
{/if}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
<DropdownMenu.SubContent class="max-w-[calc(100vw-2rem)] md:max-w-108 pt-0">
<DropdownMenuSearchable
searchValue={ms.searchTerm}
onSearchChange={(v) => ms.setSearchTerm(v)}
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
placeholder="Search models..."
searchValue={ms.searchTerm}
>
<div class="models-list">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
aria-disabled="true"
aria-selected="true"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
disabled
role="option"
type="button"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
role="option"
aria-selected="true"
aria-disabled="true"
disabled
>
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
@@ -315,38 +313,39 @@
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{#snippet modelOption(item: ModelItem, _hideOrgName: boolean, compact = false)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
{isHighlighted}
{option}
{isSelected}
{isHighlighted}
{isFav}
hideOrgName={false}
{compact}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
onMouseEnter={() => (highlightedId = option.id)}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
onMouseEnter={() => (highlightedId = option.id)}
onSelect={ms.handleSelect}
{option}
/>
{/snippet}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
renderOption={modelOption}
{currentModel}
activeId={ms.activeId}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
renderOption={modelOption}
/>
</div>
</DropdownMenuSearchable>
@@ -385,18 +384,18 @@
: 'text-foreground',
isOpen && 'text-foreground'
]}
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{/if}
@@ -423,9 +422,9 @@
{#if ms.showModelDialog}
<DialogModelInformation
modelId={ms.infoModelId}
onOpenChange={(v) => ms.setShowModelDialog(v)}
open={ms.showModelDialog}
onOpenChange={(v) => ms.setShowModelDialog(v)}
modelId={ms.infoModelId}
/>
{/if}
@@ -8,10 +8,9 @@
currentModel: string | null;
activeId: string | null;
sectionHeaderClass?: string;
orgHeaderClass?: string;
onSelect: (modelId: string) => void;
onInfoClick: (modelName: string) => void;
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
renderOption?: import('svelte').Snippet<[ModelItem, boolean, boolean?]>;
}
let {
@@ -20,34 +19,33 @@
groups,
onInfoClick,
onSelect,
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
renderOption,
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
sectionHeaderClass = 'my-1 px-2 py-2 text-xs font-semibold text-muted-foreground/70 select-none'
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
</script>
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean, compact = false)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
isHighlighted={false}
{isSelected}
{onInfoClick}
onKeyDown={() => {}}
onMouseEnter={() => {}}
{onSelect}
{option}
{isSelected}
isHighlighted={false}
{isFav}
{hideOrgName}
{compact}
{onSelect}
{onInfoClick}
onMouseEnter={() => {}}
onKeyDown={() => {}}
/>
{/snippet}
{#if groups.loaded.length > 0}
<p class={sectionHeaderClass}>Loaded models</p>
{#each groups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, false)}
{/each}
@@ -55,7 +53,6 @@
{#if groups.favorites.length > 0}
<p class={sectionHeaderClass}>Favorite models</p>
{#each groups.favorites as item (`fav-${item.option.id}`)}
{@render render(item, true)}
{/each}
@@ -63,14 +60,7 @@
{#if groups.available.length > 0}
<p class={sectionHeaderClass}>Available models</p>
{#each groups.available as group (group.orgName)}
{#if group.orgName}
<p class={orgHeaderClass}>{group.orgName}</p>
{/if}
{#each group.items as item (item.option.id)}
{@render render(item, true)}
{/each}
{#each groups.available as item (`avail-${item.option.id}`)}
{@render render(item, false)}
{/each}
{/if}
@@ -10,7 +10,7 @@
PowerOff,
RotateCw
} from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app';
import { ActionIcon, ModelId, ModelsDiscoverAvatar } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
@@ -23,6 +23,8 @@
isHighlighted: boolean;
isFav: boolean;
hideOrgName?: boolean;
/** Show only the quant/param badges, hiding the avatar and org/model name. */
compact?: boolean;
onSelect: (modelId: string) => void;
onMouseEnter: () => void;
onKeyDown: (e: KeyboardEvent) => void;
@@ -30,6 +32,7 @@
}
let {
compact = false,
hideOrgName = false,
isFav,
isHighlighted,
@@ -60,10 +63,11 @@
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
let quantOrg = $derived(option.parsedId?.orgName || option.model.split('/')[0] || option.model);
let baseOrg = $derived(option.baseModel?.org || quantOrg);
</script>
<div
aria-selected={isSelected || isHighlighted}
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer',
@@ -74,22 +78,36 @@
'focus:bg-accent',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
onclick={() => onSelect(option.id)}
onkeydown={onKeyDown}
onmouseenter={onMouseEnter}
role="option"
tabindex="0"
aria-selected={isSelected || isHighlighted}
title={loadTitle}
tabindex="0"
onclick={() => onSelect(option.id)}
onmouseenter={onMouseEnter}
onkeydown={onKeyDown}
>
{#if !compact}
<ModelsDiscoverAvatar
org={baseOrg}
{quantOrg}
size="size-5"
quantImageClass="size-3.25"
quantPositionClass="-right-1.25 -bottom-1.25"
/>
{/if}
<ModelId
aliases={option.aliases}
class="flex-1"
{hideOrgName}
{modalities}
modelId={option.model}
showRawTooltip
{supportsThinking}
{hideOrgName}
hideName={compact}
hideModalities={compact}
hideParameters={compact}
aliases={option.aliases}
tags={option.tags}
{modalities}
{supportsThinking}
showRawTooltip
class="flex-1"
/>
<div class="flex shrink-0 items-center gap-1">
@@ -101,30 +119,30 @@
>
{#if isFav}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={HeartOff}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.toggleFavorite(option.model)}
icon={HeartOff}
tooltip="Remove from favorites"
class="h-3 w-3 hover:text-foreground"
onclick={() => modelsStore.toggleFavorite(option.model)}
/>
{:else}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={Heart}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.toggleFavorite(option.model)}
icon={Heart}
tooltip="Add to favorites"
class="h-3 w-3 hover:text-foreground"
onclick={() => modelsStore.toggleFavorite(option.model)}
/>
{/if}
<!-- info button: only shown when model is loaded and callback is provided -->
{#if isLoaded && onInfoClick}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={Info}
iconSize="h-2.5 w-2.5"
onclick={() => onInfoClick(option.model)}
icon={Info}
tooltip="Model information"
class="h-3 w-3 hover:text-foreground"
onclick={() => onInfoClick(option.model)}
/>
{/if}
</div>
@@ -141,12 +159,12 @@
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-3 w-3 text-red-500 hover:text-foreground"
icon={RotateCw}
iconSize="h-2.5 w-2.5"
icon={RotateCw}
tooltip="Retry loading model"
class="h-3 w-3 text-red-500 hover:text-foreground"
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
tooltip="Retry loading model"
/>
</div>
</div>
@@ -158,14 +176,14 @@
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
icon={PowerOff}
iconSize="h-2.5 w-2.5"
icon={PowerOff}
tooltip="Unload model"
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
onclick={(e) => {
e?.stopPropagation();
modelsStore.status.unload(option.model);
}}
tooltip="Unload model"
/>
</div>
</div>
@@ -177,12 +195,12 @@
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
icon={PowerOff}
iconSize="h-2.5 w-2.5"
icon={PowerOff}
tooltip="Unload model"
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
onclick={() => modelsStore.status.unload(option.model)}
stopPropagationOnClick
tooltip="Unload model"
/>
</div>
</div>
@@ -194,12 +212,12 @@
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
icon={Power}
iconSize="h-2.5 w-2.5"
icon={Power}
tooltip="Load model"
class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
tooltip="Load model"
/>
</div>
</div>
@@ -79,6 +79,7 @@
{#if ms.isRouter}
<button
type="button"
class={[
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
@@ -90,10 +91,9 @@
: 'text-foreground',
sheetOpen && 'text-foreground'
]}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}
style="max-width: min(calc(100cqw - 9rem), 20rem)"
type="button"
>
<Package class="h-3.5 w-3.5 shrink-0" />
@@ -102,10 +102,10 @@
{:else}
<ModelId
class="text-xs"
hideOrgName
modelId={selectedOption?.model || ''}
hideQuantization
hideTags
modelId={selectedOption?.model || ''}
hideOrgName
/>
{/if}
@@ -121,7 +121,7 @@
</button>
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>
<Sheet.Content class="max-h-[85vh] gap-1" side="bottom">
<Sheet.Content side="bottom" class="max-h-[85vh] gap-1">
<Sheet.Header>
<Sheet.Title>Select Model</Sheet.Title>
@@ -133,26 +133,24 @@
<div class="flex flex-col gap-1 pb-4">
<div class="mb-3 px-4">
<SearchInput
onInput={(v) => ms.setSearchTerm(v)}
placeholder="Search models..."
value={ms.searchTerm}
onInput={(v) => ms.setSearchTerm(v)}
/>
</div>
<div class="max-h-[60vh] overflow-y-auto px-2">
{#if !ms.isCurrentModelInCache && currentModel}
<button
type="button"
class="flex w-full cursor-not-allowed items-center rounded-md bg-red-400/10 px-3 py-2.5 text-left text-sm text-red-400"
disabled
type="button"
>
<span class="min-w-0 flex-1 truncate">
{selectedOption?.name || currentModel}
</span>
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
@@ -161,13 +159,12 @@
{/if}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
{currentModel}
activeId={ms.activeId}
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
/>
</div>
</div>
@@ -185,13 +182,13 @@
? 'text-foreground'
: 'text-foreground'
]}
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
onclick={() => ms.handleOpenChange(true)}
disabled={disabled || ms.updating}
>
<Package class="h-3.5 w-3.5 shrink-0" />
<ModelId class="font-medium" hideQuantization modelId={selectedOption?.model || ''} />
<ModelId modelId={selectedOption?.model || ''} class="font-medium" hideQuantization />
{#if ms.updating}
<LoaderCircle class="h-3 w-3.5 shrink-0 animate-spin" />
@@ -203,8 +200,8 @@
{#if ms.showModelDialog}
<DialogModelInformation
modelId={ms.infoModelId}
onOpenChange={(v) => ms.setShowModelDialog(v)}
open={ms.showModelDialog}
onOpenChange={(v) => ms.setShowModelDialog(v)}
modelId={ms.infoModelId}
/>
{/if}
@@ -1,21 +1,15 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
import { SvelteMap } from 'svelte/reactivity';
export interface ModelItem {
option: ModelOption;
flatIndex: number;
}
export interface OrgGroup {
orgName: string | null;
items: ModelItem[];
}
export interface GroupedModelOptions {
loaded: ModelItem[];
favorites: ModelItem[];
available: OrgGroup[];
available: ModelItem[];
}
function matchesModality(option: ModelOption, term: string): boolean {
@@ -77,24 +71,15 @@ export function groupModelOptions(
}
}
// Available models grouped by org (excluding loaded and favorites)
const available: OrgGroup[] = [];
const orgGroups = new SvelteMap<string, ModelItem[]>();
// Available models (excluding loaded and favorites)
const available: ModelItem[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
const key = option.parsedId?.orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []);
orgGroups.get(key)!.push({ flatIndex: i, option });
}
for (const [orgName, items] of orgGroups) {
available.push({ items, orgName: orgName || null });
available.push({ flatIndex: i, option });
}
return { available, favorites, loaded };
@@ -11,13 +11,6 @@ export const MODEL_ID = {
/** Matches prefix for custom quantization types, e.g. `UD-Q8_K_XL`. */
CUSTOM_QUANTIZATION_PREFIX_RE: /^UD$/i,
/**
* Trailing `-<variant>` suffix marking a GGUF with an embedded draft in the
* same weight file (MTP) or a sidecar download entry, e.g.
* `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. The captured prefix is the
* candidate model id; the caller decides whether it looks quantized.
*/
DRAFT_VARIANT_SUFFIX_RE: /^(.*)-(mtp|dflash|dspark|eagle3)$/i,
/**
* Sidecar prefix that wraps a model id with a draft/aux variant, e.g.
* `mtp-<name>.gguf`, `dflash-<name>.gguf`, `dspark-<name>.gguf`,
@@ -25,6 +18,13 @@ export const MODEL_ID = {
* token for typed lookup.
*/
DRAFT_VARIANT_PREFIX_RE: /^(mtp|dflash|dspark|eagle3|mmproj)-(.*)$/i,
/**
* Trailing `-<variant>` suffix marking a GGUF with an embedded draft in the
* same weight file (MTP) or a sidecar download entry, e.g.
* `Hy3-IQ1_M-mtp.gguf`, `Q4_K_M-dspark`. The captured prefix is the
* candidate model id; the caller decides whether it looks quantized.
*/
DRAFT_VARIANT_SUFFIX_RE: /^(.*)-(mtp|dflash|dspark|eagle3)$/i,
/** Container format segments to exclude from tags (every model uses these). */
IGNORED_SEGMENTS: new Set(['GGUF', 'GGML']),
@@ -15,10 +15,10 @@ export const ROUTES = {
MANAGE_MODEL: '#/models-hub/[modelId]',
/** Model hub - browse and download HuggingFace GGUF models. */
MANAGE_MODELS: '#/models-hub',
/** Model manager - installed models from /v1/models. */
MODEL_MANAGER: '#/model-manager',
/** MCP servers. */
MCP_SERVERS: '#/mcp-servers',
/** Model manager - installed models from /v1/models. */
MODEL_MANAGER: '#/model-manager',
/** Search — mobile-only full-page conversation search. */
SEARCH: '#/search',
/** Root — start of the app. */
@@ -1,8 +1,10 @@
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { modelsStore, serverStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
@@ -76,14 +78,47 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
let searchTerm = $state('');
let showModelDialog = $state(false);
let infoModelId = $state<string | null>(null);
let menuOpen = $state(false);
// Base model org per base repo (e.g. `Qwen` for `ggml-org/Qwen3.8-27B-GGUF`),
// resolved lazily from the HF card while the menu is open.
const baseOrgs = new SvelteMap<string, string>();
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
// Augment each option with its base model; the org falls back to the quant
// org until the HF lookup resolves.
const optionsWithBaseModel = $derived(
filteredOptions.map((option) => {
const repo = option.model.split(':')[0];
const org = baseOrgs.get(repo) ?? option.parsedId?.orgName ?? repo.split('/')[0] ?? '';
const name = option.parsedId?.modelName ?? option.name ?? option.model;
return { ...option, baseModel: { name, org } };
})
);
const groupedFilteredOptions = $derived(
groupModelOptions(filteredOptions, modelsStore.favoriteModelIds, (m) =>
groupModelOptions(optionsWithBaseModel, modelsStore.favoriteModelIds, (m) =>
modelsStore.isModelLoaded(m)
)
);
// Fetch base model orgs for the visible repos while the menu is open. The
// service caches per repo, so repeated opens never re-hit the HF API.
$effect(() => {
if (!menuOpen) return;
const repos = new SvelteSet<string>();
for (const option of options) repos.add(option.model.split(':')[0]);
for (const repo of repos) {
if (baseOrgs.has(repo)) continue;
void HuggingFaceService.getBaseModel(repo).then((info) => {
baseOrgs.set(repo, info?.org ?? repo.split('/')[0] ?? '');
});
}
});
function handleInfoClick(modelName: string) {
infoModelId = modelName;
showModelDialog = true;
@@ -98,6 +133,8 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function handleOpenChange(open: boolean) {
if (loading || updating) return;
menuOpen = open;
if (isRouter) {
searchTerm = '';
+157 -80
View File
@@ -1,10 +1,12 @@
import { type DraftVariant,MODEL_ID } from '$lib/constants';
import { type DraftVariant, MODEL_ID } from '$lib/constants';
import type {
HfCatalogEntry,
HfModelDetailInfo,
HfModelInfo,
HfModelSearchParams,
HfModelSibling,
HfModelSort} from '$lib/types/huggingface';
HfModelSort
} from '$lib/types/huggingface';
/** Variant flag in a GGUF filename (e.g. draft-mtp, diffusion-flash, multimodal projector). */
export type GgufVariant = DraftVariant;
@@ -142,10 +144,21 @@ export class HuggingFaceService {
private static readonly BASE_URL = 'https://huggingface.co/api/models';
// Cached base model lookups keyed by repo id, so repeated selector opens
// never re-hit the HF API for the same repo.
private static baseModelCache = new Map<string, { org: string; name: string } | null>();
private static baseModelPending = new Map<
string,
Promise<{ org: string; name: string } | null>
>();
private static readonly DEFAULT_LIMIT = 50;
private static readonly MAX_LIMIT = 100;
// GGUF Model Searching
/**
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
*/
@@ -242,7 +255,7 @@ export class HuggingFaceService {
return { quant, variant, variantForm };
}
// GGUF Model Searching
// GGUF Model Browsing
/**
* Filter raw siblings by file extension and sort by size descending.
@@ -268,8 +281,6 @@ export class HuggingFaceService {
return downloads.toString();
}
// GGUF Model Browsing
/**
* Format file size in bytes to human-readable string
*/
@@ -289,6 +300,19 @@ export class HuggingFaceService {
return `${bytes} B`;
}
/**
* Format a min-max size range with a single shared unit and no spaces
* around the dash, e.g. `19.0-28.6 GB`.
*/
static formatSizeRange(min: number, max: number): string {
const unit = max >= 1_000_000_000 ? 'GB' : max >= 1_000_000 ? 'MB' : max >= 1_000 ? 'KB' : 'B';
const div =
unit === 'GB' ? 1_000_000_000 : unit === 'MB' ? 1_000_000 : unit === 'KB' ? 1_000 : 1;
const fmt = (n: number) => (div === 1 ? `${n}` : `${(n / div).toFixed(1)}`);
return `${fmt(min)}-${fmt(max)} ${unit}`;
}
/**
* Format likes count with K suffix if applicable
*/
@@ -322,6 +346,66 @@ export class HuggingFaceService {
return `${Math.floor(diffDays / 365)} years ago`;
}
// Model Details & Files
/**
* Avatar URL for an author (org or user). 404s when the author does not
* exist, so callers should provide a fallback.
*/
static getAvatarUrl(author: string): string {
return `https://huggingface.co/api/avatars/${author}`;
}
/**
* Resolve the original (non-GGUF) base model `{ org, name }` for a GGUF repo
* from its HF card (`cardData.base_model`). Returns null when the card has no
* base model. Results are cached per repo.
*/
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
const cached = this.baseModelCache.get(repoId);
if (cached !== undefined) return Promise.resolve(cached);
const pending = this.baseModelPending.get(repoId);
if (pending) return pending;
const promise = (async () => {
const details = await this.getDetails(repoId);
const base = this.getBaseModels(details)[0];
if (!base) return null;
const [org, ...rest] = base.split('/');
return { name: rest.join('/'), org };
})();
this.baseModelPending.set(repoId, promise);
promise
.then((result) => this.baseModelCache.set(repoId, result))
.finally(() => this.baseModelPending.delete(repoId));
return promise;
}
/**
* Extract the original (non-GGUF) base model ids for a repo, from
* `cardData.base_model` (string or list) and the `base_model:` tags.
*/
static getBaseModels(model: HfModelDetailInfo | null): string[] {
if (!model) return [];
const cardBase = model.cardData?.base_model;
const fromCard: string[] = Array.isArray(cardBase) ? cardBase : cardBase ? [cardBase] : [];
const fromTags = (model.tags ?? [])
.map((t) => /^base_model:(?:quantized:)?(.+)$/.exec(t)?.[1])
.filter((v): v is string => Boolean(v));
return Array.from(new Set([...fromCard, ...fromTags]));
}
/**
* Look up the average bit-depth for a known GGUF quantization.
* Returns `null` for unrecognized tokens.
@@ -343,11 +427,29 @@ export class HuggingFaceService {
});
}
// Model Details & Files
/**
* Get detailed information about a specific GGUF model
*/
/**
* Fetch the llama.app model catalog (https://llama.app/v1/catalog.json).
* Returns an empty array on failure so callers can fall back gracefully.
*/
static async getCatalog(): Promise<HfCatalogEntry[]> {
const url = 'https://llama.app/v1/catalog.json';
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch catalog: ${response.status}`);
return (await response.json()) as HfCatalogEntry[];
} catch (error) {
console.error('Error fetching catalog:', error);
return [];
}
}
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.
@@ -370,6 +472,15 @@ export class HuggingFaceService {
}
}
/**
* Get model URL on Hugging Face Hub
*/
static getModelUrl(modelId: string): string {
return `https://huggingface.co/${modelId}`;
}
// Utility Methods
/**
* Get most liked GGUF models
*/
@@ -395,6 +506,28 @@ export class HuggingFaceService {
return this.search({ limit, sort: 'downloads' });
}
/**
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
*/
static async getReadme(modelId: string): Promise<string | null> {
// Do not encode the modelId, it contains slashes for author/name
const url = `https://huggingface.co/${modelId}/raw/main/README.md`;
try {
const response = await fetch(url);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Failed to fetch README: ${response.status}`);
return HuggingFaceService.stripFrontmatter(await response.text());
} catch (error) {
console.error(`Error fetching README for ${modelId}:`, error);
return null;
}
}
/**
* Get repository file tree to list available GGUF variants
*/
@@ -413,7 +546,6 @@ export class HuggingFaceService {
return [];
}
}
/**
* Get trending GGUF models
*/
@@ -423,44 +555,6 @@ export class HuggingFaceService {
return this.search({ limit, sort: 'trendingScore' });
}
/**
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
*/
static async getReadme(modelId: string): Promise<string | null> {
// Do not encode the modelId, it contains slashes for author/name
const url = `https://huggingface.co/${modelId}/raw/main/README.md`;
try {
const response = await fetch(url);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Failed to fetch README: ${response.status}`);
return HuggingFaceService.stripFrontmatter(await response.text());
} catch (error) {
console.error(`Error fetching README for ${modelId}:`, error);
return null;
}
}
// Utility Methods
/**
* Get model URL on Hugging Face Hub
*/
static getModelUrl(modelId: string): string {
return `https://huggingface.co/${modelId}`;
}
/**
* Avatar URL for an author (org or user). 404s when the author does not
* exist, so callers should provide a fallback.
*/
static getAvatarUrl(author: string): string {
return `https://huggingface.co/api/avatars/${author}`;
}
/**
* Parse a local HF cache file path
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
@@ -475,31 +569,20 @@ export class HuggingFaceService {
if (parts.length < 2) return null;
return { repo: `${parts[0]}/${parts.slice(1).join('--')}`, file: match[2] };
return { file: match[2], repo: `${parts[0]}/${parts.slice(1).join('--')}` };
}
/**
* Extract the original (non-GGUF) base model ids for a repo, from
* `cardData.base_model` (string or list) and the `base_model:` tags.
* Best-effort parameter count parsed from a model id/name, e.g. `27B` from
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`. Returns null
* when no size token is present.
*/
static getBaseModels(model: HfModelDetailInfo | null): string[] {
if (!model) return [];
static parseParamCount(name: string): string | null {
const match = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i.exec(name);
const cardBase = model.cardData?.base_model;
const fromCard: string[] = Array.isArray(cardBase) ? cardBase : cardBase ? [cardBase] : [];
if (!match) return null;
const fromTags = (model.tags ?? [])
.map((t) => /^base_model:(?:quantized:)?(.+)$/.exec(t)?.[1])
.filter((v): v is string => Boolean(v));
return Array.from(new Set([...fromCard, ...fromTags]));
}
/** Strip a leading YAML frontmatter block (--- ... ---) from a markdown document. */
private static stripFrontmatter(text: string): string {
const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
return match ? text.slice(match[0].length) : text;
return `${match[1]}${match[2].toUpperCase()}`;
}
/**
@@ -521,19 +604,6 @@ export class HuggingFaceService {
return { isGated, isGguf, isSafetensors, license, tasks };
}
/**
* Best-effort parameter count parsed from a model id/name, e.g. `27B` from
* `Qwen3.8-27B-GGUF` or `300M` from `embeddinggemma-300M-GGUF`. Returns null
* when no size token is present.
*/
static parseParamCount(name: string): string | null {
const match = /(?:^|[^a-z0-9])(\d+(?:[._]\d+)?)\s*([bm])(?![a-z0-9])/i.exec(name);
if (!match) return null;
return `${match[1]}${match[2].toUpperCase()}`;
}
/** Resolve a pipeline_tag to a lucide icon name, or null when unknown. */
static pipelineTagIcon(tag: string | null | undefined): string | null {
if (!tag) return null;
@@ -575,8 +645,6 @@ export class HuggingFaceService {
});
}
// Internal Methods
/**
* Build API URL from search parameters
*/
@@ -596,6 +664,8 @@ export class HuggingFaceService {
return url.toString();
}
// Internal Methods
/**
* Delay helper for retry logic
*/
@@ -648,4 +718,11 @@ export class HuggingFaceService {
throw error;
}
}
/** Strip a leading YAML frontmatter block (--- ... ---) from a markdown document. */
private static stripFrontmatter(text: string): string {
const match = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
return match ? text.slice(match[0].length) : text;
}
}
@@ -9,27 +9,37 @@
* Detail data is loaded by ModelsDiscoverDetails, not here.
*/
import { CURATED_MODEL_IDS } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
import type { HfCatalogEntry, HfModelInfo } from '$lib/types/huggingface';
class ModelsHubStore {
models = $state<HfModelInfo[]>([]);
loading = $state(false);
error = $state<string | null>(null);
models = $state<HfModelInfo[]>([]);
/** First model in the list - the hub auto-opens this one. */
firstModel = $derived(this.models[0] ?? null);
private fetched = false;
loading = $state(false);
private catalog: HfCatalogEntry[] = [];
private defaultModels: HfModelInfo[] = [];
private fetched = false;
private searchRequestId = 0;
/**
* Fetch the default list: the curated ggml--org models in display order.
* Each curated model is fetched directly by ID, so the list is independent
* of download ranking (curated models may fall outside the top-50 search
* window). No-op when already loaded or in flight.
* Catalog family description for a repo id, or undefined when the repo is
* not part of the catalog (e.g. a search result outside the curated list).
*/
descriptionFor(modelId: string): string | undefined {
return this.catalog.find((entry) =>
entry.sizes.some((size) => size.builds.some((build) => build.repo === modelId))
)?.description;
}
/**
* Fetch the default list from the llama.app catalog, flattened to a flat
* list of ggml-org repo ids in catalog order (one per size). Each repo is
* fetched directly by ID, so the list is independent of download ranking.
* No-op when already loaded or in flight.
*/
async fetch(): Promise<void> {
if (this.loading || this.fetched) return;
@@ -38,10 +48,15 @@ class ModelsHubStore {
this.error = null;
try {
const catalog = await HuggingFaceService.getCatalog();
this.catalog = catalog;
const ids = this.catalogModelIds(catalog);
// getDetails returns full metadata (downloads, likes, lastModified,
// siblings, tags, gguf) for a single model.
this.defaultModels = (
await Promise.all(CURATED_MODEL_IDS.map((id) => HuggingFaceService.getDetails(id)))
await Promise.all(ids.map((id) => HuggingFaceService.getDetails(id)))
).filter((m): m is HfModelInfo => m !== null);
this.models = this.defaultModels;
this.fetched = true;
@@ -65,13 +80,14 @@ class ModelsHubStore {
if (!trimmed) {
this.models = this.defaultModels;
this.error = null;
return;
}
const requestId = this.searchRequestId;
try {
const results = await HuggingFaceService.searchByQuery(trimmed, { limit: 50, full: true });
const results = await HuggingFaceService.searchByQuery(trimmed, { full: true, limit: 50 });
if (requestId === this.searchRequestId) {
this.models = results;
@@ -83,6 +99,42 @@ class ModelsHubStore {
}
}
}
/**
* Min/max GGUF file size (bytes) across the available quants for a repo,
* or undefined when the repo is not part of the catalog.
*/
sizeRangeFor(modelId: string): { min: number; max: number } | undefined {
for (const entry of this.catalog) {
for (const size of entry.sizes) {
const builds = size.builds.filter((b) => b.repo === modelId);
if (builds.length === 0) continue;
const bytes = builds.map((b) => b.sizeBytes);
return { max: Math.max(...bytes), min: Math.min(...bytes) };
}
}
return undefined;
}
/**
* Flatten the catalog to a flat list of ggml-org repo ids, newest family
* first (by release date). Returns an empty array when the catalog is empty.
*/
private catalogModelIds(catalog: HfCatalogEntry[]): string[] {
return [...catalog]
.sort((a, b) => b.released.localeCompare(a.released))
.flatMap((entry) =>
entry.sizes.flatMap((size) => {
const build = size.builds.find((b) => b.repo.startsWith('ggml-org/'));
return build ? [build.repo] : [];
})
);
}
}
export const modelsHubStore = new ModelsHubStore();
+33 -33
View File
@@ -34,11 +34,6 @@ export interface ModelStatusHost {
}
export class ModelStatusManager {
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
private failedDownloads = new SvelteSet<string>();
private loadingStates = new SvelteMap<string, boolean>();
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
/**
* Draft sidecar files pulled by registered models, as `<repo>/<file>` keys.
* Drafts are not separate /v1/models entries - the router pulls them as
@@ -63,6 +58,11 @@ export class ModelStatusManager {
return result;
});
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
/** `<repo>:<tag>` strings whose most recent download attempt failed (download_failed). */
private failedDownloads = new SvelteSet<string>();
private loadingStates = new SvelteMap<string, boolean>();
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
// /models/sse feed state, the single source of truth for status and load progress
private statusAbort: AbortController | null = null;
private statusReaderActive = false;
@@ -102,6 +102,26 @@ export class ModelStatusManager {
}
}
/**
* Cancel an in-flight load (ROUTER mode only). The server force-kills a
* LOADING model on unload; the feed reports the settled status, so no
* waiter is registered here.
*/
async cancelLoad(modelId: string): Promise<void> {
if (!serverStore.isRouterMode) return;
this.subscribe();
try {
await ModelsService.unload(modelId);
toast.info(`Load cancelled: ${this.host.toDisplayName(modelId)}`);
} catch (error) {
toast.error(`Failed to cancel load: ${this.host.toDisplayName(modelId)}`);
throw error;
}
}
constructor(private host: ModelStatusHost) {}
/**
@@ -170,14 +190,6 @@ export class ModelStatusManager {
return this.downloadProgress.has(repoWithTag);
}
/**
* True when the given `<repo>:<tag>` is already a fully downloaded model
* registered with the server (i.e. it shows up in the /v1/models list).
*/
isModelDownloaded(repoWithTag: string): boolean {
return this.host.routerModels.some((m) => m.id === repoWithTag);
}
/**
* True when the given draft sidecar file (repo-relative path) has been pulled
* as the `--model-draft` of some registered model.
@@ -186,6 +198,14 @@ export class ModelStatusManager {
return this.downloadedDrafts.has(`${repoId}/${filePath}`);
}
/**
* True when the given `<repo>:<tag>` is already a fully downloaded model
* registered with the server (i.e. it shows up in the /v1/models list).
*/
isModelDownloaded(repoWithTag: string): boolean {
return this.host.routerModels.some((m) => m.id === repoWithTag);
}
isOperationInProgress(modelId: string): boolean {
return this.loadingStates.get(modelId) ?? false;
}
@@ -263,26 +283,6 @@ export class ModelStatusManager {
}
}
/**
* Cancel an in-flight load (ROUTER mode only). The server force-kills a
* LOADING model on unload; the feed reports the settled status, so no
* waiter is registered here.
*/
async cancelLoad(modelId: string): Promise<void> {
if (!serverStore.isRouterMode) return;
this.subscribe();
try {
await ModelsService.unload(modelId);
toast.info(`Load cancelled: ${this.host.toDisplayName(modelId)}`);
} catch (error) {
toast.error(`Failed to cancel load: ${this.host.toDisplayName(modelId)}`);
throw error;
}
}
/**
* Close the /models/sse feed and drop transient progress.
*/
+30
View File
@@ -189,3 +189,33 @@ export interface HfModelApiResponse {
/** Total count (if available) */
total?: number;
}
// llama.app model catalog (https://llama.app/v1/catalog.json)
/** A single GGUF build/repo within a catalog size. */
export interface HfCatalogBuild {
quant: string;
size: string;
sizeBytes: number;
repo: string;
}
/** A size variant (e.g. `GPT-OSS 20B`) within a catalog entry. */
export interface HfCatalogSize {
name: string;
params: string;
builds: HfCatalogBuild[];
}
/** A single model family in the catalog. `featured` marks the staff picks. */
export interface HfCatalogEntry {
name: string;
brand: string;
description: string;
details: string;
released: string;
license: string;
featured?: boolean;
maxMemGb?: number;
sizes: HfCatalogSize[];
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { DraftVariant } from '$lib/constants';
import type { ModelOption } from './models';
import type { DraftVariant } from '$lib/constants';
/**
* A draft sidecar model attached to a quant (speculative decoding).
+2
View File
@@ -19,6 +19,8 @@ export interface ModelOption {
parsedId?: ParsedModelId;
aliases?: string[];
tags?: string[];
/** Original (non-GGUF) base model, resolved from the HF card. */
baseModel?: { org: string; name: string };
}
/**
@@ -0,0 +1,28 @@
/**
* Detects whether a model's chat template supports tool calling.
*
* There is no server flag for tool support, so we infer it from the chat
* template. A template that accepts a `tools` array or emits tool-call tokens
* is treated as tool-capable.
*/
/** Tool-call tokens emitted by the template for assistant tool calls. */
const TOOL_CALL_TOKENS = [
'tool_call',
'tool_calls',
'function_call',
'tool_use',
'<tool',
'<|tool',
'TOOL_CALL'
];
/** Jinja reference to the `tools` array passed in by the caller. */
const JINJA_TOOLS_VAR = /\{\{[^{}]*\btools\b[^{}]*\}\}|\{%[^{}]*\btools\b[^{}]*%\}/i;
export function detectToolUseSupport(t: string): boolean {
if (!t) return false;
if (JINJA_TOOLS_VAR.test(t)) return true;
return TOOL_CALL_TOKENS.some((token) => t.includes(token));
}
+2
View File
@@ -263,6 +263,8 @@ export {
detectThinkingSupportWithReason
} from './chat-template-thinking-detector';
export { detectToolUseSupport } from './chat-template-tool-detector';
// Agentic content utilities (structured section derivation)
export {
deriveAgenticSections,
+8 -9
View File
@@ -1,9 +1,5 @@
import { HuggingFaceService } from '$lib/services';
import type {
ModelManagerParent,
ModelManagerQuant,
ModelManagerQuantOrg
} from '$lib/types';
import type { ModelManagerParent, ModelManagerQuant, ModelManagerQuantOrg } from '$lib/types';
import type { ModelOption } from '$lib/types/models';
/** Strip the `:quant` / `:quant-VARIANT` tag from a model id to get the repo id. */
@@ -50,7 +46,9 @@ export async function resolveBaseModel(repoId: string): Promise<string | null> {
resolved = first.trim();
} else {
// tag fallback, e.g. `base_model:Qwen/Qwen3-8B`
const tag = details?.tags?.find((t) => t.startsWith('base_model:') && !t.startsWith('base_model:quantized:'));
const tag = details?.tags?.find(
(t) => t.startsWith('base_model:') && !t.startsWith('base_model:quantized:')
);
const fromTag = tag?.slice('base_model:'.length).trim();
if (fromTag) resolved = fromTag;
@@ -90,17 +88,18 @@ export function buildModelManagerTree(
}
const key = quant ?? '';
let entry = quantMap.get(key);
if (!entry) {
entry = { quant, main: option, drafts: [], mmproj: null };
entry = { drafts: [], main: option, mmproj: null, quant };
quantMap.set(key, entry);
}
if (variant === 'mmproj') {
entry.mmproj = option;
} else if (variant) {
entry.drafts.push({ variant, option });
entry.drafts.push({ option, variant });
} else {
entry.main = option;
}
@@ -119,7 +118,7 @@ export function buildModelManagerTree(
const slashIdx = repoId.indexOf('/');
const orgName = slashIdx !== -1 ? repoId.slice(0, slashIdx) : repoId;
quantOrgs.push({ repoId, orgName, quants });
quantOrgs.push({ orgName, quants, repoId });
}
// Group quant orgs under parents.
@@ -22,7 +22,7 @@
import { SvelteMap } from 'svelte/reactivity';
let modelsHubOpen = $state(false);
let baseModels = $state(new SvelteMap<string, string | null>());
let baseModels = new SvelteMap<string, string | null>();
let isRouter = $derived(serverStore.isRouterMode);
@@ -61,7 +61,8 @@
{@const entry = getRouterEntry(option.model)}
{@const status = entry?.status}
{@const statusValue = status?.value}
{@const isLoaded = statusValue === ServerModelStatus.LOADED || statusValue === ServerModelStatus.SLEEPING}
{@const isLoaded =
statusValue === ServerModelStatus.LOADED || statusValue === ServerModelStatus.SLEEPING}
{@const isLoading = statusValue === ServerModelStatus.LOADING}
{@const isDownloading = statusValue === ServerModelStatus.DOWNLOADING}
{@const isFailed = statusValue === ServerModelStatus.FAILED || status?.failed === true}
@@ -78,7 +79,7 @@
tags={option.tags}
modalities={option.modalities}
supportsThinking={modelsStore.props.checkModelSupportsThinking(option.model)}
hideQuantization={hideQuantization}
{hideQuantization}
showRawTooltip
/>
</td>
@@ -1,12 +1,12 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Download, LoaderCircle, Trash2, TriangleAlert } from '@lucide/svelte';
import { DialogConfirmation } from '$lib/components/app';
import DownloadToast from './DownloadToast.svelte';
import { Download, LoaderCircle, TriangleAlert } from '@lucide/svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import type { DraftVariant } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { type GgufVariantTagInput, ModelsService } from '$lib/services/models.service';
import { modelsStore } from '$lib/stores';
import { toast } from 'svelte-sonner';
interface Props {
open: boolean;
@@ -30,9 +30,8 @@
variant
}: Props = $props();
type Phase = 'pending' | 'starting' | 'downloading' | 'finished';
type Phase = 'pending' | 'starting';
let phase = $state<Phase>('pending');
let hasSeenProgress = $state(false);
let lastError: string | null = $state(null);
let tagInput = $derived<GgufVariantTagInput | null>(
@@ -49,36 +48,10 @@
return 'default';
});
let inFlight = $derived(phase === 'starting' || phase === 'downloading');
// True when a previous SSE `download_failed` left a recorded failure for the
// same <repo>:<tag>. The dialog swaps Download for a delete-&-retry flow
// because POST /models rejects already-existing partial entries.
let previousFailure = $derived(modelsStore.status.hasFailedDownload(hfRepoWithTag));
let cancelling = $state(false);
let lastCancelError: string | null = $state(null);
// Only offer Delete when the model is registered with the server (a fully
// downloaded entry in /v1/models). For an in-flight download the partial
// files are cleaned up by the Retry path, so Delete would be redundant.
let canDelete = $derived(
phase === 'finished' && modelsStore.status.isModelDownloaded(hfRepoWithTag)
);
let showDeleteConfirm = $state(false);
async function handleConfirmDelete() {
showDeleteConfirm = false;
await modelsStore.status.cancelDownload(hfRepoWithTag);
// Close the download dialog too - removing the entry makes the wizard moot.
onCancel();
}
// Reactive: while the SSE feed reports progress for our download, surface it.
// The downloadProgress map is deleted on download_finished/download_failed.
let progress = $derived(modelsStore.status.getDownloadProgress(hfRepoWithTag));
let progressPercent = $derived.by(() => {
if (!progress || progress.totalBytes <= 0) return 0;
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && phase === 'pending') {
@@ -87,28 +60,24 @@
}
}
// The dialog is always closable - downloads run in the background and are
// tracked by a toast, so closing mid-flight never aborts the download.
function handleOpenChange(newOpen: boolean) {
if (newOpen) {
lastError = null;
lastCancelError = null;
showDeleteConfirm = false;
phase = 'pending';
hasSeenProgress = false;
return;
}
if (!inFlight) onCancel();
onCancel();
}
async function trigger() {
if (inFlight) return;
if (phase === 'starting') return;
phase = 'starting';
hasSeenProgress = false;
lastError = null;
lastCancelError = null;
showDeleteConfirm = false;
// A recorded failure for the same <repo>:<tag> means the server still holds
// a partial entry that POST /models would reject; remove it before retrying.
@@ -118,54 +87,26 @@
try {
await modelsStore.status.downloadModel(hfRepoWithTag, filePath);
phase = 'downloading';
// Download runs on the server; hand progress off to a toast and close.
showDownloadToast();
onConfirm();
} catch (error) {
lastError = error instanceof Error ? error.message : 'Failed to start download';
phase = 'pending';
}
}
async function cancel() {
if (cancelling) return;
cancelling = true;
lastCancelError = null;
try {
const ok = await modelsStore.status.cancelDownload(hfRepoWithTag);
if (!ok) {
lastCancelError = 'Cancel request failed. Try again in a moment.';
}
} finally {
cancelling = false;
}
function showDownloadToast() {
toast.custom(DownloadToast, {
componentProps: {
displayName: filePath,
repoId,
repoWithTag: hfRepoWithTag
},
dismissible: true,
duration: Infinity
});
}
// Latch once we've seen real progress so we know what 'no longer in flight'
// actually means. Without this the dialog auto-closed shortly after the POST
// resolved because no SSE event had landed yet.
$effect(() => {
if (phase !== 'downloading') return;
if (progress) hasSeenProgress = true;
});
// Promote to 'finished' only after progress was observed and the feed then
// drops our entry. Auto-close after a short pause.
$effect(() => {
if (phase !== 'downloading') return;
if (!hasSeenProgress) return;
const stillInFlight = modelsStore.status.isDownloadInProgress(hfRepoWithTag);
if (!stillInFlight) {
phase = 'finished';
const timer = setTimeout(() => onConfirm(), 600);
return () => clearTimeout(timer);
}
});
</script>
<AlertDialog.Root {open} onOpenChange={handleOpenChange}>
@@ -173,19 +114,12 @@
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<Download class="h-5 w-5 text-primary" />
{#if phase === 'pending'}
Download this model?
{:else}
Downloading {tagDisplay}
{/if}
Download this model?
</AlertDialog.Title>
<AlertDialog.Description>
{#if phase === 'pending'}
llama-server will download this file (and related sidecar weights such as multimodal
projectors or draft models) from Hugging Face into your local model cache.
{:else}
Download runs in the background; this dialog tracks live progress.
{/if}
llama-server will download this file (and related sidecar weights such as multimodal
projectors or draft models) from Hugging Face into your local model cache. The download runs
in the background and its progress is shown in a notification.
</AlertDialog.Description>
{#if previousFailure && phase === 'pending'}
@@ -203,20 +137,6 @@
{/if}
</AlertDialog.Header>
{#if canDelete}
<div class="flex justify-end">
<button
type="button"
onclick={() => (showDeleteConfirm = true)}
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
aria-label="Delete model from cache"
>
<Trash2 class="h-3.5 w-3.5" />
Delete from cache
</button>
</div>
{/if}
<div class="space-y-3 rounded-md border bg-muted/40 p-3 text-xs">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">Request</span>
@@ -243,74 +163,25 @@
</span>
{/if}
</div>
{#if phase === 'downloading' || phase === 'finished'}
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between text-muted-foreground">
<span>
{#if phase === 'finished'}
Complete
{:else if progress && progress.totalBytes > 0}
Downloading
{:else}
Preparing download
{/if}
</span>
<span class="font-mono tabular-nums">{progressPercent}%</span>
</div>
<DownloadProgressBar
downloadedBytes={progress?.downloadedBytes ?? 0}
totalBytes={progress?.totalBytes ?? 0}
/>
</div>
{/if}
</div>
{#if lastError}
<p class="text-xs text-destructive">{lastError}</p>
{/if}
{#if lastCancelError}
<p class="text-xs text-destructive">{lastCancelError}</p>
{/if}
<AlertDialog.Footer>
{#if phase === 'downloading'}
<AlertDialog.Action disabled={cancelling} onclick={cancel}>
{#if cancelling}
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
Cancelling...
{:else}
Cancel download
{/if}
</AlertDialog.Action>
{:else}
<AlertDialog.Cancel disabled={inFlight} onclick={onCancel}>
{#if phase === 'finished'}Close{:else}Cancel{/if}
</AlertDialog.Cancel>
{/if}
{#if phase === 'pending'}
<AlertDialog.Action disabled={inFlight} onclick={trigger}>
<Download class="mr-1.5 h-4 w-4" />
{previousFailure ? 'Retry download' : 'Download'}
</AlertDialog.Action>
{:else if phase === 'starting'}
<AlertDialog.Action disabled>
<AlertDialog.Cancel disabled={phase === 'starting'} onclick={onCancel}>
Cancel
</AlertDialog.Cancel>
<AlertDialog.Action disabled={phase === 'starting'} onclick={trigger}>
{#if phase === 'starting'}
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
Starting...
</AlertDialog.Action>
{/if}
{:else}
<Download class="mr-1.5 h-4 w-4" />
{previousFailure ? 'Retry download' : 'Download'}
{/if}
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<DialogConfirmation
bind:open={showDeleteConfirm}
title="Delete model"
description={`Remove "${hfRepoWithTag}" from your cache? Any cached files will be deleted from disk.`}
confirmText="Delete"
cancelText="Cancel"
variant="destructive"
icon={Trash2}
onConfirm={handleConfirmDelete}
onCancel={() => (showDeleteConfirm = false)}
/>
@@ -0,0 +1,90 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { ArrowUpRight, Check, TriangleAlert } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { modelsStore } from '$lib/stores';
interface Props {
/** `<repo>:<tag>` identifier the server reports progress under. */
repoWithTag: string;
/** Repo id (no tag) used by the "Open details" CTA. */
repoId: string;
/** Short label for the file being downloaded. */
displayName: string;
/** Injected by sonner; dismisses this toast. */
closeToast?: () => void;
}
let { closeToast, displayName, repoId, repoWithTag }: Props = $props();
// Live progress from the /models/sse feed. The map entry is deleted on
// download_finished / download_failed, so we latch on first sight to tell
// "not started yet" apart from "finished".
let progress = $derived(modelsStore.status.getDownloadProgress(repoWithTag));
let hasSeenProgress = $state(false);
let percent = $derived.by(() => {
if (!progress || progress.totalBytes <= 0) return 0;
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
});
$effect(() => {
if (progress) hasSeenProgress = true;
});
// A download is done when the feed drops our entry after we saw progress, or
// when the model landed in /v1/models / a failure was recorded (covers fast
// downloads that settle before the first progress event reaches this toast).
let failed = $derived(modelsStore.status.hasFailedDownload(repoWithTag));
let modelReady = $derived(modelsStore.status.isModelDownloaded(repoWithTag));
let done = $derived(
(hasSeenProgress && !modelsStore.status.isDownloadInProgress(repoWithTag)) ||
failed ||
modelReady
);
// Auto-dismiss once the download settles; keep the failure visible a bit longer.
$effect(() => {
if (!done) return;
const timer = setTimeout(() => closeToast?.(), failed ? 4000 : 2500);
return () => clearTimeout(timer);
});
function openDetails() {
closeToast?.();
goto(`/temp/models-hub/${repoId}`);
}
</script>
<div class="flex w-full flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-1.5 text-sm font-medium">
{#if done && failed}
<TriangleAlert class="h-4 w-4 text-destructive" />
Download failed
{:else if done}
<Check class="h-4 w-4 text-emerald-500" />
Download complete
{:else}
<span class="h-4 w-4 animate-pulse rounded-full bg-primary/30"></span>
Downloading
{/if}
</span>
<span class="font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
</div>
<p class="truncate text-xs text-muted-foreground">{displayName}</p>
<DownloadProgressBar
downloadedBytes={progress?.downloadedBytes ?? 0}
totalBytes={progress?.totalBytes ?? 0}
/>
<button
type="button"
onclick={openDetails}
class="inline-flex items-center justify-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
>
Open details
<ArrowUpRight class="h-3.5 w-3.5" />
</button>
</div>
@@ -82,26 +82,11 @@
const groupedOptions: GroupedModelOptions = {
available: [
{
items: [availableModels[0]],
orgName: 'deepseek'
},
{
items: [availableModels[1]],
orgName: 'google'
},
{
items: [availableModels[2]],
orgName: 'microsoft'
},
{
items: [availableModels[3]],
orgName: 'codellama'
},
{
items: [availableModels[4]],
orgName: 'intel'
}
availableModels[0],
availableModels[1],
availableModels[2],
availableModels[3],
availableModels[4]
],
favorites: favoriteModels,
loaded: loadedModels