feat: WIP

This commit is contained in:
Aleksander Grygier
2026-09-04 20:07:36 +02:00
parent 10f985a047
commit cdac3a729d
12 changed files with 439 additions and 279 deletions
@@ -1,6 +1,6 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils';
import type { ModelItem } from '$lib/components/app/navigation/utils';
import { ChevronDown, Lightbulb, Loader2, PackageSearch } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
@@ -1,5 +1,5 @@
<script lang="ts">
import type { GroupedModelOptions, ModelItem } from './utils';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
import { ModelsSelectorOption } from '$lib/components/app';
import { modelsStore } from '$lib/stores';
@@ -1,39 +1,23 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check, Copy, Download } from '@lucide/svelte';
import ModelsDownloadManagerDownloadStatusToast from '$lib/components/app/models/download-manager/ModelsDownloadManagerDownloadStatusToast.svelte';
import { ToggleGroup, ToggleGroupItem } from '$lib/components/ui/toggle-group';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { ToggleGroup } from '$lib/components/ui/toggle-group';
import { type ModelSidecar } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
import ModelsDiscoverModelDetailsDownloadOptionsDownloadButton from './ModelsDiscoverModelDetailsDownloadOptionsDownloadButton.svelte';
import ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand from './ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand.svelte';
import ModelsDiscoverModelDetailsDownloadOptionsRow from './ModelsDiscoverModelDetailsDownloadOptionsRow.svelte';
import ModelsDownloadManagerDownloadStatusToast from '$lib/components/app/models/download-manager/ModelsDownloadManagerDownloadStatusToast.svelte';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { HfModelSibling } from '$lib/types/huggingface';
import { copyToClipboard, minMemoryTierGb } from '$lib/utils';
import {
classify,
labelFor,
type BitDepthRow,
type DownloadEntryState,
type QuantOption,
type SelectableFile
} from './download-options.utils';
import { toast } from 'svelte-sonner';
/** 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[] };
/** A selectable GGUF, tagged with its kind: main weights, draft, or aux (mmproj). */
type SelectableFile = HfModelSibling & { kind: 'main' | 'draft' | 'aux' };
/** Option of a quant `<select>`; already-downloaded files stay non-selectable. */
interface QuantOption {
disabled: boolean;
/** Quant token, or the file name when the file carries no quant (e.g. BF16). */
label: string;
path: string;
size: number;
}
interface Props {
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
modelId: string;
@@ -59,12 +43,6 @@
/** Bit depth to preselect for the base model; falls back to the closest one. */
const DEFAULT_BASE_BIT_DEPTH = 4;
// min-w keeps the value clear of the native chevron: Safari sizes a select
// to its widest option, so an exactly-as-wide value would otherwise let the
// chevron overlap the text (draft selects are all same-width quants).
const selectClass =
'h-6 min-w-18 max-w-40 shrink-0 cursor-pointer rounded-md border border-input bg-transparent py-0 pr-3 pl-2 font-mono text-xs outline-none transition-colors hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]';
function stateFor(repoWithTag: string, filePath: string, isSidecar: boolean): DownloadEntryState {
if (getDownloadState) return getDownloadState(repoWithTag, filePath, isSidecar);
@@ -78,26 +56,6 @@
};
}
/** Kind of a file path: the main weights, a draft sidecar, or an aux sidecar (mmproj). */
function classify(path: string): 'main' | 'draft' | 'aux' {
const sidecar = HuggingFaceService.extractQuantMeta(path)?.sidecar;
if (!sidecar) return 'main';
return isAuxSidecar(sidecar) ? 'aux' : 'draft';
}
/** Display label of a file: its quant, else the file name without the extension. */
function labelFor(path: string): string {
const quant = HuggingFaceService.extractQuantMeta(path)?.quant;
if (quant) return quant;
const basename = path.split('/').pop() ?? path;
return basename.replace(/\.gguf$/i, '');
}
/**
* Every selectable file with its kind and download state, in row order.
* Single source of truth for the toggle group rows, the selects and the
@@ -134,6 +92,18 @@
new Set(selectableFiles.filter((f) => f.state.isDownloaded).map((f) => f.path))
);
/** Rows for the row component: per-bit-depth files with state attached. */
let rows = $derived.by(() =>
bitDepthRows.map((row) => {
const paths = new Set(row.files.map((f) => f.path));
return {
bitDepth: row.bitDepth,
files: selectableFiles.filter((f) => paths.has(f.path))
};
})
);
/** Bit depth of a file; `99` (Other) when it carries no quant token. */
function bitDepthOf(path: string): number {
const quant = HuggingFaceService.extractQuantMeta(path)?.quant ?? '';
@@ -161,7 +131,7 @@
let draftOptions = $derived(
draftFiles.map((f) => ({
...optionFor(f),
badge: HuggingFaceService.extractQuantMeta(f.path)?.sidecar
badge: HuggingFaceService.extractQuantMeta(f.path)?.sidecar ?? null
}))
);
@@ -241,8 +211,6 @@
selectedPaths = [...base, added];
}
let copied = $state(false);
/** Selection ordered main-quant-first, so the command reads naturally. */
let selected = $derived.by(() => {
const mains: SelectableFile[] = [];
@@ -290,12 +258,6 @@
draftEntry ? (HuggingFaceService.extractQuantMeta(draftEntry.path)?.quant ?? null) : null
);
async function copyCommand() {
await copyToClipboard(serveCommand);
copied = true;
setTimeout(() => (copied = false), 1500);
}
/**
* Queue one download and surface a live progress toast keyed by the tag,
* so a retry updates the same toast instead of stacking a new one.
@@ -396,11 +358,12 @@
</script>
{#if bitDepthRows.length}
<section class="rounded-xl border">
<section
class="rounded-3xl border border-border/30 bg-muted/40 shadow-xs transition-[box-shadow,border-color] focus-within:border-border focus-within:shadow-sm dark:border-border/20 dark:bg-muted/50"
>
<!-- header row is intentionally hidden for now
<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>
@@ -411,226 +374,48 @@
-->
<ToggleGroup
class="flex w-full flex-col items-stretch divide-y px-4 pb-1"
class="flex w-full flex-col items-stretch divide-y divide-border/50 px-4 pb-1 dark:divide-border/35"
onValueChange={handleSelection}
type="multiple"
value={selectedPaths}
>
{#each bitDepthRows as row (row.bitDepth)}
{@const mainFile = row.files.find(
(f) => !HuggingFaceService.extractQuantMeta(f.path)?.sidecar
)}
{@const draftFile = row.files.find((f) => {
const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar;
return sidecar && !isAuxSidecar(sidecar);
})}
{@const mainMemGb = mainFile ? minMemoryTierGb(mainFile.size ?? 0) : null}
{@const draftMemGb = draftFile ? minMemoryTierGb(draftFile.size ?? 0) : null}
<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}
{#if mainMemGb}
<span class="block text-[10px] whitespace-nowrap text-muted-foreground/60">
needs at least {mainMemGb}GB{draftMemGb ? ` + ${draftMemGb}GB` : ''}+ memory
</span>
{/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 label = labelFor(file.path)}
{@const hfRepoWithTag = ModelsService.buildDownloadTag(
modelId,
meta?.quant ?? null,
meta?.sidecar ?? null
)}
{@const state = stateFor(hfRepoWithTag, file.path, Boolean(meta?.sidecar))}
{@const isDownloading = state.isDownloading}
{@const progress = state.progress}
{@const isDownloaded = state.isDownloaded}
{@const isFailed = state.isFailed}
{@const tooltipText = isDownloading
? `Downloading ${file.path}`
: isDownloaded
? `Already downloaded: ${file.path}`
: isFailed
? `Last attempt failed: ${file.path}`
: `Download ${file.path}`}
<Tooltip.Root>
<Tooltip.Trigger>
{#if isDownloaded}
<!-- downloaded files are not selectable, just marked as done -->
<div
aria-disabled="true"
aria-label={tooltipText}
class="inline-flex cursor-default items-center gap-1 rounded-md border bg-muted px-2 py-1 font-mono text-xs opacity-70"
>
{#if meta?.sidecar && !isAuxSidecar(meta.sidecar)}
<span
class="rounded-md 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">{label}</span>
<span class="-my-1 w-px self-stretch bg-border"></span>
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
<Check class="h-3.5 w-3.5 shrink-0 text-green-500" />
</div>
{:else}
<ToggleGroupItem
aria-label={tooltipText}
class="relative inline-flex h-auto items-center gap-1 overflow-hidden rounded-md! border bg-muted px-2 py-1 text-left font-mono text-xs transition-colors data-[state=on]:border-primary data-[state=on]:bg-primary/10 {isFailed
? 'border-destructive'
: ''}"
value={file.path}
>
{#if isFailed && !isDownloading}
<span
class="rounded-md 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-md 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">{label}</span>
<span class="-my-1 w-px self-stretch bg-border"></span>
<span>
{#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}
</ToggleGroupItem>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</div>
{#each rows as row (row.bitDepth)}
<ModelsDiscoverModelDetailsDownloadOptionsRow bitDepth={row.bitDepth} files={row.files} />
{/each}
</ToggleGroup>
<!-- Terminal command with inline quant selects + download CTA -->
<div class="space-y-2 border-t px-4 pt-3 pb-4">
<div
class="flex flex-wrap items-center gap-2 rounded-md px-3 py-2"
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
>
<div
class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs text-foreground/90"
>
<span>llama</span>
<!-- Download CTA + terminal command with inline quant selects -->
<div class="space-y-2.5 border-t border-border/50 px-4 pt-3.5 pb-4 dark:border-border/35">
<ModelsDiscoverModelDetailsDownloadOptionsDownloadButton
disabled={selected.length === 0 && !commandMain}
label={downloadLabel}
onclick={downloadSelected}
/>
<span>serve</span>
<div aria-hidden="true" class="flex items-center gap-3">
<span class="h-px flex-1 bg-border/50"></span>
<span>-hf</span>
<span class="text-xs whitespace-nowrap text-muted-foreground">
or download from your terminal
</span>
<span class="truncate">{modelId}{commandMainQuant ? ':' : ''}</span>
<!-- Base quant: always part of the command, the 8-bit file by default. -->
{#if baseOptions.length}
<select
aria-label="Base model quantization"
class="{selectClass} {mainEntry ? '' : 'border-dashed'} -ml-2"
onchange={(e) => setPick('main', e.currentTarget.value)}
title={mainEntry
? undefined
: 'Default quant - pick a file above or another quant here'}
value={basePick}
>
{#each baseOptions as option (option.path)}
<option disabled={option.disabled} value={option.path}>
{option.label}
</option>
{/each}
</select>
{/if}
<!-- Draft segment: appears once a draft is picked, quant inline too. -->
{#if draftEntry && draftSidecar}
<span>-hfd</span>
<span class="truncate">{modelId}{commandDraftQuant ? ':' : ''}</span>
<select
aria-label="Draft model quantization"
class={selectClass}
onchange={(e) => setPick('draft', e.currentTarget.value)}
value={draftPick}
>
{#each draftOptions as option (option.path)}
<option disabled={option.disabled} value={option.path}>
<!-- {option.badge ? `${option.badge.toUpperCase()} ` : ''} -->
{option.label}
</option>
{/each}
</select>
<span>--spec-type</span>
<span>{SPEC_TYPE[draftSidecar]}</span>
{/if}
</div>
<button
aria-label="Copy command"
class="ml-auto shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={copyCommand}
type="button"
>
{#if copied}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
<span class="h-px flex-1 bg-border/50"></span>
</div>
<button
class="inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
disabled={selected.length === 0 && !commandMain}
onclick={downloadSelected}
type="button"
>
<Download class="h-4 w-4" />
{downloadLabel}
</button>
<ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand
{baseOptions}
{basePick}
command={serveCommand}
{draftOptions}
{draftPick}
draftQuant={commandDraftQuant}
mainQuant={commandMainQuant}
mainSelected={Boolean(mainEntry)}
{modelId}
onBasePick={(path) => setPick('main', path)}
onDraftPick={(path) => setPick('draft', path)}
specType={draftEntry && draftSidecar ? SPEC_TYPE[draftSidecar] : null}
/>
</div>
</section>
{/if}
@@ -0,0 +1,18 @@
<script lang="ts">
import { Download } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
label: string;
disabled: boolean;
onclick: () => void;
}
let { label, disabled, onclick }: Props = $props();
</script>
<Button class="w-full transition-transform active:scale-[0.98]" {disabled} {onclick}>
<Download class="h-4 w-4" />
{label}
</Button>
@@ -0,0 +1,124 @@
<script lang="ts">
import { Check, Copy } from '@lucide/svelte';
import { type ModelSidecar } from '$lib/constants';
import { copyToClipboard } from '$lib/utils';
import { SELECT_CLASS, type QuantOption } from './download-options.utils';
interface Props {
modelId: string;
/** Full command text, copied to the clipboard as-is. */
command: string;
baseOptions: QuantOption[];
draftOptions: (QuantOption & { badge: ModelSidecar | null })[];
/** Value of the base quant select, mirrored by the parent. */
basePick: string;
/** Value of the draft quant select; empty when no draft is picked. */
draftPick: string;
/** Quant after `-hf`; null when the base file carries no quant. */
mainQuant: string | null;
/** True when the base quant is a deliberate pick, not the default preview. */
mainSelected: boolean;
/** Quant after `-hfd`, shown only when a draft is picked. */
draftQuant: string | null;
/** `--spec-type` value; null hides the draft segment. */
specType: string | null;
onBasePick: (path: string) => void;
onDraftPick: (path: string) => void;
}
let {
baseOptions,
basePick,
command,
draftOptions,
draftPick,
draftQuant,
mainQuant,
mainSelected,
modelId,
onBasePick,
onDraftPick,
specType
}: Props = $props();
let copied = $state(false);
async function copy() {
await copyToClipboard(command);
copied = true;
setTimeout(() => (copied = false), 1500);
}
</script>
<div
class="flex items-center gap-2 rounded-md border border-border/40 bg-background py-2 pr-2 pl-3 shadow-xs dark:border-border/35 dark:bg-background/50"
>
<span aria-hidden="true" class="shrink-0 font-mono text-xs text-muted-foreground/50">$</span>
<div
class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1 py-0.5 font-mono text-xs text-foreground/90"
>
<span>llama</span>
<span>serve</span>
<span>-hf</span>
<span class="truncate">{modelId}{mainQuant ? ':' : ''}</span>
<!-- Base quant: always part of the command, the 8-bit file by default. -->
{#if baseOptions.length}
<select
aria-label="Base model quantization"
class="{SELECT_CLASS} {mainSelected ? '' : 'border-dashed'} -ml-2"
onchange={(e) => onBasePick(e.currentTarget.value)}
title={mainSelected ? undefined : 'Default quant - pick a file above or another quant here'}
value={basePick}
>
{#each baseOptions as option (option.path)}
<option disabled={option.disabled} value={option.path}>
{option.label}
</option>
{/each}
</select>
{/if}
<!-- Draft segment: appears once a draft is picked, quant inline too. -->
{#if specType !== null}
<span>-hfd</span>
<span class="truncate">{modelId}{draftQuant ? ':' : ''}</span>
<select
aria-label="Draft model quantization"
class={SELECT_CLASS}
onchange={(e) => onDraftPick(e.currentTarget.value)}
value={draftPick}
>
{#each draftOptions as option (option.path)}
<option disabled={option.disabled} value={option.path}>
<!-- {option.badge ? `${option.badge.toUpperCase()} ` : ''} -->
{option.label}
</option>
{/each}
</select>
<span>--spec-type</span>
<span>{specType}</span>
{/if}
</div>
<button
aria-label="Copy command"
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-accent/60 hover:text-foreground"
onclick={copy}
type="button"
>
{#if copied}
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3.5 w-3.5" />
{/if}
</button>
</div>
@@ -0,0 +1,109 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check } from '@lucide/svelte';
import { ToggleGroupItem } from '$lib/components/ui/toggle-group';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isAuxSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelSibling } from '$lib/types/huggingface';
import { labelFor, type DownloadEntryState } from './download-options.utils';
interface Props {
/** GGUF file the chip stands for. */
file: HfModelSibling;
/** Download state of the file, from the parent's status feed. */
state: DownloadEntryState;
}
let { file, state }: Props = $props();
let meta = $derived(HuggingFaceService.extractQuantMeta(file.path));
let label = $derived(labelFor(file.path));
let tooltipText = $derived(
state.isDownloading
? `Downloading ${file.path}`
: state.isDownloaded
? `Already downloaded: ${file.path}`
: state.isFailed
? `Last attempt failed: ${file.path}`
: `Download ${file.path}`
);
</script>
<Tooltip.Root>
<Tooltip.Trigger>
{#if state.isDownloaded}
<!-- downloaded files are not selectable, just marked as done -->
<div
aria-disabled="true"
aria-label={tooltipText}
class="inline-flex cursor-default items-center gap-1 rounded-md border border-green-600/25 bg-green-500/5 px-2 py-1 font-mono text-xs dark:border-green-500/30 dark:bg-green-500/10"
>
{#if meta?.sidecar && !isAuxSidecar(meta.sidecar)}
<span
class="rounded-md 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">{label}</span>
<span class="-my-1 w-px mx-0.75 self-stretch bg-green-600/25 dark:bg-green-600/30"></span>
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
<Check class="h-3.5 w-3.5 shrink-0 text-green-500" />
</div>
{:else}
<ToggleGroupItem
aria-label={tooltipText}
class="relative inline-flex h-auto items-center gap-1 overflow-hidden rounded-md! border border-border/30 bg-background px-2 py-1 text-left font-mono text-xs shadow-xs transition-colors hover:data-[state=off]:bg-muted-foreground/10 data-[state=on]:border-primary data-[state=on]:bg-primary/10 data-[state=on]:hover:bg-primary/15 dark:border-border/20 dark:bg-muted-foreground/15 dark:text-secondary-foreground dark:data-[state=on]:border-primary dark:data-[state=on]:bg-primary/15 dark:data-[state=on]:hover:bg-primary/25 {state.isFailed
? 'border-destructive!'
: ''}"
value={file.path}
>
{#if state.isFailed && !state.isDownloading}
<span
class="rounded-md 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-md 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">{label}</span>
<span class="-my-1 mx-0.75 w-px self-stretch bg-border"></span>
<span>
{#if state.isDownloading && state.progress && state.progress.totalBytes > 0}
{Math.round((state.progress.downloadedBytes / state.progress.totalBytes) * 100)}%
{:else}
{HuggingFaceService.formatFileSize(file.size ?? 0)}
{/if}
</span>
{#if state.isDownloading && state.progress}
<DownloadProgressBar
downloadedBytes={state.progress.downloadedBytes}
overlay
totalBytes={state.progress.totalBytes}
/>
{/if}
</ToggleGroupItem>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -0,0 +1,42 @@
<script lang="ts">
import { minMemoryTierGb } from '$lib/utils';
import ModelsDiscoverModelDetailsDownloadOptionsQuantToggle from './ModelsDiscoverModelDetailsDownloadOptionsQuantToggle.svelte';
import type { DownloadEntryState, SelectableFile } from './download-options.utils';
interface Props {
/** Bit depth of the row; `99` renders as "Other". */
bitDepth: number;
/** Every GGUF of this bit depth, with download state attached. */
files: (SelectableFile & { state: DownloadEntryState })[];
}
let { bitDepth, files }: Props = $props();
let mainFile = $derived(files.find((f) => f.kind === 'main') ?? null);
let draftFile = $derived(files.find((f) => f.kind === 'draft') ?? null);
let mainMemGb = $derived(mainFile ? minMemoryTierGb(mainFile.size ?? 0) : null);
let draftMemGb = $derived(draftFile ? minMemoryTierGb(draftFile.size ?? 0) : null);
</script>
<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 bitDepth === 99}
Other
{:else}
{bitDepth}-bit
{/if}
{#if mainMemGb}
<span class="block text-[10px] whitespace-nowrap text-muted-foreground/60">
needs at least {mainMemGb}GB{draftMemGb ? ` + ${draftMemGb}GB` : ''}+ memory
</span>
{/if}
</div>
<div class="flex flex-wrap justify-end gap-1.5">
{#each files as file (file.path)}
<ModelsDiscoverModelDetailsDownloadOptionsQuantToggle {file} state={file.state} />
{/each}
</div>
</div>
@@ -0,0 +1,51 @@
import { isAuxSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelSibling } from '$lib/types/huggingface';
/** Download state of a single repo entry, injected by the integration layer. */
export interface DownloadEntryState {
isDownloading: boolean;
progress: ModelDownloadProgress | null;
isDownloaded: boolean;
isFailed: boolean;
}
export type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
/** A selectable GGUF, tagged with its kind: main weights, draft, or aux (mmproj). */
export type SelectableFile = HfModelSibling & { kind: 'main' | 'draft' | 'aux' };
/** Option of a quant `<select>`; already-downloaded files stay non-selectable. */
export interface QuantOption {
disabled: boolean;
/** Quant token, or the file name when the file carries no quant (e.g. BF16). */
label: string;
path: string;
size: number;
}
/** Kind of a file path: the main weights, a draft sidecar, or an aux sidecar (mmproj). */
export function classify(path: string): 'main' | 'draft' | 'aux' {
const sidecar = HuggingFaceService.extractQuantMeta(path)?.sidecar;
if (!sidecar) return 'main';
return isAuxSidecar(sidecar) ? 'aux' : 'draft';
}
/** Display label of a file: its quant, else the file name without the extension. */
export function labelFor(path: string): string {
const quant = HuggingFaceService.extractQuantMeta(path)?.quant;
if (quant) return quant;
const basename = path.split('/').pop() ?? path;
return basename.replace(/\.gguf$/i, '');
}
// min-w keeps the value clear of the native chevron: Safari sizes a select
// to its widest option, so an exactly-as-wide value would otherwise let the
// chevron overlap the text (draft selects are all same-width quants).
export const SELECT_CLASS =
'h-7 min-w-18 max-w-40 shrink-0 cursor-pointer rounded-md border border-input bg-transparent py-0 pr-3 pl-2 font-mono text-xs outline-none transition-colors hover:bg-accent/40 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]';
@@ -78,6 +78,37 @@ export { default as ModelsDiscoverModelDetailsHeader } from './ModelsDiscoverMod
*/
export { default as ModelsDiscoverModelDetailsDownloadOptions } from './ModelsDiscoverModelDetailsDownloadOptions.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptionsRow** - One bit-depth group of quants
*
* A single bit-depth row inside ModelsDiscoverModelDetailsDownloadOptions: the
* depth label with its memory hint and the quant chips of that depth.
*/
export { default as ModelsDiscoverModelDetailsDownloadOptionsRow } from './ModelsDiscoverModelDetailsDownloadOptionsRow.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptionsQuantToggle** - One quant chip
*
* A single GGUF file as a toggle chip inside the download options toggle
* group, or as a static done chip when the file is already downloaded.
*/
export { default as ModelsDiscoverModelDetailsDownloadOptionsQuantToggle } from './ModelsDiscoverModelDetailsDownloadOptionsQuantToggle.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptionsDownloadButton** - Download CTA
*
* Full-width primary button that queues the current selection for download.
*/
export { default as ModelsDiscoverModelDetailsDownloadOptionsDownloadButton } from './ModelsDiscoverModelDetailsDownloadOptionsDownloadButton.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptionsDownloadCommand** - Terminal command
*
* The `llama serve -hf ...` command box with inline quant selects and a copy
* button; the quant picks are delegated back to the parent via callbacks.
*/
export { default as ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand } from './ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand.svelte';
/**
* **ModelsDiscoverChatTemplateDialog** - Chat template viewer
*
@@ -1,4 +1,4 @@
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/navigation/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { modelsStore, serverStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
@@ -2,7 +2,7 @@
import { defineMeta } from '@storybook/addon-svelte-csf';
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';