mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-19 17:24:57 +02:00
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:
@@ -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 · {`{ 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"
|
||||
/>
|
||||
+32
-309
@@ -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}
|
||||
|
||||
-22
@@ -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>
|
||||
+186
-83
@@ -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>
|
||||
|
||||
|
||||
+257
@@ -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}
|
||||
-109
@@ -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>
|
||||
+2
-2
@@ -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>
|
||||
-71
@@ -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();
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user