mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-11 04:56:56 +02:00
feat: WIP
This commit is contained in:
@@ -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}
|
||||
|
||||
+136
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user