feat: WIP

This commit is contained in:
Aleksander Grygier
2026-08-28 09:40:57 +02:00
parent 9ffdcb7865
commit 3fa85af123
8 changed files with 499 additions and 268 deletions
@@ -1,37 +1,19 @@
<script lang="ts">
import {
Check,
Copy,
Download,
ExternalLink,
Eye,
Heart,
MessageSquareCode,
Sparkles,
Wrench
} from '@lucide/svelte';
import { MarkdownContent } from '$lib/components/app';
import { HuggingFaceService, ModelsService } from '$lib/services';
import type { GgufVariantTagInput } from '$lib/services';
import { modelsStore } from '$lib/stores';
import { ExternalLink } from '@lucide/svelte';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types/huggingface';
import { copyToClipboard } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import ModelsDiscoverDetailsDownloadOptions from './ModelsDiscoverDetailsDownloadOptions.svelte';
import ModelsDiscoverDetailsHeader from './ModelsDiscoverDetailsHeader.svelte';
import ModelsDiscoverDetailsReadme from './ModelsDiscoverDetailsReadme.svelte';
import TerminalCommands from './TerminalCommands.svelte';
interface Props {
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
}
interface PendingDownload {
filePath: string;
sizeBytes: number | null;
quant: string | null;
variant: GgufVariantTagInput['variant'];
}
let { modelId }: Props = $props();
let details = $state<HfModelDetailInfo | null>(null);
@@ -39,7 +21,6 @@
let readme = $state<string | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let pendingDownload = $state<PendingDownload | null>(null);
let gguf = $derived(details?.gguf);
let baseModels = $derived(HuggingFaceService.getBaseModels(details));
@@ -60,6 +41,20 @@
Boolean(gguf?.chat_template && /think|reasoning/i.test(gguf.chat_template))
);
// Draft sidecar variants (mtp, dflash, dspark, eagle3) present in the repo,
// e.g. speculative-decoding drafts. mmproj is excluded: it is vision.
let draftVariants = $derived.by<DraftVariant[]>(() => {
const set = new Set<DraftVariant>();
for (const file of files) {
const variant = HuggingFaceService.extractQuantMeta(file.path)?.variant;
if (variant && variant !== 'mmproj') set.add(variant);
}
return [...set];
});
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
const rows = new SvelteMap<number, HfModelSibling[]>();
@@ -126,249 +121,21 @@
</div>
{:else if details}
<div class="space-y-6 p-6">
<!-- Header -->
<header class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<h1 class="truncate text-lg font-semibold">{details.id}</h1>
<button
type="button"
onclick={() => copyToClipboard(details?.id ?? modelId)}
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Copy model id"
>
<Copy class="h-4 w-4" />
</button>
</div>
<a
href={HuggingFaceService.getModelUrl(modelId)}
target="_blank"
rel="noopener noreferrer"
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"
>
<ExternalLink class="h-3.5 w-3.5" />
View on HF
</a>
</div>
<ModelsDiscoverDetailsHeader
{modelId}
{details}
{gguf}
{baseModels}
{licenseTag}
{hasVision}
{hasTools}
{hasReasoning}
/>
<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>
<ModelsDiscoverDetailsDownloadOptions {modelId} {bitDepthRows} />
{#if details.cardData?.description}
<p class="text-sm text-muted-foreground">{details.cardData.description}</p>
{/if}
<TerminalCommands {modelId} {draftVariants} />
<!-- Metadata chips -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{HuggingFaceService.formatFileSize(gguf.total).replace(' B', '')}B params
</span>
{/if}
{#if gguf?.architecture}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground capitalize">
{gguf.architecture.replace(/_/g, ' ')}
</span>
{/if}
{#if gguf?.context_length}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{gguf.context_length.toLocaleString()} ctx
</span>
{/if}
{#if licenseTag}
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{licenseTag}
</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>
<!-- Capability badges -->
{#if hasVision || hasTools || hasReasoning}
<div class="flex flex-wrap items-center gap-1.5">
{#if hasVision}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Eye class="h-3 w-3" />
Vision
</span>
{/if}
{#if hasTools}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Wrench class="h-3 w-3" />
Tool use
</span>
{/if}
{#if hasReasoning}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Sparkles class="h-3 w-3" />
Reasoning
</span>
{/if}
</div>
{/if}
</header>
<!-- Download options -->
{#if bitDepthRows.length}
<section class="space-y-3">
<h2 class="flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
<MessageSquareCode class="h-3.5 w-3.5" />
Download options
</h2>
<div class="space-y-2">
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3">
<div class="pt-1 text-xs font-semibold tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
{:else}
{row.bitDepth}-bit
{/if}
</div>
<div class="flex flex-wrap 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 tagInput = meta?.quant
? { quant: meta.quant, variant: meta.variant ?? null }
: null}
{@const hfRepoWithTag = ModelsService.buildDownloadTag(modelId, tagInput)}
{@const progress = modelsStore.status.getDownloadProgress(hfRepoWithTag)}
{@const isDownloading = modelsStore.status.isDownloadInProgress(hfRepoWithTag)}
{@const isDownloaded = meta?.variant
? modelsStore.status.isDraftDownloaded(modelId, file.path)
: modelsStore.status.isModelDownloaded(hfRepoWithTag)}
{@const isFailed = modelsStore.status.hasFailedDownload(hfRepoWithTag)}
<button
type="button"
onclick={() =>
(pendingDownload = {
filePath: file.path,
sizeBytes: file.size ?? null,
quant: meta?.quant ?? null,
variant: meta?.variant ?? null
})}
title={isDownloading
? `Downloading ${file.path}`
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}. Click to retry.`
: `Download ${file.path}`}
class="relative inline-flex cursor-pointer items-center gap-1 overflow-hidden rounded-md border bg-background px-2 py-1 text-left font-mono text-xs transition-colors hover:border-primary/60 hover:bg-primary/5"
class:border-foreground={isDownloaded && !isDownloading && !isFailed}
class:bg-muted={isDownloaded && !isDownloading && !isFailed}
class:border-destructive={isFailed && !isDownloading}
>
{#if isDownloaded && !isDownloading}
<Check class="h-3 w-3 text-foreground/70" />
{/if}
{#if isFailed && !isDownloading && !isDownloaded}
<span class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase">
Failed
</span>
{/if}
{#if meta?.variant}
<span class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase">
{meta.variant}
</span>
{/if}
<span class="font-medium">{label}</span>
<span class="text-muted-foreground">
{#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
overlay
downloadedBytes={progress.downloadedBytes}
totalBytes={progress.totalBytes}
/>
{/if}
</button>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
<!-- Base model -->
{#if baseModels.length}
<section class="space-y-2">
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
Base model
</h2>
<ul class="space-y-1">
{#each baseModels as base (base)}
<li>
<a
href={HuggingFaceService.getModelUrl(base)}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-md px-1 py-0.5 font-mono text-xs transition-colors hover:bg-muted"
>
{base}
<ExternalLink class="h-3 w-3 opacity-60" />
</a>
</li>
{/each}
</ul>
</section>
{/if}
<!-- README -->
{#if readme}
<section class="space-y-2">
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">README</h2>
<article class="rounded-lg border bg-card p-4">
<MarkdownContent content={readme} class="prose-sm max-w-none" />
</article>
</section>
{/if}
<ModelsDiscoverDetailsReadme {readme} />
</div>
{/if}
{#if pendingDownload}
<DialogModelDownload
bind:open={
() => pendingDownload !== null,
(v) => {
if (!v) pendingDownload = null;
}
}
repoId={modelId}
filePath={pendingDownload.filePath}
quant={pendingDownload.quant}
variant={pendingDownload.variant}
formattedSize={pendingDownload.sizeBytes != null
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
: undefined}
onConfirm={() => (pendingDownload = null)}
onCancel={() => (pendingDownload = null)}
/>
{/if}
@@ -0,0 +1,136 @@
<script lang="ts">
import { Check, MessageSquareCode } from '@lucide/svelte';
import { HuggingFaceService, ModelsService } from '$lib/services';
import type { GgufVariantTagInput } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { HfModelSibling } from '$lib/types/huggingface';
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
interface Props {
modelId: string;
bitDepthRows: BitDepthRow[];
}
interface PendingDownload {
filePath: string;
sizeBytes: number | null;
quant: string | null;
variant: GgufVariantTagInput['variant'];
}
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
let { bitDepthRows, modelId }: Props = $props();
let pendingDownload = $state<PendingDownload | null>(null);
</script>
{#if bitDepthRows.length}
<section class="space-y-3">
<h2 class="flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
<MessageSquareCode class="h-3.5 w-3.5" />
Download options
</h2>
<div class="space-y-2">
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3">
<div class="pt-1 text-xs font-semibold tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
{:else}
{row.bitDepth}-bit
{/if}
</div>
<div class="flex flex-wrap 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 tagInput = meta?.quant
? { quant: meta.quant, variant: meta.variant ?? null }
: null}
{@const hfRepoWithTag = ModelsService.buildDownloadTag(modelId, tagInput)}
{@const progress = modelsStore.status.getDownloadProgress(hfRepoWithTag)}
{@const isDownloading = modelsStore.status.isDownloadInProgress(hfRepoWithTag)}
{@const isDownloaded = meta?.variant
? modelsStore.status.isDraftDownloaded(modelId, file.path)
: modelsStore.status.isModelDownloaded(hfRepoWithTag)}
{@const isFailed = modelsStore.status.hasFailedDownload(hfRepoWithTag)}
<button
type="button"
onclick={() =>
(pendingDownload = {
filePath: file.path,
sizeBytes: file.size ?? null,
quant: meta?.quant ?? null,
variant: meta?.variant ?? null
})}
title={isDownloading
? `Downloading ${file.path}`
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}. Click to retry.`
: `Download ${file.path}`}
class="relative inline-flex cursor-pointer items-center gap-1 overflow-hidden rounded-md border bg-background px-2 py-1 text-left font-mono text-xs transition-colors hover:border-primary/60 hover:bg-primary/5"
class:border-foreground={isDownloaded && !isDownloading && !isFailed}
class:bg-muted={isDownloaded && !isDownloading && !isFailed}
class:border-destructive={isFailed && !isDownloading}
>
{#if isDownloaded && !isDownloading}
<Check class="h-3 w-3 text-foreground/70" />
{/if}
{#if isFailed && !isDownloading && !isDownloaded}
<span class="rounded bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase">
Failed
</span>
{/if}
{#if meta?.variant}
<span class="rounded bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase">
{meta.variant}
</span>
{/if}
<span class="font-medium">{label}</span>
<span class="text-muted-foreground">
{#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
overlay
downloadedBytes={progress.downloadedBytes}
totalBytes={progress.totalBytes}
/>
{/if}
</button>
{/each}
</div>
</div>
{/each}
</div>
</section>
{/if}
{#if pendingDownload}
<DialogModelDownload
bind:open={
() => pendingDownload !== null,
(v) => {
if (!v) pendingDownload = null;
}
}
repoId={modelId}
filePath={pendingDownload.filePath}
quant={pendingDownload.quant}
variant={pendingDownload.variant}
formattedSize={pendingDownload.sizeBytes != null
? HuggingFaceService.formatFileSize(pendingDownload.sizeBytes)
: undefined}
onConfirm={() => (pendingDownload = null)}
onCancel={() => (pendingDownload = null)}
/>
{/if}
@@ -0,0 +1,129 @@
<script lang="ts">
import { Download, ExternalLink, Heart, Image, Lightbulb, Wrench } from '@lucide/svelte';
import { HuggingFaceService } from '$lib/services';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverDetailsName from './ModelsDiscoverDetailsName.svelte';
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);
</script>
<header class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<ModelsDiscoverAvatar org={avatarOrg} quantOrg={quantOrg} />
<ModelsDiscoverDetailsName modelId={details.id ?? modelId} {baseModels} />
</div>
<a
href={HuggingFaceService.getModelUrl(modelId)}
target="_blank"
rel="noopener noreferrer"
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"
>
<ExternalLink class="h-3.5 w-3.5" />
View on HF
</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 details.cardData?.description}
<p class="text-sm text-muted-foreground">{details.cardData.description}</p>
{/if}
<!-- Metadata chips -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{HuggingFaceService.formatFileSize(gguf.total).replace(' B', '')}B params
</span>
{/if}
{#if gguf?.architecture}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground capitalize">
{gguf.architecture.replace(/_/g, ' ')}
</span>
{/if}
{#if gguf?.context_length}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{gguf.context_length.toLocaleString()} ctx
</span>
{/if}
{#if licenseTag}
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{licenseTag}
</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>
<!-- Capability badges -->
{#if hasVision || hasTools || hasReasoning}
<div class="flex flex-wrap items-center gap-1.5">
{#if hasVision}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Image class="h-3 w-3" />
Vision
</span>
{/if}
{#if hasTools}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Wrench class="h-3 w-3" />
Tool use
</span>
{/if}
{#if hasReasoning}
<span class="inline-flex items-center gap-1 rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Lightbulb class="h-3 w-3" />
Reasoning
</span>
{/if}
</div>
{/if}
</header>
@@ -0,0 +1,43 @@
<script lang="ts">
import { Copy, ExternalLink } from '@lucide/svelte';
import { HuggingFaceService } from '$lib/services';
import { copyToClipboard } from '$lib/utils';
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[];
}
let { baseModels, 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>
<button
type="button"
onclick={() => copyToClipboard(modelId)}
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Copy model id"
>
<Copy class="h-4 w-4" />
</button>
</div>
{#if baseModels.length}
<div class="flex items-center gap-1">
<span class="truncate text-xs text-muted-foreground">{baseModels.join(', ')}</span>
<a
href={HuggingFaceService.getModelUrl(baseModels[0])}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
aria-label="View base model on HuggingFace"
>
<ExternalLink class="h-3 w-3" />
</a>
</div>
{/if}
</div>
@@ -0,0 +1,18 @@
<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">
<h2 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">README</h2>
<article class="rounded-lg border bg-card p-4">
<MarkdownContent content={readme} class="prose-sm max-w-none" />
</article>
</section>
{/if}
@@ -15,6 +15,11 @@
let contextLength = $derived(model.gguf?.context_length);
// Reasoning support from the chat template, matching the details view.
let supportsThinking = $derived(
Boolean(model.gguf?.chat_template && /think|reasoning/i.test(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>(() => {
@@ -54,7 +59,15 @@
</script>
<span class="min-w-0 flex-1">
<ModelId modelId={model.id} hideOrgName {modalities} {draftVariants} wrap class="min-w-0" />
<ModelId
modelId={model.id}
hideOrgName
{modalities}
{supportsThinking}
{draftVariants}
wrap
class="min-w-0"
/>
<span class="mt-0.5 block truncate text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
@@ -0,0 +1,87 @@
<script lang="ts">
import { Check, Copy, Server, SquareTerminal } from '@lucide/svelte';
import { type DraftVariant } from '$lib/constants';
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). */
draftVariants?: DraftVariant[];
}
let { draftVariants = [], modelId }: Props = $props();
// llama.cpp --spec-type value for each draft variant.
const SPEC_TYPE: Record<DraftVariant, string> = {
mtp: 'draft-mtp',
dflash: 'draft-dflash',
dspark: 'draft-dspark',
eagle3: 'eagle3',
mmproj: ''
};
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 = draftVariants.filter((v) => v !== 'mmproj');
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 [
{ title: 'Serve', icon: Server, commands: build('serve') },
{ title: 'CLI', icon: SquareTerminal, commands: build('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
type="button"
onclick={() => handleCopy(i, cmd)}
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
aria-label="Copy command"
>
{#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>
@@ -46,6 +46,44 @@ export { default as ModelsDiscoverInfo } from './ModelsDiscoverInfo.svelte';
*/
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 a copy button, 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';
/**
* **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
*