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" + /> +
+
+