mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-20 01:31:31 +02:00
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:
@@ -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 · {`{ 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>
|
||||
+40
@@ -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}
|
||||
+252
@@ -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;
|
||||
|
||||
|
||||
Vendored
+9
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user