ui : add models discover components with stories

Port the discover UI from the scrapbook, adapted to the typed
sidecar API: searchable two-pane explorer (list, item, info, org
avatar with quant badge), model details (header, name badges,
download options grouped by bit depth with compatibility tiers,
terminal serve/cli commands per draft sidecar, README viewer, chat
template dialog), download confirmation dialog with progress, and
the full-screen dialog shell.

Presentational components take data and download state via props;
the loading container and store wiring land in the integration
branch. MarkdownContent gains a sanitized allowHtml option used by
the README viewer; ModelId gains context, size-range, params and
sidecar badges; the selector option passes thinking/tool flags
instead of the removed capabilities prop.

Basic Storybook stories cover each component with HF-shaped
fixtures.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-08-30 18:40:30 +02:00
parent 17731e9a92
commit 94e20a6059
27 changed files with 2245 additions and 77 deletions
@@ -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;
@@ -0,0 +1,25 @@
<script lang="ts">
import { ModelsDiscover } from '$lib/components/app/models/discover';
import * as Dialog from '$lib/components/ui/dialog';
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="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;"
>
<ModelsDiscover />
</Dialog.Content>
</Dialog.Root>
@@ -526,3 +526,13 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
* ```
*/
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
/**
* **DialogModelsDiscover** - full-screen model discovery dialog.
*
* Two-pane layout: searchable model list (Hugging Face + llama.app catalog)
* on the left, model details with download options on the right.
*
* @see ModelsDiscover in $lib/components/app/models/discover
*/
export { default as DialogModelsDiscover } from './DialogModelsDiscover.svelte';
@@ -1,45 +1,67 @@
<script lang="ts">
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 {
CAPABILITY_FLAG_KEYS,
CAPABILITY_ICONS,
CAPABILITY_LABELS,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS
} from '$lib/constants';
import { ModelCapability, ModelModality } from '$lib/enums';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
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;
capabilities?: ModelCapabilities;
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;
/** Params badge fallback (formatted) when the model id carries no params token. */
params?: string;
draftSidecars?: ModelSidecar[];
/** Allow badges to wrap onto new lines instead of truncating. */
wrap?: boolean;
class?: string;
}
let {
aliases,
capabilities,
class: className = '',
contextLength,
draftSidecars = [],
hideModalities = false,
hideName = false,
hideOrgName = false,
hideParameters = false,
hideQuantization,
hideReasoning = false,
hideTags,
iconsOnNewLine = false,
modalities,
modelId,
params,
showRaw = undefined,
showRawTooltip = false,
sizeRange,
supportsThinking = false,
supportsToolUse = false,
tags,
wrap = false,
...rest
}: Props = $props();
@@ -47,6 +69,8 @@
'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 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';
let parsed = $derived(ModelsService.parseModelId(modelId));
let resolvedShowRaw = $derived(
@@ -59,16 +83,8 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
const allModalities = [ModelModality.VISION, ModelModality.VIDEO, ModelModality.AUDIO] as const;
const allCapabilities: ModelCapability[] = [ModelCapability.REASONING];
let activeModalities = $derived(
allModalities.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
);
let activeCapabilities = $derived(
allCapabilities.filter((capability) => capabilities?.[CAPABILITY_FLAG_KEYS[capability]])
);
let uniqueDraftSidecars = $derived([...new Set(draftSidecars)].filter((s) => !isAuxSidecar(s)));
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);
@@ -78,17 +94,31 @@
<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">
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
{#if parsed.sidecar}
<span class={variantBadgeClass} title={`${parsed.sidecar.toUpperCase()} draft model`}>
{parsed.sidecar}
</span>
{/if}
{#if (parsed.params || params) && !hideParameters}
<span class={badgeClass}>
{parsed.params ?? params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</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}
@@ -113,51 +143,110 @@
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {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 activeCapabilities.length > 0 || activeModalities.length > 0}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#each activeCapabilities as capability (capability)}
{@const CapabilityIcon = CAPABILITY_ICONS[capability]}
{#if supportsToolUse}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
<CapabilityIcon class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Content>
<p>{CAPABILITY_LABELS[capability]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
{#if supportsThinking && !hideReasoning}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
{#each activeModalities as modality (modality)}
{@const ModalityIcon = MODALITY_ICONS[modality]}
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Root>
<Tooltip.Trigger>
<ModalityIcon 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>{MODALITY_LABELS[modality]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</span>
{/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?.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>
<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 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}
@@ -86,12 +86,13 @@
>
<ModelId
aliases={option.aliases}
{capabilities}
class="flex-1"
{hideOrgName}
{modalities}
modelId={option.model}
showRawTooltip
supportsThinking={capabilities.reasoning}
supportsToolUse={capabilities.tools}
tags={option.tags}
/>
@@ -0,0 +1,308 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Download, LoaderCircle, Trash2, TriangleAlert } from '@lucide/svelte';
import { DialogConfirmation } from '$lib/components/app';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { ModelsService } from '$lib/services';
import type { ModelDownloadProgress } from '$lib/types';
interface Props {
open: boolean;
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
repoId: string;
/** Repo-relative path of the file this download resolves to. */
filePath: string;
/** Quantization token of the selected file, when known. */
quant: string | null;
/** Sidecar type pulled alongside the main weights, when any. */
sidecar: ModelSidecar | null;
/** Human-readable size of the download, when known. */
formattedSize?: string;
/** True when a previous attempt for this tag failed and left partial files. */
previousFailure?: boolean;
/** True while the server reports this download as in flight. */
inFlight?: boolean;
/** Live progress from the /models/sse feed; null before the first event. */
progress?: ModelDownloadProgress | null;
/** True when the model is fully downloaded and registered with the server. */
isDownloaded?: boolean;
/** Error message from a failed start attempt, shown above the footer. */
error?: string | null;
/** Fire the download (POST /models). */
onDownload: () => void;
/** Cancel the in-flight download (DELETE /models). */
onCancelDownload?: () => void;
/** Delete the model from the server cache; offered once finished. */
onDelete?: () => void;
/** Dialog was dismissed or the download completed. */
onClose: () => void;
}
let {
error = null,
filePath,
formattedSize,
inFlight = false,
isDownloaded = false,
onCancelDownload,
onClose,
onDelete,
onDownload,
open = $bindable(false),
previousFailure = false,
progress = null,
quant,
repoId,
sidecar
}: Props = $props();
let started = $state(false);
let sawProgress = $state(false);
let cancelling = $state(false);
let showDeleteConfirm = $state(false);
let hfRepoWithTag = $derived(ModelsService.buildDownloadTag(repoId, quant, sidecar));
let tagDisplay = $derived.by(() => {
if (quant && sidecar) return `${quant}-${sidecar.toUpperCase()}`;
if (quant) return quant;
if (sidecar) return sidecar.toUpperCase();
return 'default';
});
let phase = $derived(
started && inFlight ? 'downloading' : started && sawProgress ? 'finished' : 'confirm'
);
let progressPercent = $derived.by(() => {
if (!progress || progress.totalBytes <= 0) return 0;
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
});
// Delete is offered once the download completed and the model is registered.
let canDelete = $derived(phase === 'finished' && isDownloaded);
function reset() {
started = false;
sawProgress = false;
cancelling = false;
showDeleteConfirm = false;
}
function handleOpenChange(next: boolean) {
if (next) {
reset();
return;
}
if (!inFlight) onClose();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && phase === 'confirm') {
event.preventDefault();
start();
}
}
function start() {
if (inFlight) return;
started = true;
sawProgress = false;
onDownload();
}
async function cancel() {
if (cancelling || !onCancelDownload) return;
cancelling = true;
try {
onCancelDownload();
} finally {
cancelling = false;
}
}
function handleDelete() {
showDeleteConfirm = false;
onDelete?.();
onClose();
}
// Latch progress: 'in-flight ending' only means finished once the feed has
// reported progress; otherwise the POST resolving alone proves nothing.
$effect(() => {
if (inFlight && progress) sawProgress = true;
});
// Auto-close shortly after the download completes.
$effect(() => {
if (phase !== 'finished') return;
const timer = setTimeout(() => onClose(), 600);
return () => clearTimeout(timer);
});
</script>
<AlertDialog.Root onOpenChange={handleOpenChange} {open}>
<AlertDialog.Content class="max-w-md" onkeydown={handleKeydown}>
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<Download class="h-5 w-5 text-primary" />
{#if phase === 'confirm'}
Download this model?
{:else}
Downloading {tagDisplay}
{/if}
</AlertDialog.Title>
<AlertDialog.Description>
{#if phase === 'confirm'}
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}
</AlertDialog.Description>
{#if previousFailure && phase === 'confirm'}
<div
class="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
role="status"
>
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0" />
<span>
A previous attempt for this tag failed and left partial files on disk. The server will
reject a fresh download until those files are removed. The Retry button below deletes
the partial files automatically.
</span>
</div>
{/if}
</AlertDialog.Header>
{#if canDelete}
<div class="flex justify-end">
<button
aria-label="Delete model from cache"
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"
onclick={() => (showDeleteConfirm = true)}
type="button"
>
<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>
<code class="break-all font-mono"
>POST /models&nbsp;&middot;&nbsp;{`{ model: "${hfRepoWithTag}" }`}</code
>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">File</span>
<code class="break-all font-mono">{filePath}</code>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded bg-primary/15 px-2 py-0.5 font-mono font-semibold text-primary">
{tagDisplay}
</span>
{#if formattedSize}
<span class="text-muted-foreground">{formattedSize}</span>
{/if}
{#if sidecar && !isAuxSidecar(sidecar)}
<span
class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
>
{sidecar}
</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 error}
<p class="text-xs text-destructive">{error}</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={() => onClose()}>
{#if phase === 'finished'}Close{:else}Cancel{/if}
</AlertDialog.Cancel>
{/if}
{#if phase === 'confirm'}
<AlertDialog.Action disabled={inFlight} onclick={start}>
<Download class="mr-1.5 h-4 w-4" />
{previousFailure ? 'Retry download' : 'Download'}
</AlertDialog.Action>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<DialogConfirmation
bind:open={showDeleteConfirm}
cancelText="Cancel"
confirmText="Delete"
description={`Remove "${hfRepoWithTag}" from your cache? Any cached files will be deleted from disk.`}
icon={Trash2}
onCancel={() => (showDeleteConfirm = false)}
onConfirm={handleDelete}
title="Delete model"
variant="destructive"
/>
@@ -0,0 +1,35 @@
<script lang="ts">
interface Props {
/** Bytes downloaded so far. Caller supplies the value; we normalize 0..1. */
downloadedBytes: number;
/** Total bytes for the download plan. */
totalBytes: number;
/** Pin to the bottom edge as a thin overlay like `ModelLoadHighlight`. */
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}
@@ -0,0 +1,77 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
import { ModelsDiscoverDetails, ModelsDiscoverList } from '$lib/components/app/models/discover';
import { modelsHubStore } from '$lib/stores';
let selectedId = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Load the sidebar list on mount (the component is mounted when the dialog opens).
$effect(() => {
void modelsHubStore.fetch();
void modelsHubStore.search('');
});
// Auto-select the first model.
$effect(() => {
const first = modelsHubStore.firstModel;
if (!selectedId && first) {
selectedId = first.id;
}
});
function handleSearchInput(value: string) {
searchQuery = value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
void modelsHubStore.search(value);
}, 300);
}
</script>
<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
bind:value={searchQuery}
class=""
onInput={handleSearchInput}
placeholder="Search models..."
/>
</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
activeId={selectedId}
models={modelsHubStore.models}
onSelect={(id) => (selectedId = id)}
showBaseModelAvatar
/>
{/if}
</div>
</aside>
<main class="overflow-y-auto">
{#if selectedId}
<!-- TODO: load details/tree/readme via HuggingFaceService in the integration branch -->
<ModelsDiscoverDetails
details={null}
files={[]}
loading={true}
modelId={selectedId}
readme={null}
/>
{/if}
</main>
@@ -0,0 +1,107 @@
<script lang="ts">
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 {
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);
let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
// Monogram fallback: org initial on a hue derived from its name, so each org
// gets a stable distinct color.
let hue = $derived.by(() => {
let h = 0;
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
return h % 360;
});
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;
return h % 360;
});
</script>
<span class="relative mt-0.5 inline-flex shrink-0">
{#if avatarError}
<span
aria-hidden="true"
class="flex {size} items-center justify-center rounded-md text-sm font-semibold text-white"
style="background-color: hsl({hue} 60% 45%)"
>
{org.charAt(0).toUpperCase()}
</span>
{:else}
<div class="rounded-md">
<img
alt=""
class="{size} rounded-md {invertAvatar ? 'dark:invert' : ''} {baseImageClass}"
loading="lazy"
onerror={() => (avatarError = true)}
src={HuggingFaceService.getAvatarUrl(org)}
/>
</div>
{/if}
{#if quantOrg && quantOrg !== org}
<Tooltip.Root>
<Tooltip.Trigger
class="absolute {quantPositionClass} h-4.25 w-4.25 overflow-hidden rounded-full border border-background bg-muted "
>
{#if quantError}
<span
aria-hidden="true"
class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
style="background-color: hsl({quantHue} 60% 45%)"
>
{quantOrg.charAt(0).toUpperCase()}
</span>
{:else}
<img
alt=""
class="{quantImageClass} rounded-full {invertQuant ? 'dark:invert' : ''}"
loading="lazy"
onerror={() => (quantError = true)}
src={HuggingFaceService.getAvatarUrl(quantOrg)}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{quantOrg}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
@@ -0,0 +1,40 @@
<script lang="ts">
import { Copy } from '@lucide/svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { copyToClipboard } from '$lib/utils';
interface Props {
open?: boolean;
chatTemplate: string;
onOpenChange?: (open: boolean) => void;
}
let { chatTemplate, onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="flex max-h-[calc(100vh-4rem)] flex-col gap-0 p-0 md:w-[calc(100vw-4rem)]! md:max-w-4xl!"
>
<Dialog.Header class="flex-row items-center justify-between border-b border-border/40 p-4">
<Dialog.Title class="text-sm font-semibold">Chat template</Dialog.Title>
<button
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => copyToClipboard(chatTemplate)}
type="button"
>
<Copy class="h-3.5 w-3.5" />
Copy
</button>
</Dialog.Header>
<pre
class="flex-1 overflow-auto p-4 font-mono text-xs break-all whitespace-pre-wrap text-muted-foreground">{chatTemplate}</pre>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,120 @@
<script lang="ts">
import ModelsDiscoverDetailsDownloadOptions from './ModelsDiscoverDetailsDownloadOptions.svelte';
import ModelsDiscoverDetailsHeader from './ModelsDiscoverDetailsHeader.svelte';
import ModelsDiscoverDetailsReadme from './ModelsDiscoverDetailsReadme.svelte';
import TerminalCommands from './TerminalCommands.svelte';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
/** Model details from `/api/models/{id}?full=true`; null while loading. */
details: HfModelDetailInfo | null;
/** GGUF files of the repo, shards collapsed, sorted by size desc. */
files: HfModelSibling[];
/** README.md content, frontmatter stripped; null when unavailable. */
readme: string | null;
/** True while the model data is being fetched. */
loading?: boolean;
/** Error message when loading failed. */
error?: string | null;
}
let { details, error = null, files, loading = false, modelId, readme }: Props = $props();
let gguf = $derived(details?.gguf);
let baseModels = $derived(HuggingFaceService.getBaseModels(details));
let licenseTag = $derived.by(() => {
const tags = details?.tags ?? [];
return tags.find((t) => t.startsWith('license:'))?.replace('license:', '') ?? null;
});
// Capabilities derived from HF metadata. Vision comes from an mmproj sidecar
// or a multimodal pipeline tag; tool use / reasoning from the chat template.
let hasMmproj = $derived(
files.some((f) => {
const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar;
return sidecar !== null && sidecar !== undefined && isAuxSidecar(sidecar);
})
);
let hasVision = $derived(hasMmproj || details?.pipeline_tag === 'image-text-to-text');
let hasTools = $derived(detectToolUseSupport(gguf?.chat_template ?? ''));
let hasReasoning = $derived(detectThinkingSupport(gguf?.chat_template ?? ''));
// Draft sidecars (mtp, dflash, dspark, eagle3) present in the repo, e.g.
// speculative-decoding drafts. mmproj is excluded: it is vision.
let draftSidecars = $derived.by<ModelSidecar[]>(() => {
const set = new SvelteSet<ModelSidecar>();
for (const file of files) {
const sidecar = HuggingFaceService.extractQuantMeta(file.path)?.sidecar;
if (sidecar && !isAuxSidecar(sidecar)) set.add(sidecar);
}
return [...set];
});
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
const rows = new SvelteMap<number, HfModelSibling[]>();
for (const file of files) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
// mmproj sidecars are already conveyed by the Vision capability badge.
if (meta?.sidecar && isAuxSidecar(meta.sidecar)) continue;
const depth = meta?.quant ? HuggingFaceService.getBitDepth(meta.quant) : null;
const bucket = depth ?? 99;
const list = rows.get(bucket) ?? [];
list.push(file);
rows.set(bucket, list);
}
return Array.from(rows.entries())
.map(([bitDepth, rowFiles]) => ({ bitDepth, files: rowFiles }))
.sort((a, b) => a.bitDepth - b.bitDepth);
});
</script>
{#if loading}
<div class="flex h-full items-center justify-center py-20">
<p class="text-sm text-muted-foreground">Loading model...</p>
</div>
{:else if error}
<div class="flex h-full items-center justify-center py-20">
<p class="text-sm text-destructive">{error}</p>
</div>
{:else if details}
<div class="space-y-6 p-6">
<ModelsDiscoverDetailsHeader
{baseModels}
{details}
{gguf}
{hasReasoning}
{hasTools}
{hasVision}
{licenseTag}
{modelId}
/>
<ModelsDiscoverDetailsDownloadOptions
{bitDepthRows}
{files}
{modelId}
nativeCtxTokens={gguf?.context_length ?? 0}
/>
<TerminalCommands {modelId} sidecars={draftSidecars} />
<ModelsDiscoverDetailsReadme {readme} />
</div>
{/if}
@@ -0,0 +1,252 @@
<script lang="ts">
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check, Cpu, Download, TriangleAlert, X } from '@lucide/svelte';
import { browser } from '$app/environment';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { HuggingFaceService, ModelsService } from '$lib/services';
import type { ModelDownloadProgress } from '$lib/types';
import type { HfModelSibling } from '$lib/types/huggingface';
import { computeFileCompatibilityTiers, detectOs } from '$lib/utils';
/** Download state of a single repo entry, injected by the integration layer. */
export interface DownloadEntryState {
isDownloading: boolean;
progress: ModelDownloadProgress | null;
isDownloaded: boolean;
isFailed: boolean;
}
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
interface PendingDownload {
filePath: string;
sizeBytes: number | null;
quant: string | null;
sidecar: ModelSidecar | null;
}
interface Props {
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
/** GGUF files grouped by bit depth. */
bitDepthRows: BitDepthRow[];
/** Repo GGUF files, input to the compatibility tiers. */
files: HfModelSibling[];
/** Native context window of the model, drives the fit tiers. */
nativeCtxTokens: number;
/** Device memory in GB for the fit tiers; 0 = unknown (no tiers). */
deviceMemoryGb?: number;
/** Download state lookup; defaults to all-neutral. Keyed by `<repo>:<tag>`. */
getDownloadState?: (repoWithTag: string, filePath: string) => DownloadEntryState;
}
let {
bitDepthRows,
deviceMemoryGb = 0,
files,
getDownloadState = () => ({ isDownloaded: false, isDownloading: false, isFailed: false, progress: null }),
modelId,
nativeCtxTokens
}: Props = $props();
let pendingDownload: PendingDownload | null = $state(null);
let osLabel = $derived(browser ? detectOs(navigator.userAgent) : 'unknown');
let tiers = $derived(computeFileCompatibilityTiers(files, nativeCtxTokens, deviceMemoryGb));
function buttonClass(parts: {
isDownloaded: boolean;
isFailed: boolean;
isUnavailable: boolean;
}): string {
const { isDownloaded, isFailed, isUnavailable } = parts;
const classes = [
'relative inline-flex items-center gap-1 overflow-hidden rounded-md border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors'
];
// Buttons stay neutral; only the leading compatibility badge carries
// color (green/yellow/red). Unavailable quants are greyed + disabled.
if (isUnavailable) {
classes.push('cursor-not-allowed opacity-50');
} else {
classes.push('cursor-pointer hover:border-primary/60 hover:bg-primary/5');
}
if (isDownloaded && !isFailed) {
classes.push('border-foreground bg-muted');
} else if (isFailed) {
classes.push('border-destructive');
}
return classes.join(' ');
}
</script>
{#if bitDepthRows.length}
<section class="rounded-xl border">
<div class="flex flex-wrap items-center justify-between gap-2 px-4 pt-3 pb-1">
<h2 class="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Download class="h-4 w-4" />
Downloadable options
</h2>
<span
class="inline-flex items-center gap-1.5 rounded-full border bg-background px-2.5 py-1 text-xs font-medium"
>
<Cpu class="h-3 w-3 text-muted-foreground" />
{osLabel}
{#if deviceMemoryGb > 0}
<span class="text-muted-foreground">({deviceMemoryGb} GB)</span>
{/if}
</span>
</div>
<div class="divide-y px-4 pb-1">
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-3">
<div class="pt-1 text-sm tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
{:else}
{row.bitDepth}-bit
{/if}
</div>
<div class="flex flex-wrap justify-end gap-1.5">
{#each row.files as file (file.path)}
{@const meta = HuggingFaceService.extractQuantMeta(file.path)}
{@const basename = file.path.split('/').pop() ?? file.path}
{@const label = meta?.quant ?? basename.replace(/\.gguf$/i, '')}
{@const hfRepoWithTag = ModelsService.buildDownloadTag(modelId, meta?.quant ?? null, meta?.sidecar ?? null)}
{@const state = getDownloadState(hfRepoWithTag, file.path)}
{@const isDownloading = state.isDownloading}
{@const progress = state.progress}
{@const isDownloaded = state.isDownloaded}
{@const isFailed = state.isFailed}
{@const tier = tiers.get(file.path)}
{@const isUnavailable =
tier === 'none' && !isDownloaded && !isDownloading && !isFailed}
{@const isAvailable = tier === 'full' && !isDownloaded && !isDownloading && !isFailed}
{@const isLimited =
tier === 'limited' && !isDownloaded && !isDownloading && !isFailed}
{@const tooltipText = isDownloading
? `Downloading ${file.path}`
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}. Click to retry.`
: isUnavailable
? `Does not fit this device: ${file.path}`
: `Download ${file.path}`}
<Tooltip.Root>
<Tooltip.Trigger
aria-disabled={isUnavailable}
class={buttonClass({
isDownloaded,
isFailed,
isUnavailable
})}
onclick={() => {
if (isUnavailable) return;
pendingDownload = {
filePath: file.path,
quant: meta?.quant ?? null,
sidecar: meta?.sidecar ?? null,
sizeBytes: file.size ?? null
};
}}
type="button"
>
{#if isAvailable}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-green-600"
>
<Check class="h-2.5 w-2.5 text-white" />
</span>
{:else if isLimited}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-yellow-500"
>
<TriangleAlert class="h-2.5 w-2.5 text-white" />
</span>
{:else if isUnavailable}
<span
class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full bg-red-600"
>
<X class="h-2.5 w-2.5 text-white" />
</span>
{/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"
>
Failed
</span>
{/if}
{#if meta?.sidecar && !isAuxSidecar(meta.sidecar)}
<span
class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"
>
{meta.sidecar}
</span>
{/if}
<span class="font-medium {isDownloaded ? '' : 'text-muted-foreground/80'}"
>{label}</span
>
<span class="-my-1 w-px self-stretch bg-border"></span>
<span class={isDownloaded ? '' : 'text-muted-foreground/80'}>
{#if isDownloading && progress && progress.totalBytes > 0}
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
{:else}
{HuggingFaceService.formatFileSize(file.size ?? 0)}
{/if}
</span>
{#if isDownloading && progress}
<DownloadProgressBar
downloadedBytes={progress.downloadedBytes}
overlay
totalBytes={progress.totalBytes}
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
{#if pendingDownload}
<DialogModelDownload
bind:open={
() => pendingDownload !== null,
(v) => {
if (!v) pendingDownload = null;
}
}
filePath={pendingDownload.filePath}
formattedSize={pendingDownload.sizeBytes != null
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
: undefined}
onClose={() => (pendingDownload = null)}
onDownload={() => {}}
quant={pendingDownload.quant}
repoId={modelId}
sidecar={pendingDownload.sidecar}
/>
{/if}
@@ -0,0 +1,155 @@
<script lang="ts">
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
import ModelsDiscoverDetailsName from './ModelsDiscoverDetailsName.svelte';
import { Download, ExternalLink, Heart, MessageSquareCode } from '@lucide/svelte';
import { ICON_CLASS_SM } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import { formatParameters } from '$lib/utils';
interface Props {
modelId: string;
details: HfModelDetailInfo;
gguf?: HfModelGguf;
baseModels: string[];
licenseTag: string | null;
hasVision: boolean;
hasTools: boolean;
hasReasoning: boolean;
}
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.
let repoOrg = $derived(details.id?.split('/')[0] ?? modelId.split('/')[0] ?? modelId);
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
);
let chatTemplateOpen = $state(false);
</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} />
<ModelsDiscoverDetailsName
{baseModels}
{hasReasoning}
{hasTools}
{hasVision}
modelId={details.id ?? modelId}
/>
</div>
<a
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
href={HuggingFaceService.getModelUrl(modelId)}
rel="noopener noreferrer"
target="_blank"
>
<img alt="" class="h-3.5 w-3.5" src="/recommended-mcp/huggingface.ico" />
View on Hugging Face
<ExternalLink class={ICON_CLASS_SM} />
</a>
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
{#if typeof details.downloads === 'number'}
<span class="inline-flex items-center gap-1.5">
<Download class="h-3.5 w-3.5" />
{HuggingFaceService.formatDownloads(details.downloads)}
</span>
{/if}
{#if typeof details.likes === 'number'}
<span class="inline-flex items-center gap-1.5">
<Heart class="h-3.5 w-3.5" />
{HuggingFaceService.formatLikes(details.likes)}
</span>
{/if}
{#if details.lastModified}
<span>Updated {HuggingFaceService.formatRelativeTime(details.lastModified)}</span>
{/if}
</div>
{#if description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Model size</span>
<span class="px-2.5 py-1 font-medium">{formatParameters(gguf.total)} params</span>
</span>
{/if}
{#if gguf?.context_length}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Context</span>
<span class="px-2.5 py-1 font-medium">{gguf.context_length.toLocaleString()}</span>
</span>
{/if}
{#if gguf?.architecture}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">Architecture</span>
<span class="px-2.5 py-1 font-medium">{gguf.architecture}</span>
</span>
{/if}
{#if gguf?.chat_template}
<button
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => (chatTemplateOpen = true)}
type="button"
>
<MessageSquareCode class="h-3 w-3" />
Chat template
</button>
{/if}
{#if licenseTag}
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
<span class="px-2.5 py-1 text-muted-foreground">License</span>
<span class="px-2.5 py-1 font-medium">{licenseTag}</span>
</span>
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"> </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"
>
gated
</span>
{/if}
</div>
</header>
{#if gguf?.chat_template}
<ModelsDiscoverChatTemplateDialog
bind:open={chatTemplateOpen}
chatTemplate={gguf.chat_template}
/>
{/if}
@@ -0,0 +1,79 @@
<script lang="ts">
import { ExternalLink, Image, Lightbulb, Wrench } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { HuggingFaceService } from '$lib/services';
interface Props {
/** Full HuggingFace model id (quant org + name), e.g. `ggml-org/Qwen3.8-27B-GGUF`. */
modelId: string;
/** Base model ids, shown as small text under the quant name. */
baseModels: string[];
hasVision: boolean;
hasTools: boolean;
hasReasoning: boolean;
}
let { baseModels, hasReasoning, hasTools, hasVision, modelId }: Props = $props();
</script>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h1 class="truncate text-lg font-semibold">{modelId}</h1>
{#if hasVision || hasTools || hasReasoning}
<div class="flex shrink-0 items-center gap-2.5 text-muted-foreground">
{#if hasVision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasTools}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasReasoning}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
</div>
{#if baseModels.length}
<div class="flex items-center gap-1">
<span class="truncate text-xs text-muted-foreground">{baseModels.join(', ')}</span>
<a
aria-label="View base model on HuggingFace"
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
href={HuggingFaceService.getModelUrl(baseModels[0])}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink class="h-3 w-3" />
</a>
</div>
{/if}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { MarkdownContent } from '$lib/components/app';
interface Props {
readme: string | null;
}
let { readme }: Props = $props();
</script>
{#if readme}
<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>
<MarkdownContent allowHtml class="prose-sm max-w-none" content={readme} />
</section>
{/if}
@@ -0,0 +1,148 @@
<script lang="ts">
import ModelId from '../ModelId.svelte';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelInfo } from '$lib/types/huggingface';
import type { ModelModalities } from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport, formatParameters } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
model: HfModelInfo;
}
let { model }: Props = $props();
let contextLength = $derived(model.gguf?.context_length);
// Params badge fallback: the id usually carries the count (`Qwen3.8-27B`),
// but ids like `Kimi-K3` do not. Fall back to the HF param count
// (`gguf.total`); search results omit `gguf`, so fetch details lazily only
// when the name has no params token.
let fetchedParams = $state<number | null>(null);
$effect(() => {
fetchedParams = null;
if (model.gguf?.total || ModelsService.parseModelId(model.id).params) return;
let cancelled = false;
void HuggingFaceService.getDetails(model.id).then((info) => {
if (!cancelled && info?.gguf?.total) fetchedParams = info.gguf.total;
});
return () => {
cancelled = true;
};
});
let hfParams = $derived(model.gguf?.total ?? fetchedParams);
let paramsFallback = $derived(
hfParams && !ModelsService.parseModelId(model.id).params
? formatParameters(hfParams)
: undefined
);
// Reasoning support from the chat template, matching the details view.
let supportsThinking = $derived(detectThinkingSupport(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',
'automatic-speech-recognition',
'text-to-speech',
'voice-activity-detection'
].includes(tag);
const video = ['text-to-video', 'image-to-video', 'video-to-video'].includes(tag);
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 draftSidecars = $derived.by<ModelSidecar[]>(() => {
const set = new SvelteSet<ModelSidecar>();
for (const sibling of model.siblings ?? []) {
const sidecar = HuggingFaceService.extractQuantMeta(sibling.rfilename)?.sidecar;
if (sidecar && !isAuxSidecar(sidecar)) set.add(sidecar);
}
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 (draftSidecars.length === 0) {
sizeRange = base ?? null;
return;
}
void HuggingFaceService.getTree(model.id).then((tree) => {
if (cancelled) return;
const drafts = tree
.filter((f) => {
const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar;
return sidecar && !isAuxSidecar(sidecar);
})
.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">
<ModelId
class="min-w-0"
{contextLength}
{draftSidecars}
hideOrgName
iconsOnNewLine
{modalities}
modelId={model.id}
params={paramsFallback}
{sizeRange}
{supportsThinking}
{supportsToolUse}
wrap
/>
</span>
@@ -0,0 +1,43 @@
<script lang="ts">
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;
active?: boolean;
/** Show the original (base) model's org avatar instead of the repo's org. */
showBaseModelAvatar?: boolean;
onSelect?: (modelId: string) => void;
}
let { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
let org = $derived(model.id.split('/')[0] ?? model.id);
// Org whose avatar is shown: the base model's org when showBaseModelAvatar
// (e.g. the Qwen logo for ggml-org/Qwen3.8-27B-GGUF), else the repo's org.
let avatarOrg = $derived.by(() => {
if (!showBaseModelAvatar) return org;
const base = HuggingFaceService.getBaseModels(model)[0];
return base?.split('/')[0] || org;
});
</script>
<li>
<button
aria-current={active ? 'page' : undefined}
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {active
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-muted/60'}"
onclick={() => onSelect?.(model.id)}
type="button"
>
<ModelsDiscoverAvatar org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
<ModelsDiscoverInfo {model} />
</button>
</li>
@@ -0,0 +1,20 @@
<script lang="ts">
import ModelsDiscoverItem from './ModelsDiscoverItem.svelte';
import type { HfModelInfo } from '$lib/types/huggingface';
interface Props {
models: HfModelInfo[];
activeId?: string | null;
/** Show the original (base) model's org avatar instead of the repo's org. */
showBaseModelAvatar?: boolean;
onSelect?: (modelId: string) => void;
}
let { activeId = null, models, onSelect, showBaseModelAvatar = false }: Props = $props();
</script>
<ul class="space-y-0.5 p-2">
{#each models as model (model.id)}
<ModelsDiscoverItem active={model.id === activeId} {model} {onSelect} {showBaseModelAvatar} />
{/each}
</ul>
@@ -0,0 +1,92 @@
<script lang="ts">
import { Check, Copy, Server, SquareTerminal } from '@lucide/svelte';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
import { copyToClipboard } from '$lib/utils';
interface Props {
/** Full HuggingFace model id, The draft sidecar sits in the same repo. */
modelId: string;
/** Draft sidecar variants present in the repo (mtp, dflash, dspark, eagle3). */
/** Draft sidecars present in the repo (mtp, dflash, dspark, eagle3). */
sidecars?: ModelSidecar[];
}
let { modelId, sidecars = [] }: Props = $props();
// llama.cpp --spec-type value for each draft sidecar.
const SPEC_TYPE: Record<ModelSidecar, string> = {
[ModelAuxSidecar.MMPROJ]: '',
[ModelDraftSidecar.DFLASH]: 'draft-dflash',
[ModelDraftSidecar.DSPARK]: 'draft-dspark',
[ModelDraftSidecar.EAGLE3]: 'eagle3',
[ModelDraftSidecar.MTP]: 'draft-mtp'
};
let copiedIndex = $state<number | null>(null);
async function handleCopy(index: number, text: string) {
await copyToClipboard(text);
copiedIndex = index;
setTimeout(() => (copiedIndex = null), 1500);
}
// One box per binary (serve / cli). Each box lists one command per available
// draft sidecar, or just the base command when none is present.
let boxes = $derived.by(() => {
const variants = sidecars.filter((v) => !isAuxSidecar(v));
const build = (bin: string) => {
const base = `llama ${bin} -hf ${modelId}`;
if (variants.length === 0) return [base];
return variants.map((v) => `${base} -hfd ${modelId} --spec-type ${SPEC_TYPE[v]}`);
};
return [
{ commands: build('serve'), icon: Server, title: 'Serve' },
{ commands: build('cli'), icon: SquareTerminal, title: 'CLI' }
];
});
</script>
<div class="space-y-3">
{#each boxes as box (box.title)}
<div
class="overflow-hidden rounded-md"
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<div
class="flex items-center gap-2 px-3 py-2"
style="border-bottom: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<box.icon class="h-3.5 w-3.5 text-muted-foreground/60" />
<span class="text-xs font-medium text-foreground/80">{box.title}</span>
</div>
<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"
>
<span class="truncate text-foreground/90">{cmd}</span>
<button
aria-label="Copy command"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={() => handleCopy(i, cmd)}
type="button"
>
{#if copiedIndex === i}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
{/each}
</div>
</div>
{/each}
</div>
@@ -0,0 +1,117 @@
/**
*
* MODELS HUB
*
* Components for the Models Hub route (`/models-hub`): a sidebar list of
* HuggingFace GGUF models and a detail view for the selected model.
*
*/
/**
* **ModelsDiscover** - Models hub explorer
*
* The complete discovery layout: a sidebar search + model list on the left and a
* detail view for the selected model on the right. Used as the body of the
* discovery dialog.
*/
export { default as ModelsDiscover } from './ModelsDiscover.svelte';
/**
* **ModelsDiscoverList** - Sidebar model list
*
* Renders the hub's model list as a navigable column. Each row links to the
* model's detail route and highlights the active one.
*/
export { default as ModelsDiscoverList } from './ModelsDiscoverList.svelte';
/**
* **ModelsDiscoverItem** - Single sidebar row
*
* One model entry in the sidebar list. Links to `/models-hub/[org]/[model]`.
*/
export { default as ModelsDiscoverItem } from './ModelsDiscoverItem.svelte';
/**
* **ModelsDiscoverAvatar** - Org avatar for a model row
*
* Shows the org's avatar image, falling back to a monogram on a stable hue
* derived from the org name when the image fails to load.
*/
export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte';
/**
* **ModelsDiscoverInfo** - Model name + metadata for a row
*
* Renders the model name via ModelId and the remaining metadata (org, last
* modified, params, downloads, likes, vision) below it.
*/
export { default as ModelsDiscoverInfo } from './ModelsDiscoverInfo.svelte';
/**
* **ModelsDiscoverDetails** - Model detail view
*
* Detail pane for the selected model. Loads its own data (details + GGUF file
* list) from HuggingFaceService based on the `modelId` route param.
*/
export { default as ModelsDiscoverDetails } from './ModelsDiscoverDetails.svelte';
/**
* **ModelsDiscoverDetailsHeader** - Detail view header
*
* Shows the model avatar (base org + quant org corner badge), name, base model
* info, stats, metadata chips and capability badges.
*/
export { default as ModelsDiscoverDetailsHeader } from './ModelsDiscoverDetailsHeader.svelte';
/**
* **ModelsDiscoverDetailsName** - Model name block
*
* Shows the quant model name with capability icons (vision, tool use,
* reasoning) beside it, and the base model name with a smaller external-link
* icon on the line below.
*/
export { default as ModelsDiscoverDetailsName } from './ModelsDiscoverDetailsName.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptions** - GGUF download options
*
* Groups GGUF files by bit depth and renders per-file download buttons with
* progress, owned by the download confirmation dialog.
*/
export { default as ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions.svelte';
/**
* **ModelsDiscoverChatTemplateDialog** - Chat template viewer
*
* Shows the model's chat template in a scrollable dialog with a copy button.
*/
export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverChatTemplateDialog.svelte';
/**
* **ModelsDiscoverDetailsReadme** - Detail view README
*
* Renders the model card README as markdown.
*/
export { default as ModelsDiscoverDetailsReadme } from './ModelsDiscoverDetailsReadme.svelte';
/**
* **TerminalCommands** - Terminal command block
*
* Shows the `llama serve` / `llama cli` commands for a model with copy buttons.
*/
export { default as TerminalCommands } from './TerminalCommands.svelte';
/**
* **DialogModelDownload** - Download confirmation / progress dialog
*
* Confirms a single GGUF download, tracks live progress over the SSE feed and
* offers cancel / delete-&-retry flows.
*/
export { default as DialogModelDownload } from './DialogModelDownload.svelte';
/**
* **DownloadProgressBar** - Thin download progress bar
*
* Normalizes bytes to a 0..100% bar; can pin to the bottom edge as an overlay.
*/
export { default as DownloadProgressBar } from './DownloadProgressBar.svelte';
@@ -44,6 +44,9 @@ export const STATS_UNITS = {
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;
+9
View File
@@ -46,6 +46,15 @@ export interface ModelDownloadProgress {
}
/**
* Per-byte download progress for one in-flight model download, driven by the
* /models/sse feed. Lives only while a download runs.
*/
export interface ModelDownloadProgress {
downloadedBytes: number;
totalBytes: number;
}
// LLAMA-APP-REUSE: parsed model id shape
export interface ParsedModelId {
raw: string;
+1 -1
View File
@@ -339,7 +339,7 @@ export { buildSandboxToolDefinition, SANDBOX_TOOL_DEFINITION } from './sandbox-t
export { executeGetDatetimeTool } from './get-datetime';
// Browser fallback for the server's get_info tool
export { executeBrowserInfoTool } from './browser-info';
export { detectOs, executeBrowserInfoTool } from './browser-info';
// Tool-use support detection from a chat template
export { detectToolUseSupport } from './chat-template-tool-detector';
@@ -0,0 +1,159 @@
<script lang="ts" module>
import { mockDetails, mockSiblings } from './fixtures/models-discover';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ModelsDiscoverChatTemplateDialog from '$lib/components/app/models/discover/ModelsDiscoverChatTemplateDialog.svelte';
import ModelsDiscoverDetails from '$lib/components/app/models/discover/ModelsDiscoverDetails.svelte';
import ModelsDiscoverDetailsDownloadOptions from '$lib/components/app/models/discover/ModelsDiscoverDetailsDownloadOptions.svelte';
import ModelsDiscoverDetailsHeader from '$lib/components/app/models/discover/ModelsDiscoverDetailsHeader.svelte';
import ModelsDiscoverDetailsName from '$lib/components/app/models/discover/ModelsDiscoverDetailsName.svelte';
import ModelsDiscoverDetailsReadme from '$lib/components/app/models/discover/ModelsDiscoverDetailsReadme.svelte';
import TerminalCommands from '$lib/components/app/models/discover/TerminalCommands.svelte';
import { ModelDraftSidecar } from '$lib/enums';
import type { HfModelSibling } from '$lib/types';
const { Story } = defineMeta({
tags: ['autodocs'],
title: 'Models/Discover/Details'
});
const files: HfModelSibling[] = mockSiblings;
const readme = [
'# Gemma 4 12B IT',
'',
'Quantized ggml-org build of google/gemma-4-12b-it.',
'',
'| Quant | Size |',
'| ----- | ---- |',
'| Q4_K_M | 7.3 GB |',
'| Q8_0 | 13.1 GB |'
].join('\n');
const CHAT_TEMPLATE = '{% for message in messages %}{ tools }{% endfor %}';
</script>
<Story name="Header">
<div class="w-160 p-4">
<ModelsDiscoverDetailsHeader
baseModels={mockDetails.cardData?.base_model ? [String(mockDetails.cardData.base_model)] : []}
details={mockDetails}
hasReasoning
hasTools
hasVision
licenseTag={mockDetails.tags
?.find((t) => t.startsWith('license:'))
?.replace('license:', '') ?? null}
modelId={mockDetails.id ?? 'ggml-org/gemma-4-12b-it-GGUF'}
/>
</div>
</Story>
<Story name="Name">
<div class="space-y-4 p-4">
<ModelsDiscoverDetailsName
baseModels={['google/gemma-4-12b-it']}
hasReasoning
hasTools
hasVision
modelId="ggml-org/gemma-4-12b-it"
/>
<ModelsDiscoverDetailsName
baseModels={[]}
hasReasoning={false}
hasTools={false}
hasVision={false}
modelId="ggml-org/Qwen3.8-27B"
/>
</div>
</Story>
<Story name="Download options">
<div class="w-200 p-4">
<ModelsDiscoverDetailsDownloadOptions
bitDepthRows={[
{ bitDepth: 4, files: files.filter((f) => f.path.includes('Q4_K_M')) },
{ bitDepth: 8, files: files.filter((f) => f.path.includes('Q8_0')) },
{ bitDepth: 16, files: files.filter((f) => f.path.includes('BF16')) }
]}
deviceMemoryGb={128}
{files}
modelId="ggml-org/gemma-4-12b-it-GGUF"
nativeCtxTokens={131072}
/>
</div>
</Story>
<Story name="Download options (unknown device)">
<div class="w-200 p-4">
<ModelsDiscoverDetailsDownloadOptions
bitDepthRows={[
{ bitDepth: 4, files: files.filter((f) => f.path.includes('Q4_K_M')) },
{ bitDepth: 8, files: files.filter((f) => f.path.includes('Q8_0')) }
]}
deviceMemoryGb={0}
{files}
modelId="ggml-org/gemma-4-12b-it-GGUF"
nativeCtxTokens={131072}
/>
</div>
</Story>
<Story name="Terminal commands (no sidecars)">
<div class="w-200 p-4">
<TerminalCommands modelId="ggml-org/Qwen3.8-27B-GGUF" />
</div>
</Story>
<Story name="Terminal commands (MTP sidecar)">
<div class="w-200 p-4">
<TerminalCommands modelId="ggml-org/gemma-4-12b-it-GGUF" sidecars={[ModelDraftSidecar.MTP]} />
</div>
</Story>
<Story name="Readme">
<div class="w-160 p-4">
<ModelsDiscoverDetailsReadme {readme} />
</div>
</Story>
<Story name="Chat template dialog">
<div class="p-4">
<ModelsDiscoverChatTemplateDialog chatTemplate={CHAT_TEMPLATE} open />
</div>
</Story>
<Story name="Details (loading)">
<div class="h-96 w-200 border">
<ModelsDiscoverDetails
details={null}
files={[]}
loading
modelId="ggml-org/gemma-4-12b-it-GGUF"
readme={null}
/>
</div>
</Story>
<Story name="Details (error)">
<div class="h-96 w-200 border">
<ModelsDiscoverDetails
details={null}
error="Model not found"
files={[]}
modelId="ggml-org/does-not-exist-GGUF"
readme={null}
/>
</div>
</Story>
<Story name="Details (loaded)">
<div class="h-96 w-200 overflow-y-auto border">
<ModelsDiscoverDetails
details={mockDetails}
{files}
modelId={mockDetails.id ?? 'ggml-org/gemma-4-12b-it-GGUF'}
{readme}
/>
</div>
</Story>
@@ -0,0 +1,112 @@
<script lang="ts" module>
import { mockListModels } from './fixtures/models-discover';
import { defineMeta } from '@storybook/addon-svelte-csf';
import DialogModelsDiscover from '$lib/components/app/dialogs/DialogModelsDiscover.svelte';
import DialogModelDownload from '$lib/components/app/models/discover/DialogModelDownload.svelte';
import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte';
import ModelsDiscover from '$lib/components/app/models/discover/ModelsDiscover.svelte';
import { ModelDraftSidecar } from '$lib/enums';
import { modelsHubStore } from '$lib/stores';
const { Story } = defineMeta({
tags: ['autodocs'],
title: 'Models/Discover/Dialog'
});
// Wire the hub store singleton with fixtures so the container renders data.
modelsHubStore.models = mockListModels;
modelsHubStore.loading = false;
modelsHubStore.error = null;
const PROGRESS = { downloadedBytes: 3_600_000_000, totalBytes: 7_300_000_000 };
</script>
<Story name="Progress bar">
<div class="space-y-4 p-4">
<div class="space-y-1">
<p class="text-xs text-muted-foreground">0%</p>
<DownloadProgressBar downloadedBytes={0} totalBytes={7300000000} />
</div>
<div class="space-y-1">
<p class="text-xs text-muted-foreground">49%</p>
<DownloadProgressBar downloadedBytes={3600000000} totalBytes={7300000000} />
</div>
<div class="space-y-1">
<p class="text-xs text-muted-foreground">100%</p>
<DownloadProgressBar downloadedBytes={7300000000} totalBytes={7300000000} />
</div>
<div class="relative h-8 overflow-hidden rounded border">
<p class="p-1 text-xs text-muted-foreground">overlay</p>
<DownloadProgressBar downloadedBytes={3600000000} overlay totalBytes={7300000000} />
</div>
</div>
</Story>
<Story name="Download dialog (confirm)">
<div class="p-4">
<DialogModelDownload
filePath="Q4_K_M/gemma-4-12b-it-Q4_K_M.gguf"
formattedSize="7.3 GB"
onCancelDownload={() => {}}
onClose={() => {}}
onDownload={() => {}}
open
quant="Q4_K_M"
repoId="ggml-org/gemma-4-12b-it-GGUF"
sidecar={ModelDraftSidecar.MTP}
/>
</div>
</Story>
<Story name="Download dialog (previous failure)">
<div class="p-4">
<DialogModelDownload
filePath="Q4_K_M/gemma-4-12b-it-Q4_K_M.gguf"
formattedSize="7.3 GB"
onCancelDownload={() => {}}
onClose={() => {}}
onDownload={() => {}}
open
previousFailure
quant="Q4_K_M"
repoId="ggml-org/gemma-4-12b-it-GGUF"
sidecar={null}
/>
</div>
</Story>
<Story name="Download dialog (downloading)">
<div class="p-4">
<DialogModelDownload
filePath="Q4_K_M/gemma-4-12b-it-Q4_K_M.gguf"
inFlight
onCancelDownload={() => {}}
onClose={() => {}}
onDownload={() => {}}
open
progress={PROGRESS}
quant="Q4_K_M"
repoId="ggml-org/gemma-4-12b-it-GGUF"
sidecar={null}
/>
</div>
</Story>
<Story name="Discover (dialog shell)">
<div class="h-160 w-full overflow-hidden border">
<DialogModelsDiscover open />
</div>
</Story>
<Story name="Discover (panes)">
<div class="flex h-160 w-full border">
<ModelsDiscover />
</div>
</Story>
@@ -0,0 +1,51 @@
<script lang="ts" module>
import { mockGemma, mockListModels, mockQwen } from './fixtures/models-discover';
import { defineMeta } from '@storybook/addon-svelte-csf';
import ModelsDiscoverAvatar from '$lib/components/app/models/discover/ModelsDiscoverAvatar.svelte';
import ModelsDiscoverInfo from '$lib/components/app/models/discover/ModelsDiscoverInfo.svelte';
import ModelsDiscoverItem from '$lib/components/app/models/discover/ModelsDiscoverItem.svelte';
import ModelsDiscoverList from '$lib/components/app/models/discover/ModelsDiscoverList.svelte';
const { Story } = defineMeta({
tags: ['autodocs'],
title: 'Models/Discover/List'
});
</script>
<Story name="Avatar">
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center gap-3">
<ModelsDiscoverAvatar org="ggml-org" />
<span class="text-sm">repo org only</span>
</div>
<div class="flex items-center gap-3">
<ModelsDiscoverAvatar org="ggml-org" quantOrg="Qwen" />
<span class="text-sm">base org + quant corner badge</span>
</div>
</div>
</Story>
<Story name="Info">
<div class="flex w-80 flex-col gap-2 p-4">
<ModelsDiscoverInfo model={mockGemma} />
<ModelsDiscoverInfo model={mockQwen} />
</div>
</Story>
<Story name="Item">
<ul class="w-96 p-2">
<ModelsDiscoverItem active model={mockGemma} />
<ModelsDiscoverItem model={mockQwen} />
</ul>
</Story>
<Story name="List">
<div class="h-64 w-96 overflow-y-auto">
<ModelsDiscoverList activeId={mockGemma.id} models={mockListModels} />
</div>
</Story>
@@ -0,0 +1,79 @@
import type { HfModelDetailInfo, HfModelInfo, HfModelSibling } from '$lib/types';
/**
* Fixtures for the Models Discover stories. Shapes mirror the HF API
* responses (`/api/models`, `/api/models/{id}?full=true`, `/tree`) with
* realistic ggml-org style values.
*/
export const mockSiblings: HfModelSibling[] = [
{ path: 'Q8_0/gemma-4-12b-it-Q8_0.gguf', size: 13_100_000_000 },
{ path: 'Q8_0/mmproj-Q8_0.gguf', size: 800_000_000 },
{ path: 'Q4_K_M/gemma-4-12b-it-Q4_K_M.gguf', size: 7_300_000_000 },
{ path: 'Q4_K_M/mmproj-Q4_K_M.gguf', size: 530_000_000 },
{ path: 'Q4_K_M/mtp-Q4_K_M.gguf', size: 460_000_000 },
{ path: 'BF16/gemma-4-12b-it-BF16.gguf', size: 24_500_000_000 }
];
export const mockGemma: HfModelInfo = {
_id: 'gemma',
author: 'ggml-org',
createdAt: '2026-07-01T00:00:00.000Z',
downloads: 1_234_567,
gguf: {
architecture: 'gemma4',
context_length: 131072,
total: 12_000_000_000
},
id: 'ggml-org/gemma-4-12b-it-GGUF',
library_name: 'transformers',
likes: 8900,
modelId: 'ggml-org/gemma-4-12b-it-GGUF',
pipeline_tag: 'image-text-to-text',
private: false,
siblings: mockSiblings.map((s) => ({ rfilename: s.path })),
tags: ['gguf', 'license:gemma', 'base_model:google/gemma-4-12b-it'],
trendingScore: 42
};
export const mockQwen: HfModelInfo = {
...mockGemma,
_id: 'qwen',
createdAt: '2026-08-15T00:00:00.000Z',
downloads: 45600,
gguf: {
architecture: 'qwen3',
context_length: 262144,
total: 27_000_000_000
},
id: 'ggml-org/Qwen3.8-27B-GGUF',
likes: 1200,
modelId: 'ggml-org/Qwen3.8-27B-GGUF',
pipeline_tag: 'text-generation',
tags: ['gguf', 'license:apache-2.0'],
trendingScore: 90
};
export const mockListModels: HfModelInfo[] = [mockGemma, mockQwen];
// Chat template exercising the tool-use and thinking detectors.
const CHAT_TEMPLATE = [
'{%- for message in messages %}',
'{%- if tools %}{{ tools }}{% endif %}',
'{%- if enable_think %} {% endif %}',
'{%- endfor %}'
].join('\n');
export const mockDetails: HfModelDetailInfo = {
...mockGemma,
gated: false,
gguf: {
architecture: 'gemma4',
chat_template: CHAT_TEMPLATE,
context_length: 131072,
total: 12_000_000_000
},
id: 'ggml-org/gemma-4-12b-it-GGUF',
lastModified: '2026-08-01T00:00:00.000Z',
usedStorage: 26_000_000_000
};