From c024c1c567369254c9d5e1ed276a4a3b5396c77c Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Tue, 1 Sep 2026 08:15:37 +0200 Subject: [PATCH] feat: Models Downloading UI/UX --- ...DiscoverModelDetailsDownloadOptions.svelte | 46 +++++-- .../ModelsDownloadManager.svelte | 128 ++++++++++++------ .../ui/src/lib/stores/models/status.svelte.ts | 9 ++ tools/ui/src/lib/utils/index.ts | 2 +- tools/ui/src/lib/utils/model-compatibility.ts | 41 +++++- 5 files changed, 167 insertions(+), 59 deletions(-) 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 edb5d83720..40e3c139e7 100644 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptions.svelte +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverModelDetailsDownloadOptions.svelte @@ -8,7 +8,7 @@ import { HuggingFaceService, ModelsService } from '$lib/services'; import { modelsStore } from '$lib/stores'; import type { HfModelSibling } from '$lib/types/huggingface'; - import { copyToClipboard, estimateModelMemoryBytes } from '$lib/utils'; + import { copyToClipboard, minMemoryTierGb } from '$lib/utils'; /** Download state of a single repo entry, injected by the integration layer. */ export interface DownloadEntryState { @@ -135,10 +135,11 @@ /** First selected main quant, drives the `-hf :` tag. */ let primaryQuant = $derived(selected.find((s) => !s.sidecar)?.quant ?? null); - /** First selected draft sidecar, drives the `--spec-type` flag. */ - let draft = $derived( - selected.find((s) => s.sidecar && !isAuxSidecar(s.sidecar))?.sidecar ?? null - ); + /** First selected draft sidecar entry. */ + let draftEntry = $derived(selected.find((s) => s.sidecar && !isAuxSidecar(s.sidecar)) ?? null); + + /** First selected draft sidecar type, drives the `--spec-type` flag. */ + let draft = $derived(draftEntry?.sidecar ?? null); // llama.cpp --spec-type value for each draft sidecar. const SPEC_TYPE: Record = { @@ -177,15 +178,19 @@ const quantTag = primaryQuant ? `${modelId}:${primaryQuant}` : modelId; const parts = ['llama', 'serve', '-hf', quantTag]; - if (draft) parts.push('-hfd', modelId, '--spec-type', SPEC_TYPE[draft]); + if (draft) { + const draftTag = draftEntry?.quant ? `${modelId}:${draftEntry.quant}` : modelId; + + parts.push('-hfd', draftTag, '--spec-type', SPEC_TYPE[draft]); + } return parts.join(' '); }); {#if bitDepthRows.length} -
-
+
+

Downloadable options @@ -197,19 +202,35 @@

{#each bitDepthRows as row (row.bitDepth)} -
+ {@const mainFile = row.files.find( + (f) => !HuggingFaceService.extractQuantMeta(f.path)?.sidecar + )} + {@const draftFile = row.files.find((f) => { + const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar; + + return sidecar && !isAuxSidecar(sidecar); + })} + {@const mainMemGb = mainFile ? minMemoryTierGb(mainFile.size ?? 0) : null} + {@const draftMemGb = draftFile ? minMemoryTierGb(draftFile.size ?? 0) : null} +
{#if row.bitDepth === 99} Other {:else} {row.bitDepth}-bit {/if} + + {#if mainMemGb} + + needs at least {mainMemGb}GB{draftMemGb ? ` + ${draftMemGb}GB` : ''}+ memory + + {/if}
@@ -227,14 +248,13 @@ {@const progress = state.progress} {@const isDownloaded = state.isDownloaded} {@const isFailed = state.isFailed} - {@const memoryGb = Math.ceil(estimateModelMemoryBytes(file.size ?? 0) / 1024 ** 3)} {@const tooltipText = isDownloading ? `Downloading ${file.path}` : isDownloaded ? `Already downloaded: ${file.path}` : isFailed ? `Last attempt failed: ${file.path}` - : `Download ${file.path} (requires ~${memoryGb} GB of memory)`} + : `Download ${file.path}`} {#if isDownloaded} @@ -317,7 +337,7 @@ -
+
- import { Trash2 } from '@lucide/svelte'; + import { Download, HardDriveDownload, Trash2 } from '@lucide/svelte'; import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte'; import { modelsStore } from '$lib/stores'; + import { ServerModelStatus } from '$lib/enums'; - interface Props { - open?: boolean; + function isLoaded(status: ServerModelStatus | null): boolean { + return status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING; } - - let { open = false }: Props = $props(); -{#if open} -
- {#each modelsStore.status.downloadEntries() as entry (entry.repoWithTag)} -
-
- {entry.repoWithTag} +
+ {#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} + + {#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/stores/models/status.svelte.ts b/tools/ui/src/lib/stores/models/status.svelte.ts index fe78f761d5..0f27ec00d7 100644 --- a/tools/ui/src/lib/stores/models/status.svelte.ts +++ b/tools/ui/src/lib/stores/models/status.svelte.ts @@ -124,6 +124,15 @@ 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. + */ + 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. */ diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts index 179da68786..d89a23e20c 100644 --- a/tools/ui/src/lib/utils/index.ts +++ b/tools/ui/src/lib/utils/index.ts @@ -345,7 +345,7 @@ export { detectOs, executeBrowserInfoTool } from './browser-info'; export { detectToolUseSupport } from './chat-template-tool-detector'; // Model memory estimation -export { estimateModelMemoryBytes } from './model-compatibility'; +export { estimateModelMemoryBytes, minMemoryTierGb } from './model-compatibility'; // Cryptography utilities diff --git a/tools/ui/src/lib/utils/model-compatibility.ts b/tools/ui/src/lib/utils/model-compatibility.ts index eb0928c30e..164be26f23 100644 --- a/tools/ui/src/lib/utils/model-compatibility.ts +++ b/tools/ui/src/lib/utils/model-compatibility.ts @@ -1,20 +1,53 @@ /** * Model memory estimation. * - * Runtime memory is approximated from the file size: the quantized weights - * plus KV cache/workspace overhead, rounded up to a GB. Context length and + * Mirrors the app's compatibility check (Model+Compatibility.swift): the + * runtime budget is RAM x 0.75 minus a fixed overhead, and a file fits when + * its size with headroom stays under that budget. The result is the smallest + * real Mac memory tier that can run the model, so the UI presents an honest + * machine requirement instead of a raw file size. Context length and * device-specific budgets are deliberately ignored - callers present the * requirement and let the user judge. */ // LLAMA-APP-REUSE: hardware compatibility estimation +const MIB_BYTES = 1_048_576; +const MB_PER_GB = 1024; /** Overhead multiplier applied to the file size when estimating weight memory. */ -const WEIGHT_OVERHEAD_MULTIPLIER = 1.05; +const QUANT_WEIGHT = 1.05; +/** Share of RAM the app allows the model to occupy. */ +const RAM_BUDGET_RATIO = 0.75; +/** Fixed RAM overhead (MB) reserved for the system and KV cache. */ +const RAM_OVERHEAD_MB = 2048; +/** + * Memory tiers Macs ship with (GB). Tiers past 512 extrapolate Apple's step + * pattern so builds too big for any current Mac still show an honest + * requirement instead of silently omitting the line. + */ +const MAC_MEM_TIERS = [8, 16, 24, 32, 48, 64, 96, 128, 192, 256, 512, 768, 1024]; /** * Estimated runtime memory (bytes) for a model of the given file size: * file size with headroom for KV cache and allocator overhead. */ export function estimateModelMemoryBytes(sizeBytes: number): number { - return Math.round(sizeBytes * WEIGHT_OVERHEAD_MULTIPLIER); + return Math.round(sizeBytes * QUANT_WEIGHT); +} + +/** + * Smallest Mac memory tier (GB) that can run a model of the given file size, + * or null if nothing fits even the largest tier. + */ +export function minMemoryTierGb(sizeBytes: number): number | null { + if (!sizeBytes) return null; + + const weightMb = (sizeBytes / MIB_BYTES) * QUANT_WEIGHT; + + for (const tier of MAC_MEM_TIERS) { + const budgetMb = tier * MB_PER_GB * RAM_BUDGET_RATIO - RAM_OVERHEAD_MB; + + if (weightMb <= budgetMb) return tier; + } + + return null; }