ui : rework the discover download UX into per-quant action chips

Every quant chip is now an independent download action with its own
lifecycle: download, retry, pause, resume, cancel and delete with
confirmation. The store distinguishes user-stopped downloads over the
/models/sse feed so they settle silently, keeps paused progress
resumable, resolves the server-registered id on delete and refetches
the model list so the selector stays in sync with downloads.

Drops the selection toggle group, the download CTA and the download
manager dialogs and progress toasts. The serve command preview is now
a standalone widget with its own picks and an addable draft segment.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-09-02 19:55:20 +02:00
parent 59f09f3e6f
commit ac43904800
18 changed files with 693 additions and 1145 deletions
@@ -1,28 +0,0 @@
<script lang="ts">
import ModelsDownloadManager from '$lib/components/app/models/download-manager/ModelsDownloadManager.svelte';
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(next: boolean) {
open = next;
onOpenChange?.(next);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-200!"
>
<Dialog.Header>
<Dialog.Title class="text-sm font-semibold">Download manager</Dialog.Title>
</Dialog.Header>
<ModelsDownloadManager />
</Dialog.Content>
</Dialog.Root>
@@ -536,13 +536,3 @@ export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
* @see ModelsDiscover in $lib/components/app/models/discover
*/
export { default as DialogModelsDiscover } from './DialogModelsDiscover.svelte';
/**
* **DialogModelsDownloadManager** - download manager dialog.
*
* Lists all tracked model downloads with per-file progress, cancel and
* delete actions.
*
* @see ModelsDownloadManager in $lib/components/app/models/download-manager
*/
export { default as DialogModelsDownloadManager } from './DialogModelsDownloadManager.svelte';
@@ -1,308 +0,0 @@
<script lang="ts">
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Download, LoaderCircle, Trash2, TriangleAlert } from '@lucide/svelte';
import { DialogConfirmation } from '$lib/components/app';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { ModelsService } from '$lib/services';
import type { ModelDownloadProgress } from '$lib/types';
interface Props {
open: boolean;
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
repoId: string;
/** Repo-relative path of the file this download resolves to. */
filePath: string;
/** Quantization token of the selected file, when known. */
quant: string | null;
/** Sidecar type pulled alongside the main weights, when any. */
sidecar: ModelSidecar | null;
/** Human-readable size of the download, when known. */
formattedSize?: string;
/** True when a previous attempt for this tag failed and left partial files. */
previousFailure?: boolean;
/** True while the server reports this download as in flight. */
inFlight?: boolean;
/** Live progress from the /models/sse feed; null before the first event. */
progress?: ModelDownloadProgress | null;
/** True when the model is fully downloaded and registered with the server. */
isDownloaded?: boolean;
/** Error message from a failed start attempt, shown above the footer. */
error?: string | null;
/** Fire the download (POST /models). */
onDownload?: () => void;
/** Cancel the in-flight download (DELETE /models). */
onCancelDownload?: () => void;
/** Delete the model from the server cache; offered once finished. */
onDelete?: () => void;
/** Dialog was dismissed or the download completed. */
onClose?: () => void;
}
let {
error = null,
filePath,
formattedSize,
inFlight = false,
isDownloaded = false,
onCancelDownload,
onClose,
onDelete,
onDownload,
open = $bindable(false),
previousFailure = false,
progress = null,
quant,
repoId,
sidecar
}: Props = $props();
let started = $state(false);
let sawProgress = $state(false);
let cancelling = $state(false);
let showDeleteConfirm = $state(false);
let hfRepoWithTag = $derived(ModelsService.buildDownloadTag(repoId, quant, sidecar));
let tagDisplay = $derived.by(() => {
if (quant && sidecar) return `${quant}-${sidecar.toUpperCase()}`;
if (quant) return quant;
if (sidecar) return sidecar.toUpperCase();
return 'default';
});
let phase = $derived(
started && inFlight ? 'downloading' : started && sawProgress ? 'finished' : 'confirm'
);
let progressPercent = $derived.by(() => {
if (!progress || progress.totalBytes <= 0) return 0;
return Math.round((progress.downloadedBytes / progress.totalBytes) * 100);
});
// Delete is offered once the download completed and the model is registered.
let canDelete = $derived(phase === 'finished' && isDownloaded);
function reset() {
started = false;
sawProgress = false;
cancelling = false;
showDeleteConfirm = false;
}
function handleOpenChange(next: boolean) {
if (next) {
reset();
return;
}
if (!inFlight) onClose?.();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && phase === 'confirm') {
event.preventDefault();
start();
}
}
function start() {
if (inFlight) return;
started = true;
sawProgress = false;
onDownload?.();
}
async function cancel() {
if (cancelling || !onCancelDownload) return;
cancelling = true;
try {
onCancelDownload();
} finally {
cancelling = false;
}
}
function handleDelete() {
showDeleteConfirm = false;
onDelete?.();
onClose?.();
}
// Latch progress: 'in-flight ending' only means finished once the feed has
// reported progress; otherwise the POST resolving alone proves nothing.
$effect(() => {
if (inFlight && progress) sawProgress = true;
});
// Auto-close shortly after the download completes.
$effect(() => {
if (phase !== 'finished') return;
const timer = setTimeout(() => onClose?.(), 600);
return () => clearTimeout(timer);
});
</script>
<AlertDialog.Root onOpenChange={handleOpenChange} {open}>
<AlertDialog.Content class="max-w-md" onkeydown={handleKeydown}>
<AlertDialog.Header>
<AlertDialog.Title class="flex items-center gap-2">
<Download class="h-5 w-5 text-primary" />
{#if phase === 'confirm'}
Download this model?
{:else}
Downloading {tagDisplay}
{/if}
</AlertDialog.Title>
<AlertDialog.Description>
{#if phase === 'confirm'}
llama-server will download this file (and related sidecar weights such as multimodal
projectors or draft models) from Hugging Face into your local model cache.
{:else}
Download runs in the background; this dialog tracks live progress.
{/if}
</AlertDialog.Description>
{#if previousFailure && phase === 'confirm'}
<div
class="mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-xs text-destructive"
role="status"
>
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0" />
<span>
A previous attempt for this tag failed and left partial files on disk. The server will
reject a fresh download until those files are removed. The Retry button below deletes
the partial files automatically.
</span>
</div>
{/if}
</AlertDialog.Header>
{#if canDelete}
<div class="flex justify-end">
<button
aria-label="Delete model from cache"
class="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
onclick={() => (showDeleteConfirm = true)}
type="button"
>
<Trash2 class="h-3.5 w-3.5" />
Delete from cache
</button>
</div>
{/if}
<div class="space-y-3 rounded-md border bg-muted/40 p-3 text-xs">
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">Request</span>
<code class="break-all font-mono"
>POST /models&nbsp;&middot;&nbsp;{`{ model: "${hfRepoWithTag}" }`}</code
>
</div>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground">File</span>
<code class="break-all font-mono">{filePath}</code>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded bg-primary/15 px-2 py-0.5 font-mono font-semibold text-primary">
{tagDisplay}
</span>
{#if formattedSize}
<span class="text-muted-foreground">{formattedSize}</span>
{/if}
{#if sidecar && !isAuxSidecar(sidecar)}
<span
class="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground"
>
{sidecar}
</span>
{/if}
</div>
{#if phase === 'downloading' || phase === 'finished'}
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between text-muted-foreground">
<span>
{#if phase === 'finished'}
Complete
{:else if progress && progress.totalBytes > 0}
Downloading
{:else}
Preparing download
{/if}
</span>
<span class="font-mono tabular-nums">{progressPercent}%</span>
</div>
<DownloadProgressBar
downloadedBytes={progress?.downloadedBytes ?? 0}
totalBytes={progress?.totalBytes ?? 0}
/>
</div>
{/if}
</div>
{#if error}
<p class="text-xs text-destructive">{error}</p>
{/if}
<AlertDialog.Footer>
{#if phase === 'downloading'}
<AlertDialog.Action disabled={cancelling} onclick={cancel}>
{#if cancelling}
<LoaderCircle class="mr-1.5 h-4 w-4 animate-spin" />
Cancelling...
{:else}
Cancel download
{/if}
</AlertDialog.Action>
{:else}
<AlertDialog.Cancel disabled={inFlight} onclick={() => onClose?.()}>
{#if phase === 'finished'}Close{:else}Cancel{/if}
</AlertDialog.Cancel>
{/if}
{#if phase === 'confirm'}
<AlertDialog.Action disabled={inFlight} onclick={start}>
<Download class="mr-1.5 h-4 w-4" />
{previousFailure ? 'Retry download' : 'Download'}
</AlertDialog.Action>
{/if}
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
<DialogConfirmation
bind:open={showDeleteConfirm}
cancelText="Cancel"
confirmText="Delete"
description={`Remove "${hfRepoWithTag}" from your cache? Any cached files will be deleted from disk.`}
icon={Trash2}
onCancel={() => (showDeleteConfirm = false)}
onConfirm={handleDelete}
title="Delete model"
variant="destructive"
/>
@@ -7,16 +7,10 @@
type QuantOption,
type SelectableFile
} from './download-options.utils';
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 { ToggleGroup } from '$lib/components/ui/toggle-group';
import { type ModelSidecar } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import { toast } from 'svelte-sonner';
interface Props {
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
@@ -33,15 +27,11 @@
let { bitDepthRows, getDownloadState, modelId }: Props = $props();
let selectedPaths = $state<string[]>([]);
/** Bit depth to preselect for the base model; falls back to the closest one. */
const DEFAULT_BASE_BIT_DEPTH = 4;
function stateFor(repoWithTag: string, filePath: string, isSidecar: boolean): DownloadEntryState {
if (getDownloadState) return getDownloadState(repoWithTag, filePath, isSidecar);
const isDownloading = modelsStore.status.isDownloadInProgress(repoWithTag);
const isPaused = modelsStore.status.isDownloadPaused(repoWithTag);
return {
// solo downloads register in /v1/models under the tag (args stay empty),
@@ -52,14 +42,18 @@
(isSidecar && modelsStore.status.isSidecarDownloaded(modelId, filePath))),
isDownloading,
isFailed: modelsStore.status.hasFailedDownload(repoWithTag),
progress: modelsStore.status.getDownloadProgress(repoWithTag)
isPaused,
// live progress while downloading, else the frozen snapshot of the pause
progress:
modelsStore.status.getDownloadProgress(repoWithTag) ??
modelsStore.status.getPausedDownloadProgress(repoWithTag),
repoWithTag
};
}
/**
* 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
* command.
* Single source of truth for the chip rows and the command selects.
*/
let selectableFiles = $derived.by(() => {
const files: (SelectableFile & { state: DownloadEntryState })[] = [];
@@ -84,14 +78,6 @@
return files;
});
let mainFiles = $derived(selectableFiles.filter((f) => f.kind === 'main'));
let draftFiles = $derived(selectableFiles.filter((f) => f.kind === 'draft'));
/** Paths already downloaded on the server; static chips, disabled options. */
let downloadedPaths = $derived(
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) => {
@@ -104,310 +90,47 @@
})
);
/** Bit depth of a file; `99` (Other) when it carries no quant token. */
function bitDepthOf(path: string): number {
const quant = HuggingFaceService.extractQuantMeta(path)?.quant ?? '';
const match = quant.match(/^UD-(?=.)/i) ? quant.slice(3) : quant;
const bit = match.match(/(?:I?Q|F)(\d+)/i)?.[1];
return bit ? Number(bit) : 99;
}
function optionFor(file: SelectableFile & { state: DownloadEntryState }): QuantOption {
function optionFor(file: SelectableFile): QuantOption {
return {
disabled: file.state.isDownloaded,
label: labelFor(file.path),
path: file.path,
size: file.size ?? 0
path: file.path
};
}
/** Non-draft quants for the command's base select, in row order. */
let mainOptions = $derived(selectableFiles.filter((f) => f.kind === 'main').map(optionFor));
/**
* Options per kind, in row (bit depth) order, so like quants line up
* between the two selects. Draft options carry their sidecar type (MTP,
* DFLASH...) since a repo can ship more than one draft flavour.
* Draft options for the command's draft select, with their sidecar type
* (MTP, DFLASH...) since a repo can ship more than one draft flavour.
*/
let baseOptions = $derived(mainFiles.map(optionFor));
let draftOptions = $derived(
draftFiles.map((f) => ({
...optionFor(f),
badge: HuggingFaceService.extractQuantMeta(f.path)?.sidecar ?? null
}))
selectableFiles
.filter((f) => f.kind === 'draft')
.map((f) => ({
...optionFor(f),
badge: HuggingFaceService.extractQuantMeta(f.path)?.sidecar ?? null
}))
);
/**
* Default base quant: the untouched selection falls back to the 4-bit file,
* or the lowest bit depth available when there is none. Downloaded files
* are skipped, so the command points at something there is still to fetch.
*/
function defaultBasePath(): string {
const candidates = mainFiles.filter((f) => !downloadedPaths.has(f.path));
const pool = candidates.length ? candidates : mainFiles;
const preferred = pool.find((f) => bitDepthOf(f.path) === DEFAULT_BASE_BIT_DEPTH);
if (preferred) return preferred.path;
const ranked = [...pool].sort((a, b) => bitDepthOf(a.path) - bitDepthOf(b.path));
return ranked[0]?.path ?? '';
}
/**
* Selection source of truth: the toggle group, the selects and the command
* all read it; the selects also mirror it. A pick replaces the previous one
* of its kind, an empty value drops it, aux sidecars are left alone.
*/
function setPick(kind: 'main' | 'draft', path: string) {
const rest = selectedPaths.filter((p) => classify(p) !== kind);
selectedPaths = path ? [...rest, path] : rest;
}
/**
* Seed the default quant once, when the file list first resolves. After
* that the selection is entirely user-driven; nothing re-applies it.
*/
let seeded = false;
$effect(() => {
if (seeded || !bitDepthRows.length) return;
const fallback = defaultBasePath();
if (fallback) {
selectedPaths = [fallback];
}
seeded = true;
});
/**
* Constrained selection: at most one base model and one draft sidecar.
* Aux sidecars (mmproj) are unconstrained.
*/
function handleSelection(next: string[]) {
const added = next.find((p) => !selectedPaths.includes(p));
if (!added) {
selectedPaths = next;
return;
}
const kind = classify(added);
const base = kind === 'aux' ? selectedPaths : selectedPaths.filter((p) => classify(p) !== kind);
selectedPaths = [...base, added];
}
/** Selection ordered main-quant-first, so the command reads naturally. */
let selected = $derived.by(() => {
const mains: SelectableFile[] = [];
const drafts: SelectableFile[] = [];
for (const file of selectableFiles) {
if (!selectedPaths.includes(file.path)) continue;
if (file.kind === 'draft') drafts.push(file);
else mains.push(file);
}
return [...mains, ...drafts];
});
/** Selected main weights, drive the `-hf <repo>:<quant>` tag. */
let mainEntry = $derived(selected.find((f) => f.kind === 'main') ?? null);
/** Selected draft sidecar; its type drives the `--spec-type` flag. */
let draftEntry = $derived(selected.find((f) => f.kind === 'draft') ?? null);
/** Selected paths for the command preview; mirror the selection one-way. */
let basePick = $derived(mainEntry?.path ?? '');
let draftPick = $derived(draftEntry?.path ?? '');
let draftSidecar = $derived(
draftEntry ? (HuggingFaceService.extractQuantMeta(draftEntry.path)?.sidecar ?? null) : null
);
/** True when the picked draft is the shared (target-borrowing) variant. */
let draftShared = $derived(
draftEntry ? (HuggingFaceService.extractQuantMeta(draftEntry.path)?.shared ?? false) : false
);
// LLAMA-APP-REUSE: --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'
};
/** Base file the command shows: the picked one, else whatever the base select holds. */
let commandMain = $derived(mainEntry ?? mainFiles.find((f) => f.path === basePick) ?? null);
/** Quant of the file the `-hf` tag points at; null when the file carries no quant. */
let commandMainQuant = $derived(
commandMain ? (HuggingFaceService.extractQuantMeta(commandMain.path)?.quant ?? null) : null
);
/** Quant of the file the `-hfd` tag points at. */
let commandDraftQuant = $derived(
draftEntry ? (HuggingFaceService.extractQuantMeta(draftEntry.path)?.quant ?? null) : null
);
/**
* 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.
*/
async function queueDownload(tag: string) {
try {
await modelsStore.status.downloadModel(tag);
} catch {
// the store already toasted the failure
return;
}
toast.custom(ModelsDownloadManagerDownloadStatusToast, {
componentProps: { repoWithTag: tag },
duration: Infinity,
id: tag
});
}
/**
* Files the CTA would download: the selection, else the command's base
* pick. The same set feeds the total size on the button label.
*/
let downloadQueue = $derived.by(() =>
mainEntry
? selected
: draftEntry
? selected
: [
...selected.filter((f) => f.kind === 'main' || f.kind === 'aux'),
...(commandMain ? [commandMain] : [])
]
);
/** Total bytes the CTA would fetch; drives the size in the button label. */
let downloadTotalBytes = $derived(downloadQueue.reduce((sum, file) => sum + (file.size ?? 0), 0));
/**
* Fire downloads for the selection. With no main chip on, the command shows
* the default base quant, so the CTA queues exactly that file too.
*/
function downloadSelected() {
for (const file of downloadQueue) {
const meta = HuggingFaceService.extractQuantMeta(file.path);
const tag = ModelsService.buildDownloadTag(
modelId,
meta?.quant ?? null,
meta?.sidecar ?? null
);
if (modelsStore.status.hasFailedDownload(tag)) {
void modelsStore.status.cancelDownload(tag).then(() => queueDownload(tag));
} else {
void queueDownload(tag);
}
}
selectedPaths = [];
}
/** The llama serve command; always readable, driven by the inline selects. */
// LLAMA-APP-REUSE: serve command shape (-hf / -hfd / --spec-type)
let serveCommand = $derived.by(() => {
const mainQuant = commandMain
? HuggingFaceService.extractQuantMeta(commandMain.path)?.quant
: null;
const mainTag = mainQuant ? `${modelId}:${mainQuant}` : modelId;
const parts = ['llama', 'serve', '-hf', mainTag];
if (draftEntry && draftSidecar) {
const draftQuant = HuggingFaceService.extractQuantMeta(draftEntry.path)?.quant;
const draftTag = draftQuant ? `${modelId}:${draftQuant}` : modelId;
parts.push('-hfd', draftTag, '--spec-type', SPEC_TYPE[draftSidecar]);
}
return parts.join(' ');
});
/**
* CTA label for the command shown: the selection, else the preview base
* quant, plus the total download size.
*/
let downloadLabel = $derived.by(() => {
const main = mainEntry ?? commandMain;
const draftLabel = draftSidecar
? `${draftSidecar.toUpperCase()}${draftShared ? '-SHARED' : ''}`
: undefined;
let label = 'Download';
if (main && draftLabel) label = `Download ${labelFor(main.path)} + ${draftLabel}`;
else if (draftLabel) label = `Download ${draftLabel} draft`;
else if (main) label = `Download ${labelFor(main.path)}`;
if (downloadTotalBytes > 0) {
label += ` · ${HuggingFaceService.formatFileSize(downloadTotalBytes)}`;
}
return label;
});
</script>
{#if bitDepthRows.length}
<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"
>
<ToggleGroup
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}
>
<section class="rounded-3xl border border-border/30 bg-muted/60 shadow-xs dark:border-border/20">
<!-- One chip per file, each an independent download action with its own
lifecycle state; nothing here selects anything. -->
<div class="flex w-full flex-col divide-y divide-border/50 px-4 pb-1 dark:divide-border/35">
{#each rows as row (row.bitDepth)}
<ModelsDiscoverModelDetailsDownloadOptionsRow bitDepth={row.bitDepth} files={row.files} />
{/each}
</ToggleGroup>
</div>
<!-- Download CTA (draft-only when the draft chip is the sole pick) + 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}
<!-- Terminal command preview, standalone: its picks are not bound to the chips. -->
<div class="border-t border-border/50 px-4 pt-3.5 pb-4 dark:border-border/35">
<ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand
{draftOptions}
{mainOptions}
{modelId}
/>
<div aria-hidden="true" class="flex items-center gap-3">
<span class="h-px flex-1 bg-border/50"></span>
<span class="text-xs whitespace-nowrap text-muted-foreground">
or run in your terminal
</span>
<span class="h-px flex-1 bg-border/50"></span>
</div>
{#if selected.length > 0}
<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}
/>
{/if}
</div>
</section>
{/if}
@@ -1,22 +0,0 @@
<script lang="ts">
import { Download } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
interface Props {
label: string;
disabled: boolean;
onclick: () => void;
}
let { disabled, label, onclick }: Props = $props();
</script>
<Button
class="h-10 w-full rounded-lg transition-transform active:scale-[0.97]"
{disabled}
{onclick}
>
<Download class="h-4 w-4" />
{label}
</Button>
@@ -1,57 +1,109 @@
<script lang="ts">
import { type QuantOption } from './download-options.utils';
import { Check, Copy } from '@lucide/svelte';
import { quantBitDepth, type QuantOption, SPEC_TYPE } from './download-options.utils';
import { Check, Copy, Plus, X } from '@lucide/svelte';
import * as Select from '$lib/components/ui/select';
import { type ModelSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { copyToClipboard } from '$lib/utils';
interface Props {
modelId: string;
/** Full command text, copied to the clipboard as-is. */
command: string;
baseOptions: QuantOption[];
/** Non-draft quants of the repo, in bit-depth row order. */
mainOptions: QuantOption[];
/** Draft sidecar files with their sidecar badge; empty when the repo ships none. */
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 { draftOptions, mainOptions, modelId }: Props = $props();
// Command picks, owned here: nothing two-way binds them to the quant chips.
let basePick = $state<string | null>(null);
let draftPick = $state<string | null>(null);
let draftTypePick = $state<ModelSidecar | null>(null);
let withDraft = $state(false);
/** Bit depth to prefer in the default base quant; the closest one wins. */
const DEFAULT_BASE_BIT_DEPTH = 4;
function bitDepthOf(path: string): number {
return quantBitDepth(HuggingFaceService.extractQuantMeta(path)?.quant ?? null);
}
/**
* Base file the command points at: the user's pick while it still exists in
* the options, else the 4-bit file, else the lowest bit depth available. A
* stale pick (the details pane switched models) falls back on its own.
*/
let baseOption = $derived.by(() => {
const picked = mainOptions.find((option) => option.path === basePick);
if (picked) return picked;
const preferred = mainOptions.find(
(option) => bitDepthOf(option.path) === DEFAULT_BASE_BIT_DEPTH
);
if (preferred) return preferred;
const ranked = [...mainOptions].sort((a, b) => bitDepthOf(a.path) - bitDepthOf(b.path));
return ranked[0] ?? null;
});
/** Draft sidecar types the repo ships, in option order. */
let specTypes = $derived(
draftOptions
.map((option) => option.badge)
.filter((badge): badge is ModelSidecar => badge !== null)
.filter((badge, index, all) => all.indexOf(badge) === index)
);
/**
* Draft type the --spec-type select points at: the user's pick while it
* still exists, else the first type the repo ships.
*/
let draftType = $derived(
draftTypePick && specTypes.includes(draftTypePick) ? draftTypePick : (specTypes[0] ?? null)
);
/** Draft files of the picked type; the quant select only offers these. */
let typeDraftOptions = $derived(draftOptions.filter((option) => option.badge === draftType));
/** Draft file the -hfd tag points at: the user's pick, else the first of the type. */
let draftOption = $derived(
withDraft
? (typeDraftOptions.find((option) => option.path === draftPick) ??
typeDraftOptions[0] ??
null)
: null
);
/** Quant of the file the `-hf` tag points at; null when the file carries no quant. */
let mainQuant = $derived(
baseOption ? (HuggingFaceService.extractQuantMeta(baseOption.path)?.quant ?? null) : null
);
/** Quant of the file the `-hfd` tag points at. */
let draftQuant = $derived(
draftOption ? (HuggingFaceService.extractQuantMeta(draftOption.path)?.quant ?? null) : null
);
/** `--spec-type` value; null when no draft type resolved. */
let specType = $derived(draftType ? SPEC_TYPE[draftType] : null);
/** The llama serve command, composed from the inline picks. */
let command = $derived.by(() => {
const parts = ['llama', 'serve', '-hf', mainQuant ? `${modelId}:${mainQuant}` : modelId];
if (draftOption && draftQuant) {
parts.push('-hfd', `${modelId}:${draftQuant}`, '--spec-type', specType ?? '');
}
return parts.join(' ');
});
let copied = $state(false);
// Trigger labels: the select trigger has no automatic value rendering.
let baseSelectedLabel = $derived(
baseOptions.find((option) => option.path === basePick)?.label ?? basePick
);
let draftSelectedLabel = $derived(
draftOptions.find((option) => option.path === draftPick)?.label ?? draftPick
);
async function copy() {
await copyToClipboard(command);
copied = true;
@@ -59,6 +111,14 @@
}
</script>
<!-- <div aria-hidden="true" class="flex items-center gap-3 mt-2 mb-4">
<span class="h-px flex-1 bg-border/50"></span>
<span class="text-xs whitespace-nowrap text-muted-foreground"> or run in your terminal </span>
<span class="h-px flex-1 bg-border/50"></span>
</div> -->
<div
class="relative flex items-center gap-2 overflow-hidden rounded-lg border border-border/40 bg-background py-2.5 pl-4 pr-10 shadow-xs dark:border-border/35 dark:bg-background/50"
>
@@ -74,30 +134,20 @@
<span class="shrink-0">{modelId}{mainQuant ? ':' : ''}</span>
<!-- Base quant: always part of the command, the 8-bit file by default. -->
{#if baseOptions.length}
<Select.Root onValueChange={(v) => v && onBasePick(v)} type="single" value={basePick}>
<!-- Base quant: always part of the command, the 4-bit file by default. -->
{#if baseOption}
<Select.Root onValueChange={(v) => v && (basePick = v)} type="single" value={baseOption.path}>
<Select.Trigger
aria-label="Base model quantization"
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0 {mainSelected
? ''
: 'border-dashed'}"
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
size="xs"
title={mainSelected
? undefined
: 'Default quant - pick a file above or another quant here'}
>
{baseSelectedLabel}
{baseOption.label}
</Select.Trigger>
<Select.Content class="font-mono text-xs">
{#each baseOptions as option (option.path)}
<Select.Item
class="text-xs"
disabled={option.disabled}
label={option.label}
value={option.path}
>
{#each mainOptions as option (option.path)}
<Select.Item class="text-xs" label={option.label} value={option.path}>
{option.label}
</Select.Item>
{/each}
@@ -105,38 +155,91 @@
</Select.Root>
{/if}
<!-- Draft segment: appears once a draft is picked, quant inline too. -->
{#if specType !== null}
<span>-hfd</span>
<!-- Draft add: a tiny dashed affordance right of the base part; gone once added. -->
{#if draftOptions.length && !withDraft}
<button
aria-label="Add draft model"
class="mx-1 inline-flex h-5 shrink-0 cursor-pointer items-center gap-1 rounded-md border border-dashed border-border/60 px-1.5 text-[10px] text-muted-foreground transition-colors hover:border-border hover:text-foreground"
onclick={() => (withDraft = true)}
type="button"
>
<Plus class="h-3 w-3" />
<span class="shrink-0">{modelId}{draftQuant ? ':' : ''}</span>
add draft model
</button>
{/if}
<Select.Root onValueChange={(v) => v && onDraftPick(v)} type="single" value={draftPick}>
<Select.Trigger
aria-label="Draft model quantization"
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
size="xs"
<!-- Draft segment: quant and spec type of the picked draft flavour. The X
at the end drops the whole segment (the add button is gone once added);
it only appears while hovering the segment, or directly on touch -->
{#if draftOption}
<span class="group/draft inline-flex shrink-0 items-center gap-x-2">
<span>-hfd</span>
<span class="shrink-0">{modelId}{draftQuant ? ':' : ''}</span>
<Select.Root
onValueChange={(v) => v && (draftPick = v)}
type="single"
value={draftOption.path}
>
{draftSelectedLabel}
</Select.Trigger>
<Select.Trigger
aria-label="Draft model quantization"
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
size="xs"
>
{draftOption.label}
</Select.Trigger>
<Select.Content class="font-mono text-xs">
{#each draftOptions as option (option.path)}
<Select.Item
class="text-xs"
disabled={option.disabled}
label={option.label}
value={option.path}
<Select.Content class="font-mono text-xs">
{#each typeDraftOptions as option (option.path)}
<Select.Item class="text-xs" label={option.label} value={option.path}>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if draftType}
<span>--spec-type</span>
<!-- the select only earns its chrome when there is a real choice to make -->
{#if specTypes.length > 1}
<Select.Root
onValueChange={(v) => v && (draftTypePick = v as ModelSidecar)}
type="single"
value={draftType}
>
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Select.Trigger
aria-label="Draft type"
class="border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
size="xs"
>
{SPEC_TYPE[draftType]}
</Select.Trigger>
<span>--spec-type</span>
<Select.Content class="font-mono text-xs">
{#each specTypes as type (type)}
<Select.Item class="text-xs" label={SPEC_TYPE[type]} value={type}>
{SPEC_TYPE[type]}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else}
<span>{SPEC_TYPE[draftType]}</span>
{/if}
{/if}
<span>{specType}</span>
<button
aria-label="Remove draft model"
class="shrink-0 cursor-pointer text-muted-foreground/60 opacity-0 transition-[opacity,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover/draft:opacity-100 [@media(pointer:coarse)]:opacity-100"
onclick={() => (withDraft = false)}
type="button"
>
<X class="h-3 w-3" />
</button>
</span>
{/if}
</div>
@@ -0,0 +1,257 @@
<script lang="ts">
import { type DownloadEntryState, labelFor } from './download-options.utils';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check, Download, Loader2, Pause, Play, RotateCw, X } from '@lucide/svelte';
import DialogConfirmation from '$lib/components/app/dialogs/DialogConfirmation.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { isAuxSidecar } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { HfModelSibling } from '$lib/types';
interface Props {
/** GGUF file the chip stands for. */
file: HfModelSibling;
/** Download state of the file, from the parent's status feed. */
entry: DownloadEntryState;
}
let { entry, file }: Props = $props();
// delete confirmation state
let confirmDeleteOpen = $state(false);
/** Queue the download; a failed attempt leaves partial files, drop them first. */
async function startDownload() {
try {
if (entry.isFailed) await modelsStore.status.cancelDownload(entry.repoWithTag);
await modelsStore.status.downloadModel(entry.repoWithTag);
} catch {
// the store already toasted the failure
}
}
let meta = $derived(HuggingFaceService.extractQuantMeta(file.path));
let label = $derived(labelFor(file.path));
let percent = $derived(
entry.progress && entry.progress.totalBytes > 0
? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
: null
);
let tooltipText = $derived(
entry.isDownloading
? 'Pause downloading'
: entry.isPaused
? 'Resume downloading'
: entry.isDownloaded
? 'Delete model'
: entry.isFailed
? `Retry download: ${file.path}`
: `Download ${file.path}`
);
</script>
{#if entry.isDownloaded}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- downloaded chips are delete actions: green check by default, red X on hover -->
<button
aria-label={tooltipText}
class="group relative inline-flex h-auto cursor-pointer items-center gap-1 rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-xs transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
border-green-600/25 bg-green-500/5 hover:border-destructive/50 hover:bg-destructive/10 dark:border-green-500/30 dark:bg-green-500/10 dark:hover:border-destructive/50 dark:hover:bg-destructive/15"
onclick={() => (confirmDeleteOpen = true)}
type="button"
>
{#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-green-600/25 transition-colors duration-200 group-hover:bg-destructive/30 dark:bg-green-600/30 dark:group-hover:bg-destructive/30"
></span>
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
<!-- icon slot: crossfade check -> x; touch devices show the delete affordance directly -->
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Check
class="absolute h-3.5 w-3.5 text-green-500 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-75 group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
/>
<X
class="absolute h-3.5 w-3.5 scale-75 text-destructive opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
/>
</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
<DialogConfirmation
confirmText="Delete"
description={`This permanently removes ${modelsStore.toDisplayName(entry.repoWithTag)} from disk. You can download it again later.`}
onCancel={() => (confirmDeleteOpen = false)}
onConfirm={() => {
confirmDeleteOpen = false;
void modelsStore.status.cancelDownload(entry.repoWithTag);
}}
open={confirmDeleteOpen}
title="Delete model"
variant="destructive"
/>
{:else if entry.isDownloading || entry.isPaused}
<!-- in-flight / paused chips: the chip itself pauses / resumes on click, the
trailing X cancels (stops and discards the partial files). The X slot is
reserved, so the chip row never reflows when the affordance fades in -->
<span class="group/cancel inline-flex items-center gap-1">
<Tooltip.Root>
<Tooltip.Trigger>
<button
aria-label={tooltipText}
class="group relative inline-flex h-auto cursor-pointer items-center gap-1 overflow-hidden rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-xs transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
{entry.isPaused
? 'border-yellow-600/40 bg-yellow-500/10 hover:bg-yellow-500/20 dark:border-yellow-500/30 dark:bg-yellow-500/10'
: 'border-border/30 bg-background hover:bg-muted-foreground/10 dark:border-border/20 dark:bg-muted-foreground/15'}"
onclick={() => {
if (entry.isDownloading) void modelsStore.status.pauseDownload(entry.repoWithTag);
else void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
}}
type="button"
>
{#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>
{#if percent !== null}
<span class="tabular-nums">{percent}%</span>
{:else if entry.isPaused}
<span>Paused</span>
{:else}
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
{/if}
{#if entry.isDownloading}
<!-- spinner fades into the pause affordance on hover; opacity only, the
spin keyframes own the transform so scale would fight them -->
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Loader2
class="absolute h-3.5 w-3.5 animate-spin text-muted-foreground transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
/>
<Pause
class="absolute h-3.5 w-3.5 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
/>
</span>
{:else}
<!-- paused: the play affordance fades in on hover; visible directly on touch -->
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Play
class="absolute h-3.5 w-3.5 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
/>
</span>
{/if}
{#if percent !== null}
<DownloadProgressBar
downloadedBytes={entry.progress?.downloadedBytes ?? 0}
overlay
totalBytes={entry.progress?.totalBytes ?? 0}
/>
{/if}
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<button
aria-label="Cancel downloading"
class="inline-flex h-3.5 w-3.5 shrink-0 cursor-pointer scale-75 items-center justify-center rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover/cancel:scale-100 group-hover/cancel:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
onclick={() => void modelsStore.status.cancelDownload(entry.repoWithTag)}
type="button"
>
<X class="h-3.5 w-3.5" />
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Cancel downloading</p>
</Tooltip.Content>
</Tooltip.Root>
</span>
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
<!-- idle chips download on click (retry when the last attempt failed) -->
<button
aria-label={tooltipText}
class="group relative inline-flex h-auto cursor-pointer items-center gap-1 overflow-hidden rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-xs transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
border-border/30 bg-background hover:bg-muted-foreground/10 dark:border-border/20 dark:bg-muted-foreground/15 dark:text-secondary-foreground dark:hover:bg-muted-foreground/25
{entry.isFailed ? 'border-destructive!' : ''}"
onclick={() => void startDownload()}
type="button"
>
{#if entry.isFailed}
<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>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
<span class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{#if entry.isFailed}
<RotateCw class="h-3.5 w-3.5 text-destructive" />
{:else}
<Download
class="h-3.5 w-3.5 text-muted-foreground transition-colors duration-150 group-hover:text-foreground"
/>
{/if}
</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{tooltipText}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
@@ -1,109 +0,0 @@
<script lang="ts">
import { type DownloadEntryState, labelFor } from './download-options.utils';
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';
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>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { DownloadEntryState, SelectableFile } from './download-options.utils';
import ModelsDiscoverModelDetailsDownloadOptionsQuantToggle from './ModelsDiscoverModelDetailsDownloadOptionsQuantToggle.svelte';
import ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton from './ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton.svelte';
import { minMemoryTierGb } from '$lib/utils';
interface Props {
@@ -36,7 +36,7 @@
<div class="flex flex-wrap justify-end gap-1.5">
{#each files as file (file.path)}
<ModelsDiscoverModelDetailsDownloadOptionsQuantToggle {file} state={file.state} />
<ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton entry={file.state} {file} />
{/each}
</div>
</div>
@@ -1,29 +1,37 @@
import { isAuxSidecar } from '$lib/constants';
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import type { HfModelSibling } from '$lib/types/huggingface';
/**
* Option of a quant `<select>`; the picks only compose the serve command and
* are not bound to the quant chips.
*/
export interface QuantOption {
/** Quant token, or the file name when the file carries no quant (e.g. BF16). */
label: string;
/** Repo-relative file path the option stands for. */
path: string;
}
/** Download state of a single repo entry, injected by the integration layer. */
export interface DownloadEntryState {
/** Server identifier the entry's actions (pause / resume / cancel / retry) target. */
repoWithTag: string;
isDownloading: boolean;
progress: ModelDownloadProgress | null;
isDownloaded: boolean;
isPaused: boolean;
isFailed: boolean;
}
/** Download state of a single repo entry, injected by the integration layer. */
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;
@@ -43,3 +51,25 @@ export function labelFor(path: string): string {
return basename.replace(/\.gguf$/i, '');
}
/**
* Bit depth of a quant token; `99` (Other) when it carries none. The `UD-`
* unsloth prefix is stripped before matching.
*/
export function quantBitDepth(quant: string | null): number {
if (!quant) return 99;
const stripped = quant.match(/^UD-(?=.)/i) ? quant.slice(3) : quant;
const bit = stripped.match(/(?:I?Q|F)(\d+)/i)?.[1];
return bit ? Number(bit) : 99;
}
// LLAMA-APP-REUSE: --spec-type value for each draft sidecar
export const SPEC_TYPE: Record<ModelSidecar, string> = {
[ModelAuxSidecar.MMPROJ]: '',
[ModelDraftSidecar.DFLASH]: 'draft-dflash',
[ModelDraftSidecar.DSPARK]: 'draft-dspark',
[ModelDraftSidecar.EAGLE3]: 'eagle3',
[ModelDraftSidecar.MTP]: 'draft-mtp'
};
@@ -73,8 +73,8 @@ export { default as ModelsDiscoverModelDetailsHeader } from './ModelsDiscoverMod
/**
* **ModelsDiscoverDetailsDownloadOptions** - GGUF download options
*
* Groups GGUF files by bit depth and renders per-file download buttons with
* progress, owned by the download confirmation dialog.
* Groups GGUF files by bit depth and renders one independent download
* action chip per file, plus the standalone terminal command preview.
*/
export { default as ModelsDiscoverModelDetailsDownloadOptions } from './ModelsDiscoverModelDetailsDownloadOptions.svelte';
@@ -87,25 +87,18 @@ export { default as ModelsDiscoverModelDetailsDownloadOptions } from './ModelsDi
export { default as ModelsDiscoverModelDetailsDownloadOptionsRow } from './ModelsDiscoverModelDetailsDownloadOptionsRow.svelte';
/**
* **ModelsDiscoverDetailsDownloadOptionsQuantToggle** - One quant chip
* **ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton** - 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.
* A single GGUF file as an independent action chip: download / retry when
* idle, pause / resume / cancel while in flight, delete when 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';
export { default as ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton } from './ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton.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.
* button; owns its picks, nothing two-way binds them to the quant chips.
*/
export { default as ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand } from './ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand.svelte';
@@ -139,14 +132,6 @@ export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverCha
*/
export { default as ModelsDiscoverModelDetailsReadme } from './ModelsDiscoverModelDetailsReadme.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
*
@@ -1,97 +0,0 @@
<script lang="ts">
import { Download, HardDriveDownload, Trash2 } from '@lucide/svelte';
import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
function isLoaded(status: ServerModelStatus | null): boolean {
return status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING;
}
</script>
<div class="space-y-4">
{#if modelsStore.status.downloadEntries().length}
<section class="space-y-2">
<h3
class="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase"
>
<Download class="h-3.5 w-3.5" />
In progress
</h3>
{#each modelsStore.status.downloadEntries() as entry (entry.repoWithTag)}
<div class="flex flex-col gap-1 rounded-md border p-3">
<div class="flex items-center justify-between gap-2">
<span class="truncate font-mono text-xs">{entry.repoWithTag}</span>
<button
aria-label="Cancel download"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-destructive"
onclick={() => void modelsStore.status.cancelDownload(entry.repoWithTag)}
type="button"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
{#each Object.entries(entry.progress.files) as [file, fileProgress] (file)}
<div class="space-y-0.5">
<div class="flex items-center justify-between text-muted-foreground">
<span class="truncate font-mono text-xs">{file}</span>
<span class="font-mono tabular-nums">
{fileProgress.total > 0
? Math.round((fileProgress.done / fileProgress.total) * 100)
: 0}%
</span>
</div>
<DownloadProgressBar
downloadedBytes={fileProgress.done}
totalBytes={fileProgress.total}
/>
</div>
{/each}
</div>
{/each}
</section>
{/if}
<section class="space-y-2">
<h3
class="flex items-center gap-1.5 text-xs font-medium tracking-wide text-muted-foreground uppercase"
>
<HardDriveDownload class="h-3.5 w-3.5" />
Downloaded
</h3>
{#if modelsStore.status.downloadedEntries().length}
{#each modelsStore.status.downloadedEntries() as entry (entry.id)}
<div class="flex items-center justify-between gap-2 rounded-md border p-3">
<span class="truncate font-mono text-xs">{entry.id}</span>
<div class="flex shrink-0 items-center gap-2">
{#if isLoaded(entry.status)}
<span
class="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-primary uppercase"
>
Loaded
</span>
{/if}
<button
aria-label="Delete model"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-destructive"
onclick={() => void modelsStore.status.cancelDownload(entry.id)}
type="button"
>
<Trash2 class="h-4 w-4" />
</button>
</div>
</div>
{/each}
{:else}
<p class="text-sm text-muted-foreground">No downloaded models yet.</p>
{/if}
</section>
</div>
@@ -1,71 +0,0 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte';
import { modelsStore } from '$lib/stores';
interface Props {
/** `<repo>:<tag>` the download was queued under; progress is read live from the store. */
repoWithTag: string;
/** Injected by svelte-sonner; dismisses this toast. */
closeToast?: () => void;
}
let { closeToast, repoWithTag }: Props = $props();
// Read live from the /models/sse feed, so the toast updates itself.
let progress = $derived(modelsStore.status.getDownloadProgress(repoWithTag));
function percent(done: number, total: number): number {
return total > 0 ? Math.round((done / total) * 100) : 0;
}
// The feed clears progress once the download settles; close then - the
// store's finished/failed toast reports the outcome.
let hadProgress = $state(false);
$effect(() => {
if (progress) {
hadProgress = true;
} else if (hadProgress) {
closeToast?.();
}
});
</script>
<div class="w-80 space-y-2 rounded-md border bg-background p-3 shadow-sm">
<div class="flex items-center justify-between gap-2">
<span class="truncate text-xs font-medium" title={repoWithTag}>{repoWithTag}</span>
<button
aria-label="Dismiss"
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
onclick={() => closeToast?.()}
type="button"
>
<X class="h-3.5 w-3.5" />
</button>
</div>
{#if progress}
<div class="space-y-1.5">
{#each Object.entries(progress.files) as [file, fileProgress] (file)}
<div class="space-y-0.5">
<div class="flex items-center justify-between gap-2 text-muted-foreground">
<span class="truncate font-mono text-xs">{file}</span>
<span class="shrink-0 font-mono tabular-nums">
{percent(fileProgress.done, fileProgress.total)}%
</span>
</div>
<DownloadProgressBar
downloadedBytes={fileProgress.done}
totalBytes={fileProgress.total}
/>
</div>
{/each}
</div>
{:else}
<p class="text-xs text-muted-foreground">Waiting for the server to report progress...</p>
{/if}
</div>
@@ -118,18 +118,3 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* Respects the user's `showRawModelNames` setting.
*/
export { default as ModelId } from './ModelId.svelte';
/**
* **ModelsDownloadManager** - tracked downloads list
*
* Lists every in-flight download with per-file progress and a delete action.
*/
export { default as ModelsDownloadManager } from './download-manager/ModelsDownloadManager.svelte';
/**
* **ModelsDownloadManagerDownloadStatusToast** - per-entry download toast
*
* One toast per download with a progress bar per file (main + sidecars) and
* a CTA to open the download manager.
*/
export { default as ModelsDownloadManagerDownloadStatusToast } from './download-manager/ModelsDownloadManagerDownloadStatusToast.svelte';
@@ -204,6 +204,9 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
const response = await ModelsService.list();
this.routerModels = response.data;
// keep the selector options in sync: a downloaded / deleted model shows
// up here too, not only in the router model rows
this.models = this.buildModelOptions(response);
await this.props.fetchModalitiesForLoadedModels();
const visible = this.getVisibleModels();
+138 -20
View File
@@ -81,6 +81,8 @@ export class ModelStatusManager {
private failedDownloads = new SvelteSet<string>();
private loadingStates = new SvelteMap<string, boolean>();
private loadProgress = new SvelteMap<string, ModelLoadProgress>();
/** Paused downloads with their last reported progress, or null when none arrived before the pause. */
private pausedDownloads = new SvelteMap<string, ModelDownloadProgress | null>();
// /models/sse feed state, the single source of truth for status and load progress
private statusAbort: AbortController | null = null;
private statusReaderActive = false;
@@ -88,6 +90,8 @@ export class ModelStatusManager {
string,
{ target: ServerModelStatus; resolve: () => void; reject: (e: Error) => void }
>();
/** Tags the user asked to stop (pause or cancel); the download_failed the stop triggers is intentional, not a failure. */
private stopRequests = new SvelteMap<string, 'pause' | 'cancel'>();
/**
* Cancel an in-flight download or remove a previously downloaded/failed model
@@ -103,13 +107,27 @@ export class ModelStatusManager {
this.subscribe();
// in-flight: the kill triggers download_failed over the feed; mark it as a
// user cancel so it settles silently instead of toasting a failure
if (this.downloadProgress.has(repoWithTag)) {
this.stopRequests.set(repoWithTag, 'cancel');
}
// a downloaded model registers under the name the router derived from the
// cached file (e.g. the UD- quant prefix is dropped), so resolve the tag to
// the registered id before asking the server to remove it
const registeredId =
this.host.routerModels.find((m) => downloadIdKey(m.id) === downloadIdKey(repoWithTag))?.id ??
repoWithTag;
try {
const res = await ModelsService.cancelDownload(repoWithTag);
const res = await ModelsService.cancelDownload(registeredId);
const ok = res.success === true;
if (ok) {
this.downloadProgress.delete(repoWithTag);
this.failedDownloads.delete(repoWithTag);
this.pausedDownloads.delete(repoWithTag);
}
return ok;
@@ -143,30 +161,35 @@ export class ModelStatusManager {
constructor(private host: ModelStatusHost) {}
/**
* Models registered on the router (i.e. already in its cache), as a list
* for the download manager. Rows come and go with the feed's models_reload
* and model_remove events.
* All tracked downloads (in flight or paused) with their last reported
* progress, for the models selector's "Download in progress" section.
* Paused entries carry their frozen progress snapshot.
*/
downloadedEntries(): { id: string; status: ServerModelStatus | null }[] {
return this.host.routerModels.map((m) => ({ id: m.id, status: m.status?.value ?? null }));
}
/**
* All tracked downloads (in flight), as a list for the download manager.
*/
downloadEntries(): { progress: ModelDownloadProgress; repoWithTag: string }[] {
return Array.from(this.downloadProgress, ([repoWithTag, progress]) => ({
downloadEntries(): {
isPaused: boolean;
progress: ModelDownloadProgress | null;
repoWithTag: string;
}[] {
const inFlight = Array.from(this.downloadProgress, ([repoWithTag, progress]) => ({
isPaused: false,
progress,
repoWithTag
}));
const paused = Array.from(this.pausedDownloads, ([repoWithTag, progress]) => ({
isPaused: true,
progress,
repoWithTag
}));
return [...inFlight, ...paused];
}
/**
* Trigger a model download from HuggingFace via POST /models
* (ggml-org/llama.cpp#23976). The download runs in the background on the
* server; the model appears in the list once the feed reports models_reload.
* Progress is reported by the /models/sse feed; the caller owns the
* start/progress UI.
* Progress is reported by the /models/sse feed; resuming a paused download
* (same tag) continues from the partial files the pause kept on disk.
*/
async downloadModel(repoWithTag: string): Promise<void> {
if (!serverStore.isRouterMode) {
@@ -178,12 +201,23 @@ export class ModelStatusManager {
// the feed must be live so the resulting models_reload event refreshes the list
this.subscribe();
// resuming a paused download: drop the paused state, and let the server
// discard its stale DOWNLOADED entry (via the list fetch) before re-posting
if (this.pausedDownloads.delete(repoWithTag) || this.stopRequests.delete(repoWithTag)) {
await this.host.fetchRouterModels();
}
try {
const res = await ModelsService.downloadModel(repoWithTag);
if (!res.success) {
throw new Error(res.error?.message ?? 'Server rejected the download request');
}
// flip the chip to "downloading" right away; the feed refines it with real progress
this.downloadProgress.set(repoWithTag, { downloadedBytes: 0, files: {}, totalBytes: 0 });
toast.success(`Download started: ${this.host.toDisplayName(repoWithTag)}`);
} catch (error) {
toast.error(`Download failed: ${repoWithTag}`);
@@ -212,6 +246,14 @@ export class ModelStatusManager {
return this.loadProgress.get(modelId) ?? null;
}
/**
* Last reported progress of a paused download, or null when no progress
* event arrived before the pause.
*/
getPausedDownloadProgress(repoWithTag: string): ModelDownloadProgress | null {
return this.pausedDownloads.get(repoWithTag) ?? null;
}
/** Whether the most recent download attempt for the given entry failed. */
hasFailedDownload(repoWithTag: string): boolean {
return this.failedDownloads.has(repoWithTag);
@@ -226,11 +268,10 @@ export class ModelStatusManager {
}
/**
* True when the given sidecar file (repo-relative path) has been pulled as
* the `--model-draft` or `--mmproj` of some registered model.
* True when the user paused an in-flight download and it has not been resumed.
*/
isSidecarDownloaded(repoId: string, filePath: string): boolean {
return this.downloadedSidecars.has(`${repoId}/${filePath}`);
isDownloadPaused(repoWithTag: string): boolean {
return this.pausedDownloads.has(repoWithTag);
}
/**
@@ -248,6 +289,14 @@ export class ModelStatusManager {
return this.loadingStates.get(modelId) ?? false;
}
/**
* True when the given sidecar file (repo-relative path) has been pulled as
* the `--model-draft` or `--mmproj` of some registered model.
*/
isSidecarDownloaded(repoId: string, filePath: string): boolean {
return this.downloadedSidecars.has(`${repoId}/${filePath}`);
}
async load(modelId: string): Promise<void> {
if (this.host.isModelLoaded(modelId)) return;
@@ -278,6 +327,31 @@ export class ModelStatusManager {
}
}
/**
* Pause an in-flight download (ROUTER mode only). The server stops the
* download child but keeps the partial files on disk, so re-posting the
* tag (downloadModel) resumes the download where it stopped. The feed
* reports the stop as download_failed; a 'pause' stop request marks it as such.
*/
async pauseDownload(repoWithTag: string): Promise<void> {
if (!serverStore.isRouterMode) {
toast.error('Model downloads are only available in router mode');
return;
}
this.subscribe();
this.stopRequests.set(repoWithTag, 'pause');
try {
await ModelsService.unload(repoWithTag);
} catch {
this.stopRequests.delete(repoWithTag);
toast.error(`Failed to pause: ${repoWithTag}`);
}
}
/**
* Open the /models/sse feed and keep it live with auto reconnect.
* Idempotent and router mode only.
@@ -331,19 +405,55 @@ export class ModelStatusManager {
this.loadProgress.clear();
this.downloadProgress.clear();
this.failedDownloads.clear();
this.pausedDownloads.clear();
this.stopRequests.clear();
}
/**
* Drop the stored progress for the model and toast the outcome.
* Marks failed entries so the UI can offer a delete-and-retry path.
* A user pause keeps the last progress and stays resumable, a user cancel
* settles silently; genuine failures are marked so the UI can offer a
* delete-and-retry path.
*/
private applyDownloadFinished(event: ApiModelsSseEvent): void {
let request: 'pause' | 'cancel' | undefined;
if (event.event === ServerModelsSseEventType.DOWNLOAD_FAILED) {
request = this.stopRequests.get(event.model);
this.stopRequests.delete(event.model);
}
const progress = this.downloadProgress.get(event.model) ?? null;
this.downloadProgress.delete(event.model);
if (request === 'cancel') {
// user cancel: settle silently, the feed's model_remove cleans up the entry
this.failedDownloads.delete(event.model);
this.pausedDownloads.delete(event.model);
return;
}
if (request === 'pause') {
this.pausedDownloads.set(event.model, progress);
this.failedDownloads.delete(event.model);
return;
}
this.pausedDownloads.delete(event.model);
const ok = event.event === ServerModelsSseEventType.DOWNLOAD_FINISHED;
if (ok) {
this.failedDownloads.delete(event.model);
// the finished download only registers in /v1/models on the next list
// fetch (the server reloads its model table then), so refetch to flip
// the quant chips to "downloaded" without waiting for a dialog reopen
void this.host.fetchRouterModels();
toast.success(`Download finished: ${this.host.toDisplayName(event.model)}`);
} else {
this.failedDownloads.add(event.model);
@@ -467,7 +577,15 @@ export class ModelStatusManager {
this.host.routerModels = this.host.routerModels.filter((m) => m.id !== modelId);
this.loadProgress.delete(modelId);
this.downloadProgress.delete(modelId);
this.failedDownloads.delete(modelId);
this.pausedDownloads.delete(modelId);
this.stopRequests.delete(modelId);
this.rejectStatus(modelId, new Error(`Model removed: ${this.host.toDisplayName(modelId)}`));
// drop the row from the selector options too; they rebuild from the list
// response, which only a refetch provides
void this.host.fetchRouterModels();
}
/**
@@ -2,12 +2,9 @@
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, modelsStore } from '$lib/stores';
import { SvelteSet } from 'svelte/reactivity';
import { modelsHubStore } from '$lib/stores';
const { Story } = defineMeta({
tags: ['autodocs'],
@@ -18,13 +15,6 @@
modelsHubStore.models = mockListModels;
modelsHubStore.loading = false;
modelsHubStore.error = null;
// Mark the Q4_K_M tag as failed for the previous-failure story. The status
// manager keeps this in a private SvelteSet keyed by `<repo>:<tag>`.
const FAILED_TAG = 'ggml-org/gemma-4-12b-it-GGUF:Q4_K_M';
(modelsStore.status as unknown as { failedDownloads: Set<string> }).failedDownloads =
new SvelteSet([FAILED_TAG]);
</script>
<Story name="Progress bar">
@@ -55,34 +45,6 @@
</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"
onClose={() => {}}
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"
onClose={() => {}}
open
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 />
@@ -70,6 +70,33 @@
</div>
</Story>
<!-- Injected download states cover the non-idle chip looks: downloading,
paused, downloaded and the failed retry badge. -->
<Story name="Download options (download states)">
<div class="w-200 p-4">
<ModelsDiscoverModelDetailsDownloadOptions
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')) }
]}
getDownloadState={(repoWithTag, filePath) => ({
isDownloaded: filePath.includes('BF16'),
isDownloading: filePath.includes('Q4_K_M') && !filePath.includes('mtp'),
isFailed: filePath.includes('mtp'),
isPaused: filePath.includes('Q8_0'),
progress: filePath.includes('Q4_K_M')
? { downloadedBytes: 3_200_000_000, files: {}, totalBytes: 7_300_000_000 }
: filePath.includes('Q8_0')
? { downloadedBytes: 1_300_000_000, files: {}, totalBytes: 13_100_000_000 }
: null,
repoWithTag
})}
modelId="ggml-org/gemma-4-12b-it-GGUF"
/>
</div>
</Story>
<Story name="Readme">
<div class="w-160 p-4">
<ModelsDiscoverModelDetailsReadme {readme} />