From ac4390480022796f42533c3f6881a7971ce7e6e9 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Wed, 2 Sep 2026 19:55:20 +0200 Subject: [PATCH] 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 --- .../DialogModelsDownloadManager.svelte | 28 -- .../src/lib/components/app/dialogs/index.ts | 10 - .../discover/DialogModelDownload.svelte | 308 ---------------- ...DiscoverModelDetailsDownloadOptions.svelte | 341 ++---------------- ...etailsDownloadOptionsDownloadButton.svelte | 22 -- ...tailsDownloadOptionsDownloadCommand.svelte | 269 +++++++++----- ...sDownloadOptionsQuantDownloadButton.svelte | 257 +++++++++++++ ...elDetailsDownloadOptionsQuantToggle.svelte | 109 ------ ...coverModelDetailsDownloadOptionsRow.svelte | 4 +- .../models/discover/download-options.utils.ts | 50 ++- .../components/app/models/discover/index.ts | 29 +- .../ModelsDownloadManager.svelte | 97 ----- ...sDownloadManagerDownloadStatusToast.svelte | 71 ---- .../ui/src/lib/components/app/models/index.ts | 15 - .../ui/src/lib/stores/models/index.svelte.ts | 3 + .../ui/src/lib/stores/models/status.svelte.ts | 158 +++++++- .../ModelsDiscoverDialog.stories.svelte | 40 +- .../ModelsDiscoverModelDetails.stories.svelte | 27 ++ 18 files changed, 693 insertions(+), 1145 deletions(-) delete mode 100644 tools/ui/src/lib/components/app/dialogs/DialogModelsDownloadManager.svelte delete mode 100644 tools/ui/src/lib/components/app/models/discover/DialogModelDownload.svelte delete mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsDownloadButton.svelte create mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton.svelte delete mode 100644 tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsQuantToggle.svelte delete mode 100644 tools/ui/src/lib/components/app/models/download-manager/ModelsDownloadManager.svelte delete mode 100644 tools/ui/src/lib/components/app/models/download-manager/ModelsDownloadManagerDownloadStatusToast.svelte diff --git a/tools/ui/src/lib/components/app/dialogs/DialogModelsDownloadManager.svelte b/tools/ui/src/lib/components/app/dialogs/DialogModelsDownloadManager.svelte deleted file mode 100644 index 986f50d69e..0000000000 --- a/tools/ui/src/lib/components/app/dialogs/DialogModelsDownloadManager.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - Download manager - - - - - diff --git a/tools/ui/src/lib/components/app/dialogs/index.ts b/tools/ui/src/lib/components/app/dialogs/index.ts index 895a13199e..e3963ba499 100644 --- a/tools/ui/src/lib/components/app/dialogs/index.ts +++ b/tools/ui/src/lib/components/app/dialogs/index.ts @@ -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'; diff --git a/tools/ui/src/lib/components/app/models/discover/DialogModelDownload.svelte b/tools/ui/src/lib/components/app/models/discover/DialogModelDownload.svelte deleted file mode 100644 index 902e5cdc7a..0000000000 --- a/tools/ui/src/lib/components/app/models/discover/DialogModelDownload.svelte +++ /dev/null @@ -1,308 +0,0 @@ - - - - - - - - - {#if phase === 'confirm'} - Download this model? - {:else} - Downloading {tagDisplay} - {/if} - - - - {#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} - - - {#if previousFailure && phase === 'confirm'} -
- - - - 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. - -
- {/if} -
- - {#if canDelete} -
- -
- {/if} - -
-
- Request - - POST /models · {`{ model: "${hfRepoWithTag}" }`} -
- -
- File - - {filePath} -
- -
- - {tagDisplay} - - - {#if formattedSize} - {formattedSize} - {/if} - - {#if sidecar && !isAuxSidecar(sidecar)} - - {sidecar} - - {/if} -
- - {#if phase === 'downloading' || phase === 'finished'} -
-
- - {#if phase === 'finished'} - Complete - {:else if progress && progress.totalBytes > 0} - Downloading - {:else} - Preparing download - {/if} - - - {progressPercent}% -
- - -
- {/if} -
- - {#if error} -

{error}

- {/if} - - - {#if phase === 'downloading'} - - {#if cancelling} - - Cancelling... - {:else} - Cancel download - {/if} - - {:else} - onClose?.()}> - {#if phase === 'finished'}Close{:else}Cancel{/if} - - {/if} - - {#if phase === 'confirm'} - - - {previousFailure ? 'Retry download' : 'Download'} - - {/if} - -
-
- - (showDeleteConfirm = false)} - onConfirm={handleDelete} - title="Delete model" - variant="destructive" -/> diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptions.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptions.svelte index 88dfe91481..db6955b598 100644 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptions.svelte +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptions.svelte @@ -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([]); - - /** 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 :` 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 = { - [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; - }); {#if bitDepthRows.length} -
- +
+ +
{#each rows as row (row.bitDepth)} {/each} - +
- -
- +
+ - - - - {#if selected.length > 0} - setPick('main', path)} - onDraftPick={(path) => setPick('draft', path)} - specType={draftEntry && draftSidecar ? SPEC_TYPE[draftSidecar] : null} - /> - {/if}
{/if} diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsDownloadButton.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsDownloadButton.svelte deleted file mode 100644 index 97d244c338..0000000000 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsDownloadButton.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand.svelte index c2dafcf520..86b67eeb5a 100644 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand.svelte +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsDownloadCommand.svelte @@ -1,57 +1,109 @@ + +
@@ -74,30 +134,20 @@ {modelId}{mainQuant ? ':' : ''} - - {#if baseOptions.length} - v && onBasePick(v)} type="single" value={basePick}> + + {#if baseOption} + v && (basePick = v)} type="single" value={baseOption.path}> - {baseSelectedLabel} + {baseOption.label} - {#each baseOptions as option (option.path)} - + {#each mainOptions as option (option.path)} + {option.label} {/each} @@ -105,38 +155,91 @@ {/if} - - {#if specType !== null} - -hfd + + {#if draftOptions.length && !withDraft} + + {/if} - v && onDraftPick(v)} type="single" value={draftPick}> - + {#if draftOption} + + -hfd + + {modelId}{draftQuant ? ':' : ''} + + v && (draftPick = v)} + type="single" + value={draftOption.path} > - {draftSelectedLabel} - + + {draftOption.label} + - - {#each draftOptions as option (option.path)} - + {#each typeDraftOptions as option (option.path)} + + {option.label} + + {/each} + + + + {#if draftType} + --spec-type + + + {#if specTypes.length > 1} + v && (draftTypePick = v as ModelSidecar)} + type="single" + value={draftType} > - {option.label} - - {/each} - - + + {SPEC_TYPE[draftType]} + - --spec-type + + {#each specTypes as type (type)} + + {SPEC_TYPE[type]} + + {/each} + + + {:else} + {SPEC_TYPE[draftType]} + {/if} + {/if} - {specType} + + {/if}
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton.svelte new file mode 100644 index 0000000000..f413f5d2d7 --- /dev/null +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsQuantDownloadButton.svelte @@ -0,0 +1,257 @@ + + +{#if entry.isDownloaded} + + + + + + + +

{tooltipText}

+
+
+ + (confirmDeleteOpen = false)} + onConfirm={() => { + confirmDeleteOpen = false; + + void modelsStore.status.cancelDownload(entry.repoWithTag); + }} + open={confirmDeleteOpen} + title="Delete model" + variant="destructive" + /> +{:else if entry.isDownloading || entry.isPaused} + + + + + + + + +

{tooltipText}

+
+
+ + + + + + + +

Cancel downloading

+
+
+
+{:else} + + + + + + + +

{tooltipText}

+
+
+{/if} diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsQuantToggle.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsQuantToggle.svelte deleted file mode 100644 index 32a321b2f3..0000000000 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsQuantToggle.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - - - - {#if state.isDownloaded} - -
- {#if meta?.sidecar && !isAuxSidecar(meta.sidecar)} - - {meta.sidecar} - - {/if} - - {label} - - - - {HuggingFaceService.formatFileSize(file.size ?? 0)} - - -
- {:else} - - {#if state.isFailed && !state.isDownloading} - - Failed - - {/if} - - {#if meta?.sidecar && !isAuxSidecar(meta.sidecar)} - - {meta.sidecar} - - {/if} - - {label} - - - - - {#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} - - - {#if state.isDownloading && state.progress} - - {/if} - - {/if} -
- - -

{tooltipText}

-
-
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsRow.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsRow.svelte index caf3932a12..dc9ad479f3 100644 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsRow.svelte +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptionsRow.svelte @@ -1,6 +1,6 @@ - -
- {#if modelsStore.status.downloadEntries().length} -
-

- - In progress -

- - {#each modelsStore.status.downloadEntries() as entry (entry.repoWithTag)} -
-
- {entry.repoWithTag} - - -
- - {#each Object.entries(entry.progress.files) as [file, fileProgress] (file)} -
-
- {file} - - - {fileProgress.total > 0 - ? Math.round((fileProgress.done / fileProgress.total) * 100) - : 0}% - -
- - -
- {/each} -
- {/each} -
- {/if} - -
-

- - Downloaded -

- - {#if modelsStore.status.downloadedEntries().length} - {#each modelsStore.status.downloadedEntries() as entry (entry.id)} -
- {entry.id} - -
- {#if isLoaded(entry.status)} - - Loaded - - {/if} - - -
-
- {/each} - {:else} -

No downloaded models yet.

- {/if} -
-
diff --git a/tools/ui/src/lib/components/app/models/download-manager/ModelsDownloadManagerDownloadStatusToast.svelte b/tools/ui/src/lib/components/app/models/download-manager/ModelsDownloadManagerDownloadStatusToast.svelte deleted file mode 100644 index 60b5f0ac1c..0000000000 --- a/tools/ui/src/lib/components/app/models/download-manager/ModelsDownloadManagerDownloadStatusToast.svelte +++ /dev/null @@ -1,71 +0,0 @@ - - -
-
- {repoWithTag} - - -
- - {#if progress} -
- {#each Object.entries(progress.files) as [file, fileProgress] (file)} -
-
- {file} - - - {percent(fileProgress.done, fileProgress.total)}% - -
- - -
- {/each} -
- {:else} -

Waiting for the server to report progress...

- {/if} -
diff --git a/tools/ui/src/lib/components/app/models/index.ts b/tools/ui/src/lib/components/app/models/index.ts index 2ff74ac6ef..672357c08e 100644 --- a/tools/ui/src/lib/components/app/models/index.ts +++ b/tools/ui/src/lib/components/app/models/index.ts @@ -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'; diff --git a/tools/ui/src/lib/stores/models/index.svelte.ts b/tools/ui/src/lib/stores/models/index.svelte.ts index b95014168a..235266576e 100644 --- a/tools/ui/src/lib/stores/models/index.svelte.ts +++ b/tools/ui/src/lib/stores/models/index.svelte.ts @@ -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(); diff --git a/tools/ui/src/lib/stores/models/status.svelte.ts b/tools/ui/src/lib/stores/models/status.svelte.ts index a20293ad52..634f23d35d 100644 --- a/tools/ui/src/lib/stores/models/status.svelte.ts +++ b/tools/ui/src/lib/stores/models/status.svelte.ts @@ -81,6 +81,8 @@ export class ModelStatusManager { private failedDownloads = new SvelteSet(); private loadingStates = new SvelteMap(); private loadProgress = new SvelteMap(); + /** Paused downloads with their last reported progress, or null when none arrived before the pause. */ + private pausedDownloads = new SvelteMap(); // /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(); /** * 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 { 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 { 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 { + 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(); } /** diff --git a/tools/ui/tests/stories/ModelsDiscoverDialog.stories.svelte b/tools/ui/tests/stories/ModelsDiscoverDialog.stories.svelte index 6254387a2d..7d1ae17388 100644 --- a/tools/ui/tests/stories/ModelsDiscoverDialog.stories.svelte +++ b/tools/ui/tests/stories/ModelsDiscoverDialog.stories.svelte @@ -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 `:`. - const FAILED_TAG = 'ggml-org/gemma-4-12b-it-GGUF:Q4_K_M'; - - (modelsStore.status as unknown as { failedDownloads: Set }).failedDownloads = - new SvelteSet([FAILED_TAG]); @@ -55,34 +45,6 @@ - -
- {}} - open - quant="Q4_K_M" - repoId="ggml-org/gemma-4-12b-it-GGUF" - sidecar={ModelDraftSidecar.MTP} - /> -
-
- - -
- {}} - open - quant="Q4_K_M" - repoId="ggml-org/gemma-4-12b-it-GGUF" - sidecar={null} - /> -
-
-
diff --git a/tools/ui/tests/stories/ModelsDiscoverModelDetails.stories.svelte b/tools/ui/tests/stories/ModelsDiscoverModelDetails.stories.svelte index 09e8dc74fb..58bb21b7ee 100644 --- a/tools/ui/tests/stories/ModelsDiscoverModelDetails.stories.svelte +++ b/tools/ui/tests/stories/ModelsDiscoverModelDetails.stories.svelte @@ -70,6 +70,33 @@
+ + +
+ 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" + /> +
+
+