feat: Models Downloading UI/UX

This commit is contained in:
Aleksander Grygier
2026-09-04 20:07:36 +02:00
parent bd1f21efb3
commit c024c1c567
5 changed files with 167 additions and 59 deletions
@@ -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 <repo>:<quant>` 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<ModelSidecar, string> = {
@@ -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(' ');
});
</script>
{#if bitDepthRows.length}
<section class="space-y-3 rounded-xl border p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<section class="rounded-xl border">
<div class="flex flex-wrap items-center justify-between gap-2 px-4 pt-3 pb-1">
<h2 class="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Download class="h-4 w-4" />
Downloadable options
@@ -197,19 +202,35 @@
</div>
<ToggleGroup
class="flex flex-col"
class="flex w-full flex-col items-stretch divide-y px-4 pb-1"
onValueChange={handleSelection}
type="multiple"
value={selectedPaths}
>
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-2">
{@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}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-3">
<div class="pt-1 text-sm tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
{:else}
{row.bitDepth}-bit
{/if}
{#if mainMemGb}
<span class="block text-[10px] whitespace-nowrap text-muted-foreground/60">
needs at least {mainMemGb}GB{draftMemGb ? ` + ${draftMemGb}GB` : ''}+ memory
</span>
{/if}
</div>
<div class="flex flex-wrap justify-end gap-1.5">
@@ -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}`}
<Tooltip.Root>
<Tooltip.Trigger>
{#if isDownloaded}
@@ -317,7 +337,7 @@
</ToggleGroup>
<!-- Terminal command + download CTA for the current selection -->
<div class="space-y-2">
<div class="space-y-2 border-t px-4 pt-3 pb-4">
<div
class="flex items-center justify-between gap-2 rounded-md px-3 py-2"
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
@@ -1,51 +1,97 @@
<script lang="ts">
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();
</script>
{#if open}
<div class="space-y-2">
{#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>
<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>
<button
aria-label="Delete model"
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 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>
{#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}
/>
<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}
</div>
{/each}
</div>
{/if}
{#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>
@@ -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.
*/
+1 -1
View File
@@ -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
+37 -4
View File
@@ -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;
}