mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +02:00
feat: Models Downloading UI/UX
This commit is contained in:
+33
-13
@@ -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);"
|
||||
|
||||
+87
-41
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user