mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-07 20:47:30 +02:00
ui : add download manager pieces and the list search component
- ModelsDiscoverListSearch: extracted search input from the list - ModelsDiscoverModelDetailsMetadata: description + metadata chips extracted from the details header - ModelsDiscoverModelDetailsCommands: quant + draft sidecar selectors embedded in the inline command text - ModelsDownloadManager: tracked downloads with per-file progress and a delete action - ModelsDownloadManagerDownloadStatusToast: one toast per download with a progress bar per file (main + sidecars) and a CTA to open the download manager - DialogModelsDownloadManager: dialog shell for the manager Assisted-by: pi
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import ModelsDownloadManager from '$lib/components/app/models/download-manager/ModelsDownloadManager.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
open = next;
|
||||
onOpenChange?.(next);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-200!"
|
||||
>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="text-sm font-semibold">Download manager</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<ModelsDownloadManager />
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -536,3 +536,13 @@ 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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import {
|
||||
ModelsDiscoverList,
|
||||
ModelsDiscoverListSearch,
|
||||
ModelsDiscoverModelDetails
|
||||
} from '$lib/components/app/models/discover';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
@@ -96,14 +96,7 @@
|
||||
<aside
|
||||
class="w-108 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
|
||||
>
|
||||
<div class="p-2 sticky top-0 z-99">
|
||||
<SearchInput
|
||||
bind:value={searchQuery}
|
||||
class=""
|
||||
onInput={handleSearchInput}
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
</div>
|
||||
<ModelsDiscoverListSearch bind:value={searchQuery} onSearch={handleSearchInput} />
|
||||
|
||||
<div>
|
||||
{#if modelsHubStore.loading}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
/** Debounced search callback (300ms). */
|
||||
onSearch?: (query: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { onSearch, placeholder = 'Search models...', value = $bindable('') }: Props = $props();
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function handleInput(next: string) {
|
||||
value = next;
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
|
||||
searchTimeout = setTimeout(() => onSearch?.(value), 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-99 p-2">
|
||||
<SearchInput bind:value onInput={(v) => handleInput(v)} {placeholder} />
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverModelDetailsCommands from './ModelsDiscoverModelDetailsCommands.svelte';
|
||||
import ModelsDiscoverDetailsDownloadOptions from './ModelsDiscoverModelDetailsDownloadOptions.svelte';
|
||||
import ModelsDiscoverModelDetailsDownloadOptions from './ModelsDiscoverModelDetailsDownloadOptions.svelte';
|
||||
import ModelsDiscoverDetailsHeader from './ModelsDiscoverModelDetailsHeader.svelte';
|
||||
import ModelsDiscoverDetailsReadme from './ModelsDiscoverModelDetailsReadme.svelte';
|
||||
import { isAuxSidecar, type ModelSidecar } from '$lib/constants';
|
||||
@@ -62,6 +62,16 @@
|
||||
});
|
||||
|
||||
type BitDepthRow = { bitDepth: number; files: HfModelSibling[] };
|
||||
let quants = $derived(
|
||||
Array.from(
|
||||
new Set(
|
||||
files
|
||||
.map((f) => HuggingFaceService.extractQuantMeta(f.path)?.quant)
|
||||
.filter((q): q is string => Boolean(q))
|
||||
.map((q) => q.toUpperCase())
|
||||
)
|
||||
)
|
||||
);
|
||||
let bitDepthRows = $derived.by<BitDepthRow[]>(() => {
|
||||
const rows = new SvelteMap<number, HfModelSibling[]>();
|
||||
|
||||
@@ -106,9 +116,9 @@
|
||||
{modelId}
|
||||
/>
|
||||
|
||||
<ModelsDiscoverDetailsDownloadOptions {bitDepthRows} {modelId} />
|
||||
<ModelsDiscoverModelDetailsDownloadOptions {bitDepthRows} {modelId} />
|
||||
|
||||
<ModelsDiscoverModelDetailsCommands {modelId} sidecars={draftSidecars} />
|
||||
<ModelsDiscoverModelDetailsCommands {modelId} {quants} sidecars={draftSidecars} />
|
||||
|
||||
<ModelsDiscoverDetailsReadme {readme} />
|
||||
</div>
|
||||
|
||||
+64
-32
@@ -5,14 +5,19 @@
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
/** Full HuggingFace model id, The draft sidecar sits in the same repo. */
|
||||
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
|
||||
modelId: string;
|
||||
/** Draft sidecar variants present in the repo (mtp, dflash, dspark, eagle3). */
|
||||
/** Available quantization tags, e.g. `Q4_K_M`, `Q8_0`. */
|
||||
quants: string[];
|
||||
/** Draft sidecars present in the repo (mtp, dflash, dspark, eagle3). */
|
||||
sidecars?: ModelSidecar[];
|
||||
}
|
||||
|
||||
let { modelId, sidecars = [] }: Props = $props();
|
||||
let { modelId, quants, sidecars = [] }: Props = $props();
|
||||
|
||||
let pickedQuant = $state<string | null>(null);
|
||||
let selectedQuant = $derived(pickedQuant ?? quants[0] ?? null);
|
||||
let selectedSidecar = $state<ModelSidecar | null>(null);
|
||||
|
||||
// llama.cpp --spec-type value for each draft sidecar.
|
||||
const SPEC_TYPE: Record<ModelSidecar, string> = {
|
||||
@@ -31,27 +36,60 @@
|
||||
setTimeout(() => (copiedIndex = null), 1500);
|
||||
}
|
||||
|
||||
// One box per binary (serve / cli). Each box lists one command per available
|
||||
// draft sidecar, or just the base command when none is present.
|
||||
// One box per binary (serve / cli). The command embeds inline selectors for
|
||||
// the quant and the draft sidecar type.
|
||||
let boxes = $derived.by(() => {
|
||||
const variants = sidecars.filter((v) => !isAuxSidecar(v));
|
||||
const quant = selectedQuant ?? quants[0];
|
||||
|
||||
if (!quant) return [];
|
||||
|
||||
const draft = selectedSidecar && !isAuxSidecar(selectedSidecar) ? selectedSidecar : null;
|
||||
const specType = draft ? `--spec-type ${SPEC_TYPE[draft]}` : '';
|
||||
const build = (bin: string) => {
|
||||
const base = `llama ${bin} -hf ${modelId}`;
|
||||
const parts = ['llama', bin, '-hfd', modelId];
|
||||
|
||||
if (variants.length === 0) return [base];
|
||||
if (specType) parts.push(specType);
|
||||
|
||||
return variants.map((v) => `${base} -hfd ${modelId} --spec-type ${SPEC_TYPE[v]}`);
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
return [
|
||||
{ commands: build('serve'), icon: Server, title: 'Serve' },
|
||||
{ commands: build('cli'), icon: SquareTerminal, title: 'CLI' }
|
||||
{ command: build('serve'), icon: Server, title: 'Serve' },
|
||||
{ command: build('cli'), icon: SquareTerminal, title: 'CLI' }
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each boxes as box (box.title)}
|
||||
<div class="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Quant</span>
|
||||
|
||||
<select
|
||||
bind:value={selectedQuant}
|
||||
class="rounded border bg-background px-1.5 py-0.5 font-mono text-xs"
|
||||
>
|
||||
{#each quants as quant (quant)}
|
||||
<option value={quant}>{quant}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if sidecars.length}
|
||||
<span>Draft</span>
|
||||
|
||||
<select
|
||||
bind:value={selectedSidecar}
|
||||
class="rounded border bg-background px-1.5 py-0.5 font-mono text-xs"
|
||||
>
|
||||
<option value={null}>none</option>
|
||||
|
||||
{#each sidecars as sidecar (sidecar)}
|
||||
<option value={sidecar}>{sidecar}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each boxes as box, i (box.title)}
|
||||
<div
|
||||
class="overflow-hidden rounded-md"
|
||||
style="background: var(--code-background); border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);"
|
||||
@@ -65,27 +103,21 @@
|
||||
<span class="text-xs font-medium text-foreground/80">{box.title}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 p-2">
|
||||
{#each box.commands as cmd, i (cmd)}
|
||||
<div
|
||||
class="group flex items-center justify-between gap-2 rounded px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
<span class="truncate text-foreground/90">{cmd}</span>
|
||||
<div class="flex items-center justify-between gap-2 p-2">
|
||||
<span class="truncate font-mono text-xs text-foreground/90">{box.command}</span>
|
||||
|
||||
<button
|
||||
aria-label="Copy command"
|
||||
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
onclick={() => handleCopy(i, cmd)}
|
||||
type="button"
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Check class="h-3.5 w-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
aria-label="Copy command"
|
||||
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
onclick={() => handleCopy(i, box.command)}
|
||||
type="button"
|
||||
>
|
||||
{#if copiedIndex === i}
|
||||
<Check class="h-3.5 w-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
+3
-85
@@ -1,21 +1,11 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
|
||||
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Image,
|
||||
Lightbulb,
|
||||
MessageSquareCode,
|
||||
Wrench
|
||||
} from '@lucide/svelte';
|
||||
import ModelsDiscoverModelDetailsMetadata from './ModelsDiscoverModelDetailsMetadata.svelte';
|
||||
import { Download, ExternalLink, Heart, Image, Lightbulb, Wrench } from '@lucide/svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ICON_CLASS_SM } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsHubStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
|
||||
import { formatParameters } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
@@ -37,13 +27,6 @@
|
||||
let baseOrg = $derived(baseModels[0]?.split('/')[0]);
|
||||
let avatarOrg = $derived(baseOrg || repoOrg);
|
||||
let quantOrg = $derived(baseOrg && baseOrg !== repoOrg ? repoOrg : undefined);
|
||||
|
||||
// Catalog family description when curated, else the HF card description.
|
||||
let description = $derived(
|
||||
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
|
||||
);
|
||||
|
||||
let chatTemplateOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<header class="space-y-3">
|
||||
@@ -148,70 +131,5 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<p class="text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if gguf?.total}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Model size</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{formatParameters(gguf.total)} params</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.context_length}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Context</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{gguf.context_length.toLocaleString()}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.architecture}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Architecture</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{gguf.architecture}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.chat_template}
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||
onclick={() => (chatTemplateOpen = true)}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareCode class="h-3 w-3" />
|
||||
Chat template
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if licenseTag}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">License</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{licenseTag}</span>
|
||||
</span>
|
||||
|
||||
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground"> </span>
|
||||
{/if}
|
||||
|
||||
{#if details.gated === true}
|
||||
<span
|
||||
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
gated
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<ModelsDiscoverModelDetailsMetadata {details} {gguf} {licenseTag} {modelId} />
|
||||
</header>
|
||||
|
||||
{#if gguf?.chat_template}
|
||||
<ModelsDiscoverChatTemplateDialog
|
||||
bind:open={chatTemplateOpen}
|
||||
chatTemplate={gguf.chat_template}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
|
||||
import { MessageSquareCode } from '@lucide/svelte';
|
||||
import { modelsHubStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
|
||||
import { formatParameters } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
|
||||
modelId: string;
|
||||
details: HfModelDetailInfo;
|
||||
gguf?: HfModelGguf;
|
||||
licenseTag: string | null;
|
||||
}
|
||||
|
||||
let { details, gguf, licenseTag, modelId }: Props = $props();
|
||||
|
||||
// Catalog family description when curated, else the HF card description.
|
||||
let description = $derived(
|
||||
modelsHubStore.descriptionFor(modelId) ?? details.cardData?.description
|
||||
);
|
||||
|
||||
let chatTemplateOpen = $state(false);
|
||||
</script>
|
||||
|
||||
{#if description}
|
||||
<p class="text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if gguf?.total}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Model size</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{formatParameters(gguf.total)} params</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.context_length}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Context</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{gguf.context_length.toLocaleString()}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.architecture}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">Architecture</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{gguf.architecture}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.chat_template}
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||
onclick={() => (chatTemplateOpen = true)}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareCode class="h-3 w-3" />
|
||||
Chat template
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if licenseTag}
|
||||
<span class="inline-flex items-center divide-x divide-border rounded-md border text-xs">
|
||||
<span class="px-2.5 py-1 text-muted-foreground">License</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{licenseTag}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if details.gated === true}
|
||||
<span
|
||||
class="rounded bg-yellow-500/10 px-2 py-0.5 text-xs font-medium text-yellow-600 dark:text-yellow-400"
|
||||
>
|
||||
gated
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if gguf?.chat_template}
|
||||
<ModelsDiscoverChatTemplateDialog
|
||||
bind:open={chatTemplateOpen}
|
||||
chatTemplate={gguf.chat_template}
|
||||
/>
|
||||
{/if}
|
||||
@@ -24,6 +24,13 @@ export { default as ModelsDiscover } from './ModelsDiscover.svelte';
|
||||
*/
|
||||
export { default as ModelsDiscoverList } from './ModelsDiscoverList.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverListSearch** - Sidebar search input
|
||||
*
|
||||
* Debounced search field for the model list.
|
||||
*/
|
||||
export { default as ModelsDiscoverListSearch } from './ModelsDiscoverListSearch.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverItem** - Single sidebar row
|
||||
*
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { Trash2 } from '@lucide/svelte';
|
||||
import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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 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}
|
||||
</div>
|
||||
{/if}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { X } from '@lucide/svelte';
|
||||
import DownloadProgressBar from '$lib/components/app/models/discover/DownloadProgressBar.svelte';
|
||||
import type { ModelDownloadProgress } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** HuggingFace repo id of the download. */
|
||||
repoId: string;
|
||||
/** Live progress from the /models/sse feed (per-file). */
|
||||
progress: ModelDownloadProgress;
|
||||
/** CTA fired to open the download manager dialog. */
|
||||
onOpenManager?: () => void;
|
||||
/** Dismiss the toast (does not cancel the download). */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
let { onDismiss, onOpenManager, progress, repoId }: Props = $props();
|
||||
|
||||
let files = $derived(Object.entries(progress.files));
|
||||
|
||||
function percent(done: number, total: number): number {
|
||||
return total > 0 ? Math.round((done / total) * 100) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-80 space-y-2 rounded-md border bg-background p-3 shadow-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-xs font-medium" title={repoId}>{repoId}</span>
|
||||
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
class="shrink-0 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
onclick={() => onDismiss?.()}
|
||||
type="button"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
{#each files as [file, fileProgress] (file)}
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center justify-between gap-2 text-muted-foreground">
|
||||
<span class="truncate font-mono text-xs">{file}</span>
|
||||
|
||||
<span class="shrink-0 font-mono tabular-nums">
|
||||
{percent(fileProgress.done, fileProgress.total)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DownloadProgressBar downloadedBytes={fileProgress.done} totalBytes={fileProgress.total} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if onOpenManager}
|
||||
<button
|
||||
class="w-full rounded-md border px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||
onclick={() => onOpenManager?.()}
|
||||
type="button"
|
||||
>
|
||||
Open download manager
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -109,3 +109,18 @@ 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';
|
||||
|
||||
@@ -124,6 +124,16 @@ export class ModelStatusManager {
|
||||
|
||||
constructor(private host: ModelStatusHost) {}
|
||||
|
||||
/**
|
||||
* All tracked downloads (in flight), as a list for the download manager.
|
||||
*/
|
||||
downloadEntries(): { progress: ModelDownloadProgress; repoWithTag: string }[] {
|
||||
return Array.from(this.downloadProgress, ([repoWithTag, progress]) => ({
|
||||
progress,
|
||||
repoWithTag
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a model download from HuggingFace via POST /models
|
||||
* (ggml-org/llama.cpp#23976). The download runs in the background on the
|
||||
@@ -332,7 +342,11 @@ export class ModelStatusManager {
|
||||
total += file?.total ?? 0;
|
||||
}
|
||||
|
||||
this.downloadProgress.set(event.model, { downloadedBytes: downloaded, totalBytes: total });
|
||||
this.downloadProgress.set(event.model, {
|
||||
downloadedBytes: downloaded,
|
||||
files: progress,
|
||||
totalBytes: total
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,6 +109,7 @@ export type {
|
||||
ModelCapabilities,
|
||||
ModelModalities,
|
||||
ModelOption,
|
||||
ModelDownloadFileProgress,
|
||||
ModelDownloadProgress,
|
||||
ModelLoadProgress,
|
||||
ModalityCapabilities
|
||||
|
||||
Vendored
+9
-8
@@ -40,19 +40,20 @@ export interface ModelLoadProgress {
|
||||
* Per-byte download progress for one in-flight model download, driven by the
|
||||
* /models/sse feed. Lives only while a download runs.
|
||||
*/
|
||||
export interface ModelDownloadProgress {
|
||||
downloadedBytes: number;
|
||||
totalBytes: number;
|
||||
export interface ModelDownloadFileProgress {
|
||||
/** Bytes downloaded for this file so far. */
|
||||
done: number;
|
||||
/** Total bytes of the file. */
|
||||
total: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Per-byte download progress for one in-flight model download, driven by the
|
||||
* /models/sse feed. Lives only while a download runs.
|
||||
*/
|
||||
export interface ModelDownloadProgress {
|
||||
/** Summed progress across all files of the download plan. */
|
||||
downloadedBytes: number;
|
||||
/** Summed plan size across all files. */
|
||||
totalBytes: number;
|
||||
/** Per-file progress keyed by file URL, as reported by the feed. */
|
||||
files: Record<string, ModelDownloadFileProgress>;
|
||||
}
|
||||
|
||||
// LLAMA-APP-REUSE: parsed model id shape
|
||||
|
||||
@@ -74,7 +74,10 @@
|
||||
|
||||
<Story name="Terminal commands (no sidecars)">
|
||||
<div class="w-200 p-4">
|
||||
<ModelsDiscoverModelDetailsCommands modelId="ggml-org/Qwen3.8-27B-GGUF" />
|
||||
<ModelsDiscoverModelDetailsCommands
|
||||
modelId="ggml-org/Qwen3.8-27B-GGUF"
|
||||
quants={['Q4_K_M', 'Q8_0']}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
@@ -82,6 +85,7 @@
|
||||
<div class="w-200 p-4">
|
||||
<ModelsDiscoverModelDetailsCommands
|
||||
modelId="ggml-org/gemma-4-12b-it-GGUF"
|
||||
quants={['Q4_K_M', 'Q8_0']}
|
||||
sidecars={[ModelDraftSidecar.MTP]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user