mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-30 18:07:38 -05:00
ui : add the discover models components
The Discover surface: the curated-catalog list with its search and skeletons, the details pane with its readme, metadata, Hub stats and download options, and the standalone download progress bar that replaces the plain one. The quant download button sizes its select with a new xs trigger, so the select gains that size and the muted box look. The store stays incomplete when a repo fails, so the next mount retries it. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
downloadedBytes: number;
|
||||
totalBytes: number;
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
|
||||
|
||||
let fraction = $derived.by(() => {
|
||||
if (totalBytes <= 0) return 0;
|
||||
|
||||
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
|
||||
});
|
||||
let percent = $derived(Math.round(fraction * 100));
|
||||
</script>
|
||||
|
||||
{#if overlay}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
|
||||
<div
|
||||
class="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ModelsDiscoverDetails,
|
||||
ModelsDiscoverList,
|
||||
ModelsDiscoverListSearch
|
||||
} from '$lib/components/app/models/discover';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsDiscoverStore } from '$lib/stores';
|
||||
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types';
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Detail pane state, reloaded when the selection changes.
|
||||
let details = $state<HfModelDetailInfo | null>(null);
|
||||
let files = $state<HfModelSibling[]>([]);
|
||||
let readme = $state<string | null>(null);
|
||||
let detailLoading = $state(false);
|
||||
let detailError = $state<string | null>(null);
|
||||
|
||||
// Load the sidebar list on mount (the component is mounted when the dialog opens).
|
||||
$effect(() => {
|
||||
void modelsDiscoverStore.fetch();
|
||||
void modelsDiscoverStore.search('');
|
||||
});
|
||||
|
||||
// Auto-select the first model.
|
||||
$effect(() => {
|
||||
const first = modelsDiscoverStore.firstModel;
|
||||
|
||||
if (!selectedId && first) {
|
||||
selectedId = first.id;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSearchInput(value: string) {
|
||||
searchQuery = value;
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
void modelsDiscoverStore.search(value);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Load the detail pane for the selected model (component is reused across
|
||||
// selections, so this re-fetches on every change).
|
||||
$effect(() => {
|
||||
const id = selectedId;
|
||||
|
||||
if (!id) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
detailLoading = true;
|
||||
detailError = null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [info, tree, readmeText] = await Promise.all([
|
||||
HuggingFaceService.getDetails(id),
|
||||
HuggingFaceService.getTree(id),
|
||||
HuggingFaceService.getReadme(id)
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (!info) {
|
||||
detailError = 'Model not found';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
details = info;
|
||||
files = HuggingFaceService.filterByExtension(
|
||||
HuggingFaceService.collapseGgufShards(tree),
|
||||
'.gguf'
|
||||
);
|
||||
readme = readmeText;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
detailError = err instanceof Error ? err.message : 'Failed to load model';
|
||||
} finally {
|
||||
if (!cancelled) detailLoading = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="w-md shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:pr-4 h-full space-y-4"
|
||||
>
|
||||
<ModelsDiscoverListSearch bind:value={searchQuery} onSearch={handleSearchInput} />
|
||||
|
||||
<!-- One list instance, so the rows keep their state across search round trips;
|
||||
skeleton rows replace them while the initial catalog or a query loads. -->
|
||||
<div>
|
||||
{#if modelsDiscoverStore.error}
|
||||
<div class="flex flex-col items-start gap-2 p-4">
|
||||
<p class="text-sm text-destructive">{modelsDiscoverStore.error}</p>
|
||||
|
||||
<Button onclick={() => void modelsDiscoverStore.fetch()} size="sm" variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
{:else if !modelsDiscoverStore.loading && !modelsDiscoverStore.searching && modelsDiscoverStore.models.length === 0}
|
||||
<p class="p-4 text-sm text-muted-foreground">No models found</p>
|
||||
{:else}
|
||||
<ModelsDiscoverList
|
||||
activeId={selectedId}
|
||||
loading={modelsDiscoverStore.loading || modelsDiscoverStore.searching}
|
||||
models={modelsDiscoverStore.models}
|
||||
onSelect={(id) => (selectedId = id)}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="overflow-y-auto">
|
||||
{#if selectedId}
|
||||
<ModelsDiscoverDetails
|
||||
{details}
|
||||
error={detailError}
|
||||
{files}
|
||||
loading={detailLoading}
|
||||
modelId={selectedId}
|
||||
{readme}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { Check, Copy, X } from '@lucide/svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
chatTemplate: string;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
let { chatTemplate, onOpenChange, open = $bindable(false) }: Props = $props();
|
||||
|
||||
function handleOpenChange(value: boolean) {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
}
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
async function copy() {
|
||||
await copyToClipboard(chatTemplate);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root onOpenChange={handleOpenChange} {open}>
|
||||
<Dialog.Content
|
||||
class="flex max-h-[calc(100vh-4rem)] flex-col gap-0 p-0 md:w-[calc(100vw-4rem)]! md:max-w-4xl!"
|
||||
>
|
||||
<!-- The header's corner-pinned close X never lines up with a padded flex row,
|
||||
so it is replaced by one inside the row, aligned with the title -->
|
||||
<Dialog.Header
|
||||
class="flex-row items-center gap-2 border-b border-border/40 p-4"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<Dialog.Title class="text-sm font-semibold">Chat template</Dialog.Title>
|
||||
|
||||
<button
|
||||
aria-label="Copy chat template"
|
||||
class="inline-flex cursor-pointer items-center gap-1.5 rounded-md border border-border/30 px-2 py-1 text-xs font-medium shadow-sm transition-colors hover:bg-muted dark:border-border/20"
|
||||
onclick={() => void copy()}
|
||||
type="button"
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="h-3.5 w-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
|
||||
Copy
|
||||
</button>
|
||||
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
class="ml-auto inline-flex cursor-pointer items-center justify-center rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-muted-foreground/10 hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Dialog.Close>
|
||||
</Dialog.Header>
|
||||
|
||||
<pre
|
||||
class="flex-1 overflow-auto p-4 font-mono text-xs break-all whitespace-pre-wrap text-muted-foreground">{chatTemplate}</pre>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions';
|
||||
import ModelsDiscoverDetailsHeader from './ModelsDiscoverDetailsHeader.svelte';
|
||||
import ModelsDiscoverDetailsReadme from './ModelsDiscoverDetailsReadme.svelte';
|
||||
import ModelsDiscoverDetailsSkeleton from './ModelsDiscoverDetailsSkeleton.svelte';
|
||||
import { OTHER_BIT_DEPTH } from '$lib/constants';
|
||||
import { ModelAuxSidecar } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelDetailInfo, HfModelSibling, ModelBitDepthRow } from '$lib/types';
|
||||
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
/** Full HuggingFace model id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
|
||||
modelId: string;
|
||||
/** Model details from `/api/models/{id}?full=true`; null while loading. */
|
||||
details: HfModelDetailInfo | null;
|
||||
/** GGUF files of the repo, shards collapsed, sorted by size desc. */
|
||||
files: HfModelSibling[];
|
||||
/** README.md content, frontmatter stripped; null when unavailable. */
|
||||
readme: string | null;
|
||||
/** True while the model data is being fetched. */
|
||||
loading?: boolean;
|
||||
/** Error message when loading failed. */
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
let { details, error = null, files, loading = false, modelId, readme }: Props = $props();
|
||||
|
||||
let gguf = $derived(details?.gguf);
|
||||
let baseModels = $derived(HuggingFaceService.getBaseModels(details));
|
||||
let licenseTag = $derived.by(() => {
|
||||
const tags = details?.tags ?? [];
|
||||
|
||||
return tags.find((t) => t.startsWith('license:'))?.replace('license:', '') ?? null;
|
||||
});
|
||||
|
||||
// Capabilities derived from HF metadata. Vision comes from an mmproj sidecar
|
||||
// or a multimodal pipeline tag; tool use / reasoning from the chat template.
|
||||
let hasMmproj = $derived(
|
||||
files.some(
|
||||
(f) => HuggingFaceService.extractQuantMeta(f.path)?.sidecar === ModelAuxSidecar.MMPROJ
|
||||
)
|
||||
);
|
||||
let hasVision = $derived(hasMmproj || details?.pipeline_tag === 'image-text-to-text');
|
||||
let hasTools = $derived(detectToolUseSupport(gguf?.chat_template ?? ''));
|
||||
let hasReasoning = $derived(detectThinkingSupport(gguf?.chat_template ?? ''));
|
||||
|
||||
let bitDepthRows = $derived.by<ModelBitDepthRow[]>(() => {
|
||||
const rows = new SvelteMap<number, HfModelSibling[]>();
|
||||
|
||||
for (const file of files) {
|
||||
const meta = HuggingFaceService.extractQuantMeta(file.path);
|
||||
|
||||
// mmproj sidecars are already conveyed by the Vision capability badge;
|
||||
// imatrix ships as a normal chip with its own badge.
|
||||
if (meta?.sidecar === ModelAuxSidecar.MMPROJ) continue;
|
||||
|
||||
const depth = meta?.quant ? HuggingFaceService.getBitDepth(meta.quant) : null;
|
||||
const bucket = depth ?? OTHER_BIT_DEPTH;
|
||||
const list = rows.get(bucket) ?? [];
|
||||
|
||||
list.push(file);
|
||||
rows.set(bucket, list);
|
||||
}
|
||||
|
||||
return Array.from(rows.entries())
|
||||
.map(([bitDepth, rowFiles]) => ({ bitDepth, files: rowFiles }))
|
||||
.sort((a, b) => a.bitDepth - b.bitDepth);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<ModelsDiscoverDetailsSkeleton />
|
||||
{:else if error}
|
||||
<div class="flex h-full items-center justify-center py-20">
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
{:else if details}
|
||||
<div class="space-y-6 p-6 pr-0">
|
||||
<ModelsDiscoverDetailsHeader
|
||||
{baseModels}
|
||||
{details}
|
||||
{gguf}
|
||||
{hasReasoning}
|
||||
{hasTools}
|
||||
{hasVision}
|
||||
{licenseTag}
|
||||
{modelId}
|
||||
/>
|
||||
|
||||
<ModelsDiscoverDetailsDownloadOptions {bitDepthRows} {modelId} />
|
||||
|
||||
<ModelsDiscoverDetailsReadme {readme} />
|
||||
</div>
|
||||
{/if}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { classify, labelFor } from './download-options.utils';
|
||||
import ModelsDiscoverDetailsDownloadOptionsDownloadCommand from './ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte';
|
||||
import ModelsDiscoverDetailsDownloadOptionsRow from './ModelsDiscoverDetailsDownloadOptionsRow.svelte';
|
||||
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
||||
import { ModelDownloadConfirmAction, ModelSelectableFileKind } from '$lib/enums';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type {
|
||||
ModelBitDepthRow,
|
||||
ModelDownloadEntryState,
|
||||
ModelQuantOption,
|
||||
ModelSelectableFile
|
||||
} from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** Full HuggingFace repo id, e.g. `ggml-org/gemma-3-4b-it-GGUF`. */
|
||||
modelId: string;
|
||||
/** GGUF files grouped by bit depth. */
|
||||
bitDepthRows: ModelBitDepthRow[];
|
||||
/** Download state lookup; defaults to the models store status feed. */
|
||||
getDownloadState?: (
|
||||
repoWithTag: string,
|
||||
filePath: string,
|
||||
isSidecar: boolean
|
||||
) => ModelDownloadEntryState;
|
||||
}
|
||||
|
||||
let { bitDepthRows, getDownloadState, modelId }: Props = $props();
|
||||
|
||||
// Destructive chip actions (delete a downloaded model, cancel a download) are
|
||||
// confirmed here rather than inside each chip: a single shared dialog owned by
|
||||
// the options panel, keyed by the repo+tag the user acted on, so one dialog is
|
||||
// mounted for the whole panel instead of one per chip.
|
||||
// The acted-on target is kept after closing so the copy stays rendered through
|
||||
// the dialog's close transition.
|
||||
let pending: { action: ModelDownloadConfirmAction; repoWithTag: string } = $state({
|
||||
action: ModelDownloadConfirmAction.CANCEL,
|
||||
repoWithTag: ''
|
||||
});
|
||||
let confirmOpen = $state(false);
|
||||
|
||||
function requestCancel(repoWithTag: string) {
|
||||
pending = { action: ModelDownloadConfirmAction.CANCEL, repoWithTag };
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
function requestDelete(repoWithTag: string) {
|
||||
pending = { action: ModelDownloadConfirmAction.DELETE, repoWithTag };
|
||||
confirmOpen = true;
|
||||
}
|
||||
|
||||
function stateFor(
|
||||
repoWithTag: string,
|
||||
filePath: string,
|
||||
isSidecar: boolean
|
||||
): ModelDownloadEntryState {
|
||||
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),
|
||||
// while drafts pulled by a loaded model only show up as its --model-draft
|
||||
isDownloaded:
|
||||
!isDownloading &&
|
||||
(modelsStore.status.isModelDownloaded(repoWithTag) ||
|
||||
(isSidecar && modelsStore.status.isSidecarDownloaded(modelId, filePath))),
|
||||
isDownloading,
|
||||
isFailed: modelsStore.status.hasFailedDownload(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 chip rows and the command selects.
|
||||
*/
|
||||
let selectableFiles = $derived.by(() => {
|
||||
const files: (ModelSelectableFile & { state: ModelDownloadEntryState })[] = [];
|
||||
|
||||
for (const row of bitDepthRows) {
|
||||
for (const file of row.files) {
|
||||
const meta = HuggingFaceService.extractQuantMeta(file.path);
|
||||
const tag = ModelsService.buildDownloadTag(
|
||||
modelId,
|
||||
meta?.quant ?? null,
|
||||
meta?.sidecar ?? null
|
||||
);
|
||||
|
||||
files.push({
|
||||
...file,
|
||||
kind: classify(file.path),
|
||||
state: stateFor(tag, file.path, Boolean(meta?.sidecar))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
});
|
||||
|
||||
/** Rows for the row component: per-bit-depth files with state attached. */
|
||||
let rows = $derived.by(() =>
|
||||
bitDepthRows.map((row) => {
|
||||
const paths = new Set(row.files.map((f) => f.path));
|
||||
|
||||
return {
|
||||
bitDepth: row.bitDepth,
|
||||
files: selectableFiles.filter((f) => paths.has(f.path))
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
function optionFor(file: ModelSelectableFile): ModelQuantOption {
|
||||
return {
|
||||
label: labelFor(file.path),
|
||||
path: file.path
|
||||
};
|
||||
}
|
||||
|
||||
/** Non-draft quants for the command's base select, in row order. */
|
||||
let mainOptions = $derived(
|
||||
selectableFiles.filter((f) => f.kind === ModelSelectableFileKind.MAIN).map(optionFor)
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 draftOptions = $derived(
|
||||
selectableFiles
|
||||
.filter((f) => f.kind === ModelSelectableFileKind.DRAFT)
|
||||
.map((f) => ({
|
||||
...optionFor(f),
|
||||
badge: HuggingFaceService.extractQuantMeta(f.path)?.sidecar ?? null
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if bitDepthRows.length}
|
||||
<section class="rounded-3xl border border-border/30 bg-muted/60 shadow-sm dark:border-border/20">
|
||||
<!-- One chip per file, each an independent download action with its own
|
||||
lifecycle state; nothing here selects anything. -->
|
||||
<div class="flex w-full flex-col divide-y divide-border/50 px-4 pb-1 dark:divide-border/35">
|
||||
{#each rows as row (row.bitDepth)}
|
||||
<ModelsDiscoverDetailsDownloadOptionsRow
|
||||
bitDepth={row.bitDepth}
|
||||
files={row.files}
|
||||
onRequestCancel={requestCancel}
|
||||
onRequestDelete={requestDelete}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Terminal command preview, standalone: its picks are not bound to the chips. -->
|
||||
<div class="border-t border-border/50 px-4 pt-3.5 pb-4 dark:border-border/35">
|
||||
<ModelsDiscoverDetailsDownloadOptionsDownloadCommand {draftOptions} {mainOptions} {modelId} />
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<DialogConfirmDownload
|
||||
action={pending.action}
|
||||
onClose={() => (confirmOpen = false)}
|
||||
open={confirmOpen}
|
||||
repoWithTag={pending.repoWithTag}
|
||||
/>
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
import { Check, Copy, Plus, X } from '@lucide/svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import {
|
||||
DEFAULT_BASE_BIT_DEPTH,
|
||||
MODEL_ID,
|
||||
type ModelSidecar,
|
||||
OTHER_BIT_DEPTH,
|
||||
SERVE_COMMAND,
|
||||
SPEC_TYPE
|
||||
} from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { ModelQuantOption } from '$lib/types';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
/** Non-draft quants of the repo, in bit-depth row order. */
|
||||
mainOptions: ModelQuantOption[];
|
||||
/** Draft sidecar files with their sidecar badge; empty when the repo ships none. */
|
||||
draftOptions: (ModelQuantOption & { badge: ModelSidecar | null })[];
|
||||
}
|
||||
|
||||
let { draftOptions, mainOptions, modelId }: Props = $props();
|
||||
|
||||
// Command picks, owned here: nothing two-way binds them to the quant chips.
|
||||
let basePick = $state<string | null>(null);
|
||||
let draftPick = $state<string | null>(null);
|
||||
let draftTypePick = $state<ModelSidecar | null>(null);
|
||||
let withDraft = $state(false);
|
||||
|
||||
function bitDepthOf(path: string): number {
|
||||
const quant = HuggingFaceService.extractQuantMeta(path)?.quant;
|
||||
|
||||
return quant ? (HuggingFaceService.getBitDepth(quant) ?? OTHER_BIT_DEPTH) : OTHER_BIT_DEPTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base file the command points at: the user's pick while it still exists in
|
||||
* the options, else the 4-bit file, else the lowest bit depth available. A
|
||||
* stale pick (the details pane switched models) falls back on its own.
|
||||
*/
|
||||
let baseOption = $derived.by(() => {
|
||||
const picked = mainOptions.find((option) => option.path === basePick);
|
||||
|
||||
if (picked) return picked;
|
||||
|
||||
const preferred = mainOptions.find(
|
||||
(option) => bitDepthOf(option.path) === DEFAULT_BASE_BIT_DEPTH
|
||||
);
|
||||
|
||||
if (preferred) return preferred;
|
||||
|
||||
const ranked = [...mainOptions].sort((a, b) => bitDepthOf(a.path) - bitDepthOf(b.path));
|
||||
|
||||
return ranked[0] ?? null;
|
||||
});
|
||||
|
||||
/** Draft sidecar types the repo ships, in option order. */
|
||||
let specTypes = $derived(
|
||||
draftOptions
|
||||
.map((option) => option.badge)
|
||||
.filter((badge): badge is ModelSidecar => badge !== null)
|
||||
.filter((badge, index, all) => all.indexOf(badge) === index)
|
||||
);
|
||||
|
||||
/**
|
||||
* Draft type the --spec-type select points at: the user's pick while it
|
||||
* still exists, else the first type the repo ships.
|
||||
*/
|
||||
let draftType = $derived(
|
||||
draftTypePick && specTypes.includes(draftTypePick) ? draftTypePick : (specTypes[0] ?? null)
|
||||
);
|
||||
|
||||
/** Draft files of the picked type; the quant select only offers these. */
|
||||
let typeDraftOptions = $derived(draftOptions.filter((option) => option.badge === draftType));
|
||||
|
||||
/** Draft file the -hfd tag points at: the user's pick, else the first of the type. */
|
||||
let draftOption = $derived(
|
||||
withDraft
|
||||
? (typeDraftOptions.find((option) => option.path === draftPick) ??
|
||||
typeDraftOptions[0] ??
|
||||
null)
|
||||
: null
|
||||
);
|
||||
|
||||
/** Quant of the file the `-hf` tag points at; null when the file carries no quant. */
|
||||
let mainQuant = $derived(
|
||||
baseOption ? (HuggingFaceService.extractQuantMeta(baseOption.path)?.quant ?? null) : null
|
||||
);
|
||||
|
||||
/** Quant of the file the `-hfd` tag points at. */
|
||||
let draftQuant = $derived(
|
||||
draftOption ? (HuggingFaceService.extractQuantMeta(draftOption.path)?.quant ?? null) : null
|
||||
);
|
||||
|
||||
/** `--spec-type` value; null when no draft type resolved. */
|
||||
let specType = $derived(draftType ? SPEC_TYPE[draftType] : null);
|
||||
|
||||
/** The llama serve command, composed from the inline picks. */
|
||||
let command = $derived.by(() => {
|
||||
const parts = [
|
||||
SERVE_COMMAND.BIN,
|
||||
SERVE_COMMAND.SUBCOMMAND,
|
||||
SERVE_COMMAND.MODEL_FLAG,
|
||||
mainQuant ? `${modelId}${MODEL_ID.QUANTIZATION_SEPARATOR}${mainQuant}` : modelId
|
||||
];
|
||||
|
||||
if (draftOption && draftQuant) {
|
||||
parts.push(
|
||||
SERVE_COMMAND.DRAFT_FLAG,
|
||||
`${modelId}${MODEL_ID.QUANTIZATION_SEPARATOR}${draftQuant}`,
|
||||
SERVE_COMMAND.SPEC_TYPE_FLAG,
|
||||
specType ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
});
|
||||
|
||||
let copied = $state(false);
|
||||
|
||||
async function copy() {
|
||||
await copyToClipboard(command);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- <div aria-hidden="true" class="flex items-center gap-3 mt-2 mb-4">
|
||||
<span class="h-px flex-1 bg-border/50"></span>
|
||||
|
||||
<span class="text-xs whitespace-nowrap text-muted-foreground"> or run in your terminal </span>
|
||||
|
||||
<span class="h-px flex-1 bg-border/50"></span>
|
||||
</div> -->
|
||||
|
||||
<div
|
||||
class="relative flex items-center gap-2 overflow-hidden rounded-lg border border-border/40 bg-background py-2.5 pl-4 pr-10 shadow-xs dark:border-border/35 dark:bg-background/50"
|
||||
>
|
||||
<!-- Single line: long commands scroll horizontally instead of wrapping. -->
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-x-2 overflow-x-auto py-0.5 font-mono text-xs whitespace-nowrap text-foreground/90"
|
||||
>
|
||||
<span class="shrink-0">{SERVE_COMMAND.BIN}</span>
|
||||
|
||||
<span class="shrink-0">{SERVE_COMMAND.SUBCOMMAND}</span>
|
||||
|
||||
<span class="shrink-0">{SERVE_COMMAND.MODEL_FLAG}</span>
|
||||
|
||||
<span class="shrink-0">
|
||||
{modelId}{mainQuant ? MODEL_ID.QUANTIZATION_SEPARATOR : ''}
|
||||
</span>
|
||||
|
||||
<!-- Base quant: always part of the command, the 4-bit file by default. -->
|
||||
{#if baseOption}
|
||||
<Select.Root onValueChange={(v) => v && (basePick = v)} type="single" value={baseOption.path}>
|
||||
<Select.Trigger
|
||||
aria-label="Base model quantization"
|
||||
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
|
||||
size="xs"
|
||||
>
|
||||
{baseOption.label}
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content class="font-mono text-xs">
|
||||
{#each mainOptions as option (option.path)}
|
||||
<Select.Item class="text-xs" label={option.label} value={option.path}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
|
||||
<!-- Draft add: a tiny dashed affordance right of the base part; gone once added. -->
|
||||
{#if draftOptions.length && !withDraft}
|
||||
<button
|
||||
aria-label="Add draft model"
|
||||
class="mx-1 inline-flex h-5 shrink-0 cursor-pointer items-center gap-1 rounded-md border border-dashed border-border/60 px-1.5 text-[10px] text-muted-foreground transition-colors hover:border-border hover:text-foreground"
|
||||
onclick={() => (withDraft = true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus class="h-3 w-3" />
|
||||
|
||||
add draft model
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Draft segment: quant and spec type of the picked draft flavour. The X
|
||||
at the end drops the whole segment (the add button is gone once added);
|
||||
it only appears while hovering the segment, or directly on touch -->
|
||||
{#if draftOption}
|
||||
<span class="group/draft inline-flex shrink-0 items-center gap-x-2">
|
||||
<span>{SERVE_COMMAND.DRAFT_FLAG}</span>
|
||||
|
||||
<span class="shrink-0">
|
||||
{modelId}{draftQuant ? MODEL_ID.QUANTIZATION_SEPARATOR : ''}
|
||||
</span>
|
||||
|
||||
<Select.Root
|
||||
onValueChange={(v) => v && (draftPick = v)}
|
||||
type="single"
|
||||
value={draftOption.path}
|
||||
>
|
||||
<Select.Trigger
|
||||
aria-label="Draft model quantization"
|
||||
class="-ml-2 border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
|
||||
size="xs"
|
||||
>
|
||||
{draftOption.label}
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content class="font-mono text-xs">
|
||||
{#each typeDraftOptions as option (option.path)}
|
||||
<Select.Item class="text-xs" label={option.label} value={option.path}>
|
||||
{option.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
|
||||
{#if draftType}
|
||||
<span>{SERVE_COMMAND.SPEC_TYPE_FLAG}</span>
|
||||
|
||||
<!-- the select only earns its chrome when there is a real choice to make -->
|
||||
{#if specTypes.length > 1}
|
||||
<Select.Root
|
||||
onValueChange={(v) => v && (draftTypePick = v as ModelSidecar)}
|
||||
type="single"
|
||||
value={draftType}
|
||||
>
|
||||
<Select.Trigger
|
||||
aria-label="Draft type"
|
||||
class="border-primary/15 bg-primary/[0.07] font-mono text-foreground hover:bg-primary/15 focus-visible:border-primary/40 focus-visible:ring-0"
|
||||
size="xs"
|
||||
>
|
||||
{SPEC_TYPE[draftType]}
|
||||
</Select.Trigger>
|
||||
|
||||
<Select.Content class="font-mono text-xs">
|
||||
{#each specTypes as type (type)}
|
||||
<Select.Item class="text-xs" label={SPEC_TYPE[type]} value={type}>
|
||||
{SPEC_TYPE[type]}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{:else}
|
||||
<span>{SPEC_TYPE[draftType]}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<button
|
||||
aria-label="Remove draft model"
|
||||
class="shrink-0 cursor-pointer text-muted-foreground/60 opacity-0 transition-[opacity,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover/draft:opacity-100 [@media(pointer:coarse)]:opacity-100"
|
||||
onclick={() => (withDraft = false)}
|
||||
type="button"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Copy command"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer rounded-md p-1.5 text-muted-foreground/70 transition-colors hover:bg-primary/10 hover:text-foreground"
|
||||
onclick={copy}
|
||||
type="button"
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="h-3.5 w-3.5 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="h-3.5 w-3.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverDownloadProgressBar from '../../ModelsDiscoverDownloadProgressBar.svelte';
|
||||
import { labelFor } from './download-options.utils';
|
||||
import { Check, Download, Loader2, Pause, Play, RotateCw, X } from '@lucide/svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { HfModelSibling, ModelDownloadEntryState } from '$lib/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** GGUF file the chip stands for. */
|
||||
file: HfModelSibling;
|
||||
/** Download state of the file, from the parent's status feed. */
|
||||
entry: ModelDownloadEntryState;
|
||||
/**
|
||||
* Ask the parent to confirm deleting a downloaded model. The chip owns no
|
||||
* dialog; the parent renders the single confirmation and acts on confirm.
|
||||
*/
|
||||
onRequestDelete?: (repoWithTag: string) => void;
|
||||
/** Ask the parent to confirm cancelling an in-flight download. */
|
||||
onRequestCancel?: (repoWithTag: string) => void;
|
||||
}
|
||||
|
||||
let { entry, file, onRequestCancel, onRequestDelete }: Props = $props();
|
||||
|
||||
// Sidecar kind (mtp, mmproj, ...) tag shown on every chip state; one source so
|
||||
// the idle, in-flight and downloaded variants stay identical.
|
||||
const SIDECAR_BADGE_CLASS =
|
||||
'rounded-md bg-primary px-1 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase';
|
||||
|
||||
/** Queue the download; a failed attempt leaves partial files, drop them first. */
|
||||
async function startDownload() {
|
||||
try {
|
||||
if (entry.isFailed) await modelsStore.status.cancelDownload(entry.repoWithTag);
|
||||
|
||||
await modelsStore.status.downloadModel(entry.repoWithTag);
|
||||
} catch {
|
||||
// the store already toasted the failure
|
||||
}
|
||||
}
|
||||
|
||||
let meta = $derived(HuggingFaceService.extractQuantMeta(file.path));
|
||||
let label = $derived(labelFor(file.path));
|
||||
|
||||
let percent = $derived(
|
||||
entry.progress && entry.progress.totalBytes > 0
|
||||
? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
|
||||
: null
|
||||
);
|
||||
|
||||
let tooltipText = $derived(
|
||||
entry.isDownloading
|
||||
? 'Pause downloading'
|
||||
: entry.isPaused
|
||||
? 'Resume downloading'
|
||||
: entry.isDownloaded
|
||||
? 'Delete model'
|
||||
: entry.isFailed
|
||||
? `Retry download: ${file.path}`
|
||||
: `Download ${file.path}`
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet chipBody(dividerClass: string)}
|
||||
<!-- badge, label and divider: identical on every chip state -->
|
||||
{#if meta?.sidecar}
|
||||
<span class={SIDECAR_BADGE_CLASS}>
|
||||
{meta.sidecar}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="font-medium">{label}</span>
|
||||
|
||||
<span class={dividerClass}></span>
|
||||
{/snippet}
|
||||
|
||||
<!-- every chip is a tooltip trigger; the button renders as the trigger's child
|
||||
so no nested button element is created -->
|
||||
{#snippet tooltipTrigger(tip: string, button: Snippet<[Record<string, unknown>]>)}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
{#snippet child({ props })}
|
||||
{@render button(props)}
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content>
|
||||
<p>{tip}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/snippet}
|
||||
|
||||
{#snippet deleteChip(props: Record<string, unknown>)}
|
||||
<!-- downloaded chips are delete actions: green check by default, red X on hover -->
|
||||
<button
|
||||
{...props}
|
||||
aria-label={tooltipText}
|
||||
class="group relative inline-flex h-auto cursor-pointer items-center gap-1 rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-sm transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
|
||||
border-green-600/25 bg-green-500/5 hover:border-destructive/50 hover:bg-destructive/10 dark:border-green-500/30 dark:bg-green-500/10 dark:hover:border-destructive/50 dark:hover:bg-destructive/15"
|
||||
onclick={() => onRequestDelete?.(entry.repoWithTag)}
|
||||
type="button"
|
||||
>
|
||||
{@render chipBody(
|
||||
'-my-1 mx-0.75 w-px self-stretch bg-green-600/25 transition-colors duration-200 group-hover:bg-destructive/30 dark:bg-green-600/30 dark:group-hover:bg-destructive/30'
|
||||
)}
|
||||
|
||||
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
|
||||
|
||||
<!-- icon slot: crossfade check -> x; touch devices show the delete affordance directly -->
|
||||
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Check
|
||||
class="absolute h-3.5 w-3.5 text-green-500 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-75 group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
|
||||
/>
|
||||
|
||||
<X
|
||||
class="absolute h-3.5 w-3.5 scale-75 text-destructive opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet pauseResumeChip(props: Record<string, unknown>)}
|
||||
<button
|
||||
{...props}
|
||||
aria-label={tooltipText}
|
||||
class="flex h-auto min-w-0 flex-1 cursor-pointer items-center gap-1 text-left"
|
||||
onclick={() => {
|
||||
if (entry.isDownloading) void modelsStore.status.pauseDownload(entry.repoWithTag);
|
||||
else void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{@render chipBody('-my-1 mx-0.75 w-px self-stretch bg-border')}
|
||||
|
||||
{#if percent !== null}
|
||||
<span class="mr-1 tabular-nums">{percent}%</span>
|
||||
{:else if entry.isPaused}
|
||||
<span>Paused</span>
|
||||
{:else}
|
||||
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
|
||||
{/if}
|
||||
|
||||
{#if entry.isDownloading}
|
||||
<!-- spinner fades into the pause affordance on hover; opacity only, the
|
||||
spin keyframes own the transform so scale would fight them -->
|
||||
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Loader2
|
||||
class="absolute h-3.5 w-3.5 animate-spin text-muted-foreground transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
|
||||
/>
|
||||
|
||||
<Pause
|
||||
class="absolute h-3.5 w-3.5 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
<!-- paused: the play affordance fades in on hover; visible directly on touch -->
|
||||
<span class="relative inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Play
|
||||
class="absolute h-3.5 w-3.5 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet cancelChip(props: Record<string, unknown>)}
|
||||
<!-- cancel slot: same fixed slot and fade as the pause / play affordances
|
||||
so the two icons line up exactly; the X turns destructive on its hover -->
|
||||
<button
|
||||
{...props}
|
||||
aria-label="Cancel downloading"
|
||||
class="relative grid h-3.5 w-3.5 shrink-0 cursor-pointer items-center justify-center text-muted-foreground/70"
|
||||
onclick={() => onRequestCancel?.(entry.repoWithTag)}
|
||||
type="button"
|
||||
>
|
||||
<X
|
||||
class="h-3.5 w-3.5 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] text-destructive [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet downloadChip(props: Record<string, unknown>)}
|
||||
<!-- idle chips download on click (retry when the last attempt failed) -->
|
||||
<button
|
||||
{...props}
|
||||
aria-label={tooltipText}
|
||||
class="group relative inline-flex h-auto cursor-pointer items-center gap-1 overflow-hidden rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-sm transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
|
||||
border-border/30 bg-background hover:bg-muted-foreground/10 dark:border-border/20 dark:bg-muted-foreground/15 dark:text-secondary-foreground dark:hover:bg-muted-foreground/25
|
||||
{entry.isFailed ? 'border-destructive!' : ''}"
|
||||
onclick={() => void startDownload()}
|
||||
type="button"
|
||||
>
|
||||
{#if entry.isFailed}
|
||||
<span
|
||||
class="rounded-md bg-destructive px-1 py-0.5 text-[10px] font-semibold tracking-wide text-destructive-foreground uppercase"
|
||||
>
|
||||
Failed
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{@render chipBody('-my-1 mx-0.75 w-px self-stretch bg-border')}
|
||||
|
||||
<span>{HuggingFaceService.formatFileSize(file.size ?? 0)}</span>
|
||||
|
||||
<span class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
{#if entry.isFailed}
|
||||
<RotateCw class="h-3.5 w-3.5 text-destructive" />
|
||||
{:else}
|
||||
<Download
|
||||
class="h-3.5 w-3.5 text-muted-foreground transition-colors duration-150 group-hover:text-foreground"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if entry.isDownloaded}
|
||||
{@render tooltipTrigger(tooltipText, deleteChip)}
|
||||
{:else if entry.isDownloading || entry.isPaused}
|
||||
<!-- in-flight / paused chips: the chip body pauses / resumes on click, the X
|
||||
inside the chip cancels (stops and discards the partial files). The X slot
|
||||
is reserved, so the chip never reflows when the affordance fades in -->
|
||||
<div
|
||||
class="group relative inline-flex h-auto items-center gap-1 overflow-hidden rounded-md! border px-2 py-1 text-left font-mono text-xs shadow-sm transition-[background-color,border-color,transform] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.97]
|
||||
{entry.isPaused
|
||||
? 'border-yellow-600/40 bg-yellow-500/10 hover:bg-yellow-500/20 dark:border-yellow-500/30 dark:bg-yellow-500/10'
|
||||
: 'border-border/30 bg-background hover:bg-muted-foreground/10 dark:border-border/20 dark:bg-muted-foreground/15'}"
|
||||
>
|
||||
{@render tooltipTrigger(tooltipText, pauseResumeChip)}
|
||||
|
||||
{@render tooltipTrigger('Cancel downloading', cancelChip)}
|
||||
|
||||
{#if percent !== null}
|
||||
<ModelsDiscoverDownloadProgressBar
|
||||
downloadedBytes={entry.progress?.downloadedBytes ?? 0}
|
||||
overlay
|
||||
totalBytes={entry.progress?.totalBytes ?? 0}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{@render tooltipTrigger(tooltipText, downloadChip)}
|
||||
{/if}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton from './ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte';
|
||||
import {
|
||||
BIT_DEPTH_LABEL_SUFFIX,
|
||||
GIGABYTE_LABEL,
|
||||
OTHER_BIT_DEPTH,
|
||||
OTHER_BIT_DEPTH_LABEL
|
||||
} from '$lib/constants';
|
||||
import { ModelSelectableFileKind } from '$lib/enums';
|
||||
import type { ModelDownloadEntryState, ModelSelectableFile } from '$lib/types';
|
||||
import { minMemoryTierGb } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
/** Bit depth of the row; `99` renders as "Other". */
|
||||
bitDepth: number;
|
||||
/** Every GGUF of this bit depth, with download state attached. */
|
||||
files: (ModelSelectableFile & { state: ModelDownloadEntryState })[];
|
||||
/** Forwarded to each chip: ask the parent to confirm a cancel. */
|
||||
onRequestCancel?: (repoWithTag: string) => void;
|
||||
/** Forwarded to each chip: ask the parent to confirm a delete. */
|
||||
onRequestDelete?: (repoWithTag: string) => void;
|
||||
}
|
||||
|
||||
let { bitDepth, files, onRequestCancel, onRequestDelete }: Props = $props();
|
||||
|
||||
let mainFile = $derived(files.find((f) => f.kind === ModelSelectableFileKind.MAIN) ?? null);
|
||||
let draftFile = $derived(files.find((f) => f.kind === ModelSelectableFileKind.DRAFT) ?? null);
|
||||
|
||||
let mainMemGb = $derived(mainFile ? minMemoryTierGb(mainFile.size ?? 0) : null);
|
||||
let draftMemGb = $derived(draftFile ? minMemoryTierGb(draftFile.size ?? 0) : null);
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-[5rem_1fr] items-center gap-3 py-3">
|
||||
<div class="pt-1 text-sm tabular-nums text-muted-foreground">
|
||||
{#if bitDepth === OTHER_BIT_DEPTH}
|
||||
{OTHER_BIT_DEPTH_LABEL}
|
||||
{:else}
|
||||
{bitDepth}{BIT_DEPTH_LABEL_SUFFIX}
|
||||
{/if}
|
||||
|
||||
{#if mainMemGb}
|
||||
<span class="block text-[10px] whitespace-nowrap text-muted-foreground/60">
|
||||
needs at least {mainMemGb}{GIGABYTE_LABEL}{draftMemGb
|
||||
? ` + ${draftMemGb}${GIGABYTE_LABEL}`
|
||||
: ''} memory
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-end gap-1.5">
|
||||
{#each files as file (file.path)}
|
||||
<ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton
|
||||
entry={file.state}
|
||||
{file}
|
||||
{onRequestCancel}
|
||||
{onRequestDelete}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { DRAFT_FILE_LABEL, MODEL_ID, PATH_SEPARATOR } from '$lib/constants';
|
||||
import { ModelSelectableFileKind } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { isAuxSidecar, isDraftSidecar } from '$lib/utils';
|
||||
|
||||
/** Kind of a file path: the main weights, a draft sidecar, or an aux sidecar (mmproj). */
|
||||
export function classify(path: string): ModelSelectableFileKind {
|
||||
const sidecar = HuggingFaceService.extractQuantMeta(path)?.sidecar;
|
||||
|
||||
if (!sidecar) return ModelSelectableFileKind.MAIN;
|
||||
|
||||
return isAuxSidecar(sidecar) ? ModelSelectableFileKind.AUX : ModelSelectableFileKind.DRAFT;
|
||||
}
|
||||
|
||||
/** Display label of a file: its quant, else the file name without the extension. */
|
||||
export function labelFor(path: string): string {
|
||||
const meta = HuggingFaceService.extractQuantMeta(path);
|
||||
|
||||
if (meta?.quant) return meta.quant;
|
||||
|
||||
// Quantless sidecar files: the chip badge already carries the sidecar type,
|
||||
// so the label only marks draft files; aux sidecars badge alone.
|
||||
if (meta?.sidecar) return isDraftSidecar(meta.sidecar) ? DRAFT_FILE_LABEL : '';
|
||||
|
||||
const basename = path.split(PATH_SEPARATOR).pop() ?? path;
|
||||
|
||||
return basename.replace(MODEL_ID.WEIGHT_EXTENSION_REGEX, '');
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
*
|
||||
* MODELS DISCOVER - DETAILS - DOWNLOAD OPTIONS
|
||||
*
|
||||
* The download area of the detail pane: GGUF files grouped by bit depth, each an
|
||||
* independent download action chip, plus the standalone terminal command preview.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsDownloadOptions** - GGUF download options
|
||||
*
|
||||
* Groups GGUF files by bit depth and renders one independent download
|
||||
* action chip per file, plus the standalone terminal command preview.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsDownloadOptions } from './ModelsDiscoverDetailsDownloadOptions.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsDownloadOptionsRow** - One bit-depth group of quants
|
||||
*
|
||||
* A single bit-depth row inside ModelsDiscoverDetailsDownloadOptions: the
|
||||
* depth label with its memory hint and the quant chips of that depth.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsDownloadOptionsRow } from './ModelsDiscoverDetailsDownloadOptionsRow.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton** - One quant chip
|
||||
*
|
||||
* A single GGUF file as an independent action chip: download / retry when
|
||||
* idle, pause / resume / cancel while in flight, delete when downloaded.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton } from './ModelsDiscoverDetailsDownloadOptionsQuantDownloadButton.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsDownloadOptionsDownloadCommand** - Terminal command
|
||||
*
|
||||
* The `llama serve -hf ...` command box with inline quant selects and a copy
|
||||
* button; owns its picks, nothing two-way binds them to the quant chips.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsDownloadOptionsDownloadCommand } from './ModelsDiscoverDetailsDownloadOptionsDownloadCommand.svelte';
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import ModelCapabilityIcons from '../../ModelCapabilityIcons.svelte';
|
||||
import ModelsDiscoverAvatar from '../ModelsDiscoverAvatar.svelte';
|
||||
import ModelsDiscoverDetailsHfHubStats from './ModelsDiscoverDetailsHfHubStats.svelte';
|
||||
import ModelsDiscoverDetailsMetadata from './ModelsDiscoverDetailsMetadata.svelte';
|
||||
import { ExternalLink } from '@lucide/svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { ICON_CLASS_SM } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
|
||||
import { orgOf } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
modelId: string;
|
||||
details: HfModelDetailInfo;
|
||||
gguf?: HfModelGguf;
|
||||
baseModels: string[];
|
||||
licenseTag: string | null;
|
||||
hasVision: boolean;
|
||||
hasTools: boolean;
|
||||
hasReasoning: boolean;
|
||||
}
|
||||
|
||||
let { baseModels, details, gguf, hasReasoning, hasTools, hasVision, licenseTag, modelId }: Props =
|
||||
$props();
|
||||
|
||||
// Avatar shows the base model's org (e.g. the Qwen logo for a ggml-org GGUF)
|
||||
// with the quant org as a corner badge when they differ.
|
||||
let repoOrg = $derived(orgOf(details.id) || orgOf(modelId));
|
||||
let baseOrg = $derived(orgOf(baseModels[0]));
|
||||
let avatarOrg = $derived(baseOrg || repoOrg);
|
||||
let quantOrg = $derived(baseOrg && baseOrg !== repoOrg ? repoOrg : undefined);
|
||||
</script>
|
||||
|
||||
<header class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ModelsDiscoverAvatar
|
||||
org={avatarOrg}
|
||||
{quantOrg}
|
||||
quantPositionClass="-bottom-1.5 -right-1.5"
|
||||
quantSize="h-6 w-6"
|
||||
size="h-12 w-12"
|
||||
/>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="truncate text-lg font-semibold">{details.id ?? modelId}</h1>
|
||||
|
||||
<ModelCapabilityIcons
|
||||
gapClass="gap-2"
|
||||
iconSize="h-4 w-4"
|
||||
modalities={{ audio: false, video: false, vision: hasVision }}
|
||||
supportsThinking={hasReasoning}
|
||||
supportsToolUse={hasTools}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if baseModels.length}
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="truncate text-xs text-muted-foreground">{baseModels.join(', ')}</span>
|
||||
|
||||
<a
|
||||
aria-label="View base model on HuggingFace"
|
||||
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
href={HuggingFaceService.getModelUrl(baseModels[0])}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border/30 px-2.5 py-1.5 text-xs font-medium shadow-sm transition-colors hover:bg-muted dark:border-border/20"
|
||||
href={HuggingFaceService.getModelUrl(modelId)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<img alt="" class="h-3.5 w-3.5" src="{base}/recommended-mcp/huggingface.ico" />
|
||||
|
||||
View on Hugging Face
|
||||
|
||||
<ExternalLink class={ICON_CLASS_SM} />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ModelsDiscoverDetailsHfHubStats {details} />
|
||||
|
||||
<ModelsDiscoverDetailsMetadata {details} {gguf} {licenseTag} {modelId} />
|
||||
</header>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Download, Heart } from '@lucide/svelte';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { HfModelDetailInfo } from '$lib/types/huggingface';
|
||||
|
||||
interface Props {
|
||||
details: HfModelDetailInfo;
|
||||
}
|
||||
|
||||
let { details }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
{#if typeof details.downloads === 'number'}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Download class="h-3.5 w-3.5" />
|
||||
{HuggingFaceService.formatDownloads(details.downloads)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if typeof details.likes === 'number'}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Heart class="h-3.5 w-3.5" />
|
||||
{HuggingFaceService.formatLikes(details.likes)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if details.lastModified}
|
||||
<span>Updated {HuggingFaceService.formatRelativeTime(details.lastModified)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
|
||||
import ModelsDiscoverDetailsMetadataItem from './ModelsDiscoverDetailsMetadataItem.svelte';
|
||||
import { MessageSquareCode } from '@lucide/svelte';
|
||||
import { modelsDiscoverStore } 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(
|
||||
modelsDiscoverStore.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}
|
||||
<ModelsDiscoverDetailsMetadataItem
|
||||
label="Model size"
|
||||
value="{formatParameters(gguf.total)} params"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.context_length}
|
||||
<ModelsDiscoverDetailsMetadataItem
|
||||
label="Context"
|
||||
value={gguf.context_length.toLocaleString()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if gguf?.architecture}
|
||||
<ModelsDiscoverDetailsMetadataItem label="Architecture" value={gguf.architecture} />
|
||||
{/if}
|
||||
|
||||
{#if gguf?.chat_template}
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-border/30 px-2.5 py-1 text-xs font-medium shadow-sm transition-colors hover:bg-muted dark:border-border/20"
|
||||
onclick={() => (chatTemplateOpen = true)}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareCode class="h-3 w-3" />
|
||||
Chat template
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if licenseTag}
|
||||
<ModelsDiscoverDetailsMetadataItem label="License" value={licenseTag} />
|
||||
{/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}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let { label, value }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- Metadata chip: label | value pair, matching the HF model page style -->
|
||||
<span
|
||||
class="inline-flex items-center divide-x divide-border rounded-md border border-border/30 text-xs shadow-sm dark:border-border/20"
|
||||
>
|
||||
<span class="px-2.5 py-1 text-muted-foreground">{label}</span>
|
||||
|
||||
<span class="px-2.5 py-1 font-medium">{value}</span>
|
||||
</span>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { MarkdownContent } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
readme: string | null;
|
||||
}
|
||||
|
||||
let { readme }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if readme}
|
||||
<section
|
||||
class="p-3 rounded-3xl border border-border/30 bg-muted/60 shadow-sm dark:border-border/20"
|
||||
>
|
||||
<MarkdownContent allowHtml class="prose-sm max-w-none" content={readme} />
|
||||
</section>
|
||||
{/if}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
</script>
|
||||
|
||||
<!-- Static skeleton of ModelsDiscoverDetails: header, metadata chips, download options and readme lines. -->
|
||||
<div class="space-y-6 p-6" data-slot="model-details-skeleton">
|
||||
<!-- Header: avatar, name with capability icons, base model line, HF link -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Skeleton class="h-12 w-12 rounded-md" />
|
||||
|
||||
<div class="min-w-0 space-y-1.5">
|
||||
<Skeleton class="h-5 w-56 max-w-full" />
|
||||
|
||||
<Skeleton class="h-3 w-40 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Skeleton class="h-7.5 w-44 shrink-0 rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- downloads / likes / last updated -->
|
||||
<div class="flex items-center gap-4">
|
||||
<Skeleton class="h-3.5 w-16" />
|
||||
|
||||
<Skeleton class="h-3.5 w-12" />
|
||||
|
||||
<Skeleton class="h-3.5 w-24" />
|
||||
</div>
|
||||
|
||||
<!-- metadata chips -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<Skeleton class="h-7 w-36 rounded-md" />
|
||||
|
||||
<Skeleton class="h-7 w-28 rounded-md" />
|
||||
|
||||
<Skeleton class="h-7 w-32 rounded-md" />
|
||||
|
||||
<Skeleton class="h-7 w-24 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download options: quant rows, CTA, terminal command -->
|
||||
<div
|
||||
class="rounded-3xl border border-border/30 bg-muted/40 p-4 shadow-sm dark:border-border/20 dark:bg-muted/50"
|
||||
>
|
||||
<div class="space-y-3 divide-y divide-border/50 dark:divide-border/35 pb-1">
|
||||
{#each [0, 1, 2] as _, index (index)}
|
||||
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-3">
|
||||
<div class="space-y-1.5 pt-1">
|
||||
<Skeleton class="h-4 w-14" />
|
||||
|
||||
<Skeleton class="h-2.5 w-24" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<Skeleton class="h-6 w-28 rounded-md" />
|
||||
|
||||
<Skeleton class="h-6 w-20 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5 border-t border-border/50 pt-3.5 dark:border-border/35">
|
||||
<Skeleton class="h-9 w-full rounded-md" />
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="h-px flex-1 bg-border/50"></span>
|
||||
|
||||
<Skeleton class="h-3 w-44" />
|
||||
|
||||
<span class="h-px flex-1 bg-border/50"></span>
|
||||
</div>
|
||||
|
||||
<Skeleton class="h-11 w-full rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Readme -->
|
||||
<div class="space-y-2.5">
|
||||
<Skeleton class="h-3.5 w-full" />
|
||||
|
||||
<Skeleton class="h-3.5 w-full" />
|
||||
|
||||
<Skeleton class="h-3.5 w-5/6" />
|
||||
|
||||
<Skeleton class="h-3.5 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
*
|
||||
* MODELS DISCOVER - DETAILS
|
||||
*
|
||||
* The right-hand detail pane of the discover view: header (avatar, name, stats, metadata
|
||||
* chips, capability badges), the download options area and the model-card README.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetails** - Model detail view
|
||||
*
|
||||
* Detail pane for the selected model. Loads its own data (details + GGUF file
|
||||
* list) from HuggingFaceService based on the `modelId` route param.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetails } from './ModelsDiscoverDetails.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsHeader** - Detail view header
|
||||
*
|
||||
* Shows the model avatar (base org + quant org corner badge), name, base model
|
||||
* info, stats, metadata chips and capability badges.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsHeader } from './ModelsDiscoverDetailsHeader.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsHfHubStats** - HuggingFace Hub stats row
|
||||
*
|
||||
* Downloads, likes and last-updated for the viewed model, formatted the way
|
||||
* the Hub shows them.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsHfHubStats } from './ModelsDiscoverDetailsHfHubStats.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsSkeleton** - Detail view loading skeleton
|
||||
*
|
||||
* Static placeholder matching the detail layout: header with avatar and name,
|
||||
* metadata chips, the download options box and readme text lines.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsSkeleton } from './ModelsDiscoverDetailsSkeleton.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsReadme** - Detail view README
|
||||
*
|
||||
* Renders the model card README as markdown.
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsReadme } from './ModelsDiscoverDetailsReadme.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsMetadata** - Detail view metadata row
|
||||
*
|
||||
* The detail view's metadata chips (model size, context, architecture, license,
|
||||
* chat template).
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsMetadata } from './ModelsDiscoverDetailsMetadata.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDetailsMetadataItem** - Single metadata chip
|
||||
*
|
||||
* One label | value chip of the detail view's metadata row (model size,
|
||||
* context, architecture, license).
|
||||
*/
|
||||
export { default as ModelsDiscoverDetailsMetadataItem } from './ModelsDiscoverDetailsMetadataItem.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverChatTemplateDialog** - Chat template viewer
|
||||
*
|
||||
* Shows the model's chat template in a scrollable dialog with a copy button.
|
||||
*/
|
||||
export { default as ModelsDiscoverChatTemplateDialog } from './ModelsDiscoverChatTemplateDialog.svelte';
|
||||
|
||||
export * from './ModelsDiscoverDetailsDownloadOptions';
|
||||
+2
-2
@@ -18,14 +18,14 @@
|
||||
{#if overlay}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
|
||||
<div
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
class="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
class="h-full bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverListItem from './ModelsDiscoverListItem.svelte';
|
||||
import ModelsDiscoverListItemSkeleton from './ModelsDiscoverListItemSkeleton.svelte';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
|
||||
interface Props {
|
||||
models: HfModelInfo[];
|
||||
activeId?: string | null;
|
||||
loading?: boolean;
|
||||
loadingSkeletonRowsCount?: number;
|
||||
/** Show the original (base) model's org avatar instead of the repo's org. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
onSelect?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
activeId = null,
|
||||
loading = false,
|
||||
loadingSkeletonRowsCount = 8,
|
||||
models,
|
||||
onSelect,
|
||||
showBaseModelAvatar = false
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<ul class="space-y-0.5">
|
||||
{#if loading}
|
||||
{#each Array(loadingSkeletonRowsCount) as _, index (index)}
|
||||
<ModelsDiscoverListItemSkeleton {index} />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each models as model (model.id)}
|
||||
<ModelsDiscoverListItem
|
||||
active={model.id === activeId}
|
||||
{model}
|
||||
{onSelect}
|
||||
{showBaseModelAvatar}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import ModelId from '../../ModelId.svelte';
|
||||
import ModelsDiscoverAvatar from '../ModelsDiscoverAvatar.svelte';
|
||||
import { HF_MMPROJ_FILENAME_TOKEN, HF_MODALITY_PIPELINE_TAGS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsDiscoverStore } from '$lib/stores';
|
||||
import type { ModelsDiscoverSizeRange } from '$lib/stores/models-discover/index.svelte';
|
||||
import type { HfModelInfo } from '$lib/types/huggingface';
|
||||
import type { ModelModalities, ModelSidecarBadge } from '$lib/types/models';
|
||||
import { detectThinkingSupport, detectToolUseSupport, isAuxSidecar, orgOf } from '$lib/utils';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
model: HfModelInfo;
|
||||
active?: boolean;
|
||||
/** Show the original (base) model's org avatar instead of the repo's org. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
onSelect?: (modelId: string) => void;
|
||||
}
|
||||
|
||||
let { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
|
||||
|
||||
let org = $derived(orgOf(model.id));
|
||||
|
||||
// Org whose avatar is shown: the base model's org when showBaseModelAvatar
|
||||
// (e.g. the Qwen logo for ggml-org/Qwen3.8-27B-GGUF), else the repo's org.
|
||||
let avatarOrg = $derived.by(() => {
|
||||
if (!showBaseModelAvatar) return org;
|
||||
|
||||
return orgOf(HuggingFaceService.getBaseModels(model)[0]) || org;
|
||||
});
|
||||
|
||||
let contextLength = $derived(model.gguf?.context_length);
|
||||
|
||||
// Reasoning support from the chat template, matching the details view.
|
||||
let supportsThinking = $derived(detectThinkingSupport(model.gguf?.chat_template ?? ''));
|
||||
|
||||
// Tool use support from the chat template.
|
||||
let supportsToolUse = $derived(detectToolUseSupport(model.gguf?.chat_template ?? ''));
|
||||
|
||||
// Modalities derived from HF metadata: vision from an mmproj sidecar or a
|
||||
// multimodal pipeline tag, audio/video from their pipeline tags.
|
||||
let modalities = $derived.by<ModelModalities>(() => {
|
||||
const tag = model.pipeline_tag ?? '';
|
||||
const vision =
|
||||
HF_MODALITY_PIPELINE_TAGS.vision.includes(tag) ||
|
||||
Boolean(
|
||||
model.siblings?.some((s) => s.rfilename.toLowerCase().includes(HF_MMPROJ_FILENAME_TOKEN))
|
||||
);
|
||||
const audio = HF_MODALITY_PIPELINE_TAGS.audio.includes(tag);
|
||||
const video = HF_MODALITY_PIPELINE_TAGS.video.includes(tag);
|
||||
|
||||
return { audio, video, vision };
|
||||
});
|
||||
|
||||
// Draft sidecars (mtp, dflash, dspark, eagle3) present in the repo, e.g.
|
||||
// speculative-decoding drafts. mmproj is excluded: it is vision, already
|
||||
// conveyed by the modalities.
|
||||
let draftSidecars = $derived.by<ModelSidecarBadge[]>(() => {
|
||||
const badges = new SvelteMap<string, ModelSidecarBadge>();
|
||||
|
||||
for (const sibling of model.siblings ?? []) {
|
||||
const meta = HuggingFaceService.extractQuantMeta(sibling.rfilename);
|
||||
|
||||
if (!meta?.sidecar || isAuxSidecar(meta.sidecar)) continue;
|
||||
|
||||
const badge: ModelSidecarBadge = { kind: meta.sidecar, quant: meta.quant, repo: model.id };
|
||||
|
||||
badges.set(`${badge.kind}:${badge.quant ?? ''}`, badge);
|
||||
}
|
||||
|
||||
return [...badges.values()];
|
||||
});
|
||||
|
||||
// Min/max size across the repo's quants, draft sidecars included. The store
|
||||
// has catalog rows covered already; any other row (a search hit) measures
|
||||
// its repo once here and the result is cached per repo.
|
||||
let measuredSize = $state<ModelsDiscoverSizeRange | null>(null);
|
||||
let sizeRange = $derived(modelsDiscoverStore.cachedSizeRangeFor(model.id) ?? measuredSize);
|
||||
|
||||
$effect(() => {
|
||||
const id = model.id;
|
||||
|
||||
if (modelsDiscoverStore.cachedSizeRangeFor(id)) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void modelsDiscoverStore.sizeRange(id).then((range) => {
|
||||
if (!cancelled) measuredSize = range ?? null;
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<li>
|
||||
<button
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {active
|
||||
? 'bg-primary/10 hover:bg-primary/15'
|
||||
: 'hover:bg-muted/60'}"
|
||||
onclick={() => onSelect?.(model.id)}
|
||||
type="button"
|
||||
>
|
||||
<ModelsDiscoverAvatar
|
||||
class="mt-1"
|
||||
org={avatarOrg}
|
||||
quantOrg={showBaseModelAvatar ? org : undefined}
|
||||
/>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<ModelId
|
||||
class="min-w-0"
|
||||
{contextLength}
|
||||
{draftSidecars}
|
||||
hideOrgName
|
||||
iconsOnNewLine
|
||||
{modalities}
|
||||
modelId={model.id}
|
||||
{sizeRange}
|
||||
{supportsThinking}
|
||||
{supportsToolUse}
|
||||
wrap
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
|
||||
interface Props {
|
||||
/** Row index; varies skeleton widths so the list does not look mechanical. */
|
||||
index?: number;
|
||||
}
|
||||
|
||||
let { index = 0 }: Props = $props();
|
||||
|
||||
// Deterministic width variations keyed by row position.
|
||||
const NAME_WIDTHS = ['w-40', 'w-52', 'w-44', 'w-56'];
|
||||
const BADGE_WIDTHS = [
|
||||
['w-12', 'w-14', 'w-10'],
|
||||
['w-16', 'w-12', 'w-12'],
|
||||
['w-10', 'w-16', 'w-10'],
|
||||
['w-14', 'w-10', 'w-14']
|
||||
];
|
||||
|
||||
let nameWidth = $derived(NAME_WIDTHS[index % NAME_WIDTHS.length]);
|
||||
let badgeWidths = $derived(BADGE_WIDTHS[index % BADGE_WIDTHS.length]);
|
||||
</script>
|
||||
|
||||
<!-- Static skeleton of ModelsDiscoverListItem: avatar, name and badge rows. -->
|
||||
<li>
|
||||
<div class="flex w-full items-start gap-2.5 rounded-lg p-2.5 text-left">
|
||||
<Skeleton class="h-9 w-9 shrink-0 rounded-md" />
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<Skeleton class="{nameWidth} h-4 max-w-full" />
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{#each badgeWidths as width, i (i)}
|
||||
<Skeleton class="{width} h-3.5 rounded" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
/** Search callback; the parent owns debouncing. */
|
||||
onSearch?: (query: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onSearch,
|
||||
placeholder = 'Search models on Hugging Face',
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
function handleInput(next: string) {
|
||||
value = next;
|
||||
|
||||
onSearch?.(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-99">
|
||||
<SearchInput bind:value onInput={(v) => handleInput(v)} {placeholder} size="sm" />
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
*
|
||||
* MODELS DISCOVER - LIST
|
||||
*
|
||||
* The discover sidebar column: a debounced search field above a navigable list of
|
||||
* HuggingFace GGUF models, with skeleton rows while loading.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverList** - Sidebar model list
|
||||
*
|
||||
* Renders the discover model list as a navigable column. Each row links to the
|
||||
* model's detail and highlights the active one.
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverListItem** - Single sidebar row
|
||||
*
|
||||
* One model entry in the discover sidebar list, selectable via `onSelect`.
|
||||
*/
|
||||
export { default as ModelsDiscoverListItem } from './ModelsDiscoverListItem.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverListItemSkeleton** - Skeleton sidebar row
|
||||
*
|
||||
* Pulsing placeholder matching a ModelsDiscoverListItem row, shown while the
|
||||
* list is loading.
|
||||
*/
|
||||
export { default as ModelsDiscoverListItemSkeleton } from './ModelsDiscoverListItemSkeleton.svelte';
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
*
|
||||
* MODELS DISCOVER
|
||||
*
|
||||
* Components for the Models Discover view: a sidebar search + list of
|
||||
* HuggingFace GGUF models and a detail view for the selected model, used as the
|
||||
* body of the discovery dialog. The list and detail trees live in their own
|
||||
* subfolders; this barrel re-exports them alongside the shared leaves.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **ModelsDiscover** - Models discover explorer
|
||||
*
|
||||
* The complete discovery layout: a sidebar search + model list on the left and a
|
||||
* detail view for the selected model on the right. Used as the body of the
|
||||
* discovery dialog.
|
||||
*/
|
||||
export { default as ModelsDiscover } from './ModelsDiscover.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverAvatar** - Org avatar for a model row
|
||||
*
|
||||
* Shows the org's avatar image, falling back to a monogram on a stable hue
|
||||
* derived from the org name when the image fails to load. Shared by the list,
|
||||
* the detail header and the model selector rows.
|
||||
*/
|
||||
export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte';
|
||||
|
||||
/**
|
||||
* **ModelsDiscoverDownloadProgressBar** - Thin download progress bar
|
||||
*
|
||||
* Normalizes bytes to a 0..100% bar; can pin to the bottom edge as an overlay.
|
||||
* Shared by the quant chips and the model selector's download rows.
|
||||
*/
|
||||
export { default as ModelsDiscoverDownloadProgressBar } from './ModelsDiscoverDownloadProgressBar.svelte';
|
||||
|
||||
export * from './ModelsDiscoverList';
|
||||
export * from './ModelsDiscoverDetails';
|
||||
@@ -88,7 +88,7 @@
|
||||
<SelectPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
'relative z-[var(--layer-popover,1000000)] max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
'relative z-[var(--layer-popover,1000000)] max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover dark:border-border/20 text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||
className
|
||||
)}
|
||||
data-slot="select-content"
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
<SelectPrimitive.Viewport
|
||||
class={cn(
|
||||
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
|
||||
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1.5'
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -11,20 +11,26 @@
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||
size?: 'sm' | 'default';
|
||||
size?: 'xs' | 'sm' | 'default';
|
||||
variant?: 'default' | 'plain';
|
||||
} = $props();
|
||||
|
||||
// Super small trigger: fits its selected value, for dense inline use.
|
||||
const xsClasses =
|
||||
"group flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-border/30 bg-muted/60 px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground";
|
||||
|
||||
const baseClasses = $derived(
|
||||
variant === 'plain'
|
||||
? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground"
|
||||
: "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"
|
||||
: size === 'xs'
|
||||
? xsClasses
|
||||
: "flex w-fit items-center justify-between gap-2 rounded-md border border-border/30 bg-muted/60 px-3 py-2 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground"
|
||||
);
|
||||
|
||||
const chevronClasses = $derived(
|
||||
variant === 'plain'
|
||||
variant === 'plain' || size === 'xs'
|
||||
? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
|
||||
: 'size-4 opacity-50'
|
||||
: 'size-3.5 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ export * from './path-display.constants';
|
||||
export * from './model-id.constants';
|
||||
export * from './model-loading.constants';
|
||||
export * from './models-discover.constants';
|
||||
export * from './models-discover-download.constants';
|
||||
export * from './model-compatibility.constants';
|
||||
export * from './huggingface.constants';
|
||||
export * from './precision.constants';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Models Discover - download options constants.
|
||||
*
|
||||
* Shared values backing the download-options area of the details pane: the
|
||||
* serve `--spec-type` mapping, the bit-depth buckets, and the standalone
|
||||
* command builder. Kept here (not component-local) so they sit with the rest
|
||||
* of the discover domain constants.
|
||||
*/
|
||||
|
||||
import type { ModelSidecar } from '$lib/constants';
|
||||
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* `--spec-type` value for each draft sidecar; aux sidecars (mmproj, imatrix)
|
||||
* carry none and stay empty.
|
||||
*/
|
||||
export const SPEC_TYPE: Record<ModelSidecar, string> = {
|
||||
[ModelAuxSidecar.IMATRIX]: '',
|
||||
[ModelAuxSidecar.MMPROJ]: '',
|
||||
[ModelDraftSidecar.DFLASH]: 'draft-dflash',
|
||||
[ModelDraftSidecar.DSPARK]: 'draft-dspark',
|
||||
[ModelDraftSidecar.EAGLE3]: 'draft-eagle3',
|
||||
[ModelDraftSidecar.MTP]: 'draft-mtp'
|
||||
};
|
||||
|
||||
/** Bit-depth bucket for files that carry no quant token; rendered as "Other". */
|
||||
export const OTHER_BIT_DEPTH = 99;
|
||||
|
||||
/** Label for the OTHER_BIT_DEPTH bucket. */
|
||||
export const OTHER_BIT_DEPTH_LABEL = 'Other';
|
||||
|
||||
/** Suffix appended after a bit-depth number to label a row, e.g. `4-bit`. */
|
||||
export const BIT_DEPTH_LABEL_SUFFIX = '-bit';
|
||||
|
||||
/**
|
||||
* Fixed tokens of the standalone `llama serve` command preview, so the command
|
||||
* builder and its rendered spans share one source of the CLI spelling.
|
||||
*/
|
||||
export const SERVE_COMMAND = {
|
||||
BIN: 'llama',
|
||||
/** Draft model repo:tag argument (download sidecar / draft weights). */
|
||||
DRAFT_FLAG: '-hfd',
|
||||
/** Main model repo:tag argument. */
|
||||
MODEL_FLAG: '-hf',
|
||||
/** Speculative-decoding type argument for the draft. */
|
||||
SPEC_TYPE_FLAG: '--spec-type',
|
||||
SUBCOMMAND: 'serve'
|
||||
} as const;
|
||||
|
||||
/** Bit depth preferred for the command's default base quant. */
|
||||
export const DEFAULT_BASE_BIT_DEPTH = 4;
|
||||
|
||||
/** Label shown for a draft-sidecar chip whose quant is unknown. */
|
||||
export const DRAFT_FILE_LABEL = 'draft';
|
||||
@@ -6,3 +6,9 @@
|
||||
|
||||
/** llama.app model catalog used as the default model list. Online-only source; the discover feature requires an internet connection anyway. */
|
||||
export const MODELS_DISCOVER_CATALOG_URL = 'https://llama.app/v1/catalog.json';
|
||||
|
||||
/**
|
||||
* Catalog repos fetched in parallel per batch; small on purpose so the HF API
|
||||
* is not hit with the whole catalog at once.
|
||||
*/
|
||||
export const MODELS_DISCOVER_CATALOG_BATCH = 4;
|
||||
|
||||
@@ -69,7 +69,13 @@ export {
|
||||
JsonSchemaType
|
||||
} from './mcp.enums';
|
||||
|
||||
export { ModelAuxSidecar, ModelCapability, ModelDraftSidecar, ModelModality } from './model.enums';
|
||||
export {
|
||||
ModelAuxSidecar,
|
||||
ModelCapability,
|
||||
ModelDraftSidecar,
|
||||
ModelModality,
|
||||
ModelSelectableFileKind
|
||||
} from './model.enums';
|
||||
|
||||
export { ModelDownloadConfirmAction, ModelDownloadStopRequest } from './model.enums';
|
||||
|
||||
|
||||
@@ -36,6 +36,16 @@ export enum ModelAuxSidecar {
|
||||
MMPROJ = 'mmproj'
|
||||
}
|
||||
|
||||
/**
|
||||
* Role of a selectable GGUF in the download options: the main weights, a
|
||||
* speculative-decoding draft sidecar, or an auxiliary sidecar (mmproj).
|
||||
*/
|
||||
export enum ModelSelectableFileKind {
|
||||
AUX = 'aux',
|
||||
DRAFT = 'draft',
|
||||
MAIN = 'main'
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an in-flight download is being stopped, so the terminal `download_failed`
|
||||
* feed event can be attributed: a user pause (resumable) or a user cancel
|
||||
|
||||
@@ -40,6 +40,9 @@ export { mcpStore } from './mcp/index.svelte';
|
||||
// MODELS
|
||||
export { modelsStore } from './models/index.svelte';
|
||||
|
||||
// MODELS DISCOVER (HuggingFace browse state for the discover dialog)
|
||||
export { modelsDiscoverStore } from './models-discover/index.svelte';
|
||||
|
||||
// SERVER
|
||||
export { serverStore } from './server.svelte';
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* modelsDiscoverStore - Models Discover browse state
|
||||
*
|
||||
* Owns the HuggingFace GGUF model list shown in the discover sidebar
|
||||
* (the Discover tab of DialogManageModels). By default the list is the curated catalog set;
|
||||
* search replaces it with matches across all of HuggingFace. Both paths fetch
|
||||
* the same fields, so a row renders the same badges and sizes either way.
|
||||
*/
|
||||
|
||||
import { MODELS_DISCOVER_CATALOG_BATCH } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type {
|
||||
HfCatalogBuild,
|
||||
HfCatalogEntry,
|
||||
HfModelInfo,
|
||||
HfModelSibling
|
||||
} from '$lib/types/huggingface';
|
||||
import { isAuxSidecar } from '$lib/utils';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
/** Min/max GGUF file size (bytes) across the quants of one repo. */
|
||||
export interface ModelsDiscoverSizeRange {
|
||||
max: number;
|
||||
min: number;
|
||||
}
|
||||
|
||||
class ModelsDiscoverStore {
|
||||
error = $state<string | null>(null);
|
||||
models = $state<HfModelInfo[]>([]);
|
||||
/** First model in the list - discover auto-opens this one. */
|
||||
firstModel = $derived(this.models[0] ?? null);
|
||||
|
||||
loading = $state(false);
|
||||
/** True while a search is in flight; only the newest request owns this flag. */
|
||||
searching = $state(false);
|
||||
|
||||
private catalog: HfCatalogEntry[] = [];
|
||||
/** Repo id -> size range, for catalog rows and lazily measured search rows. */
|
||||
private catalogSizeRanges = new SvelteMap<string, ModelsDiscoverSizeRange>();
|
||||
private defaultModels: HfModelInfo[] = [];
|
||||
private fetched = false;
|
||||
private searchRequestId = 0;
|
||||
/** In-flight `sizeRange()` lookups, keyed by repo id. */
|
||||
private sizeRangePending = new Map<string, Promise<ModelsDiscoverSizeRange | undefined>>();
|
||||
|
||||
/**
|
||||
* Cached size range for a repo, without measuring: the synchronous part of
|
||||
* `sizeRange()`, for rendering a row before its measurement resolves.
|
||||
*/
|
||||
cachedSizeRangeFor(modelId: string): ModelsDiscoverSizeRange | undefined {
|
||||
return this.catalogSizeRanges.get(modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog family description for a repo id, or undefined when the repo is
|
||||
* not part of the catalog (e.g. a search result outside the curated list).
|
||||
*/
|
||||
descriptionFor(modelId: string): string | undefined {
|
||||
return this.catalog.find((entry) =>
|
||||
entry.sizes.some((size) => size.builds.some((build) => build.repo === modelId))
|
||||
)?.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the default list from the llama.app catalog, one repo per catalog
|
||||
* size in display order (newest family first). Every repo is fetched by ID
|
||||
* with its file tree, so the rows carry chat-template capabilities, context
|
||||
* length and a real size range - the same data a search result gets.
|
||||
* No-op when already loaded or in flight.
|
||||
*/
|
||||
async fetch(): Promise<void> {
|
||||
if (this.loading || this.fetched) return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const catalog = await HuggingFaceService.getCatalog();
|
||||
|
||||
this.catalog = catalog;
|
||||
|
||||
const builds = this.catalogBuilds();
|
||||
const failedBuilds: HfCatalogBuild[] = [];
|
||||
|
||||
this.defaultModels = [];
|
||||
|
||||
// fetch in small batches: not every repo hits the HF API at once,
|
||||
// and the rows land as they arrive instead of all at the end
|
||||
for (let i = 0; i < builds.length; i += MODELS_DISCOVER_CATALOG_BATCH) {
|
||||
const batch = await Promise.all(
|
||||
builds.slice(i, i + MODELS_DISCOVER_CATALOG_BATCH).map(async (build) => {
|
||||
const [info, tree] = await Promise.all([
|
||||
HuggingFaceService.getDetails(build.repo),
|
||||
HuggingFaceService.getTree(build.repo)
|
||||
]);
|
||||
|
||||
return { build, info, tree };
|
||||
})
|
||||
);
|
||||
|
||||
for (const { build, info, tree } of batch) {
|
||||
if (!info) {
|
||||
failedBuilds.push(build);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
this.catalogSizeRanges.set(build.repo, this.sizeRangeFor(build, tree));
|
||||
|
||||
// the catalog repo id, not the HF response `id`, drives selection
|
||||
this.defaultModels.push({ ...info, id: build.repo, modelId: build.repo } as HfModelInfo);
|
||||
}
|
||||
|
||||
// show what has landed, unless a search owns the list right now
|
||||
if (!this.searching) {
|
||||
this.models = [...this.defaultModels];
|
||||
}
|
||||
}
|
||||
|
||||
// stay incomplete when some repos failed, so the next mount retries
|
||||
// them; succeeded repos are served from the service cache for free
|
||||
this.fetched = failedBuilds.length === 0;
|
||||
} catch (err) {
|
||||
this.error = err instanceof Error ? err.message : 'Failed to fetch models';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the list with search results; an empty query restores the default list. */
|
||||
async search(query: string): Promise<void> {
|
||||
const trimmed = query.trim();
|
||||
const requestId = ++this.searchRequestId;
|
||||
|
||||
if (!trimmed) {
|
||||
this.searching = false;
|
||||
this.models = this.defaultModels;
|
||||
this.error = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.searching = true;
|
||||
|
||||
try {
|
||||
const results = await HuggingFaceService.searchByQuery(trimmed, { limit: 50 });
|
||||
|
||||
if (requestId !== this.searchRequestId) return;
|
||||
|
||||
this.models = results;
|
||||
this.error = null;
|
||||
} catch (err) {
|
||||
if (requestId !== this.searchRequestId) return;
|
||||
|
||||
this.error = err instanceof Error ? err.message : 'Search failed';
|
||||
} finally {
|
||||
if (requestId === this.searchRequestId) this.searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Size range for a repo not measured yet - a search result, or a catalog
|
||||
* repo whose tree came back empty. Fetches the file tree once per repo and
|
||||
* caches it, so remounting a row (scrolling, searching back) is free.
|
||||
*/
|
||||
sizeRange(modelId: string): Promise<ModelsDiscoverSizeRange | undefined> {
|
||||
const cached = this.catalogSizeRanges.get(modelId);
|
||||
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const pending = this.sizeRangePending.get(modelId);
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const request = (async () => {
|
||||
const tree = await HuggingFaceService.getTree(modelId);
|
||||
const range = this.sizeRangeOfMainQuants(tree);
|
||||
|
||||
if (range) this.catalogSizeRanges.set(modelId, range);
|
||||
|
||||
return range;
|
||||
})()
|
||||
.catch(() => undefined)
|
||||
.finally(() => this.sizeRangePending.delete(modelId));
|
||||
|
||||
this.sizeRangePending.set(modelId, request);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes of every quant the catalog lists under this repo, parsed from the
|
||||
* `size` strings when a build carries no `sizeBytes`. Never empty: an
|
||||
* unparsable entry contributes the build's own size.
|
||||
*/
|
||||
private buildSizeBytes(build: HfCatalogBuild): number[] {
|
||||
const sizes = this.catalog
|
||||
.flatMap((entry) => entry.sizes)
|
||||
.flatMap((size) => size.builds.filter((b) => b.repo === build.repo))
|
||||
.map((b) => b.sizeBytes ?? HuggingFaceService.parseSizeBytes(b.size))
|
||||
.filter((bytes): bytes is number => Boolean(bytes) && bytes > 0);
|
||||
|
||||
return sizes.length > 0 ? sizes : [build.sizeBytes ?? 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* One build per catalog size, newest family first (by release date).
|
||||
* Prefers the official ggml-org repo, falling back to the first build so
|
||||
* families published only by other orgs (mistralai, unsloth) still show up.
|
||||
* Returns an empty array when the catalog is empty.
|
||||
*/
|
||||
private catalogBuilds(): HfCatalogBuild[] {
|
||||
return [...this.catalog]
|
||||
.sort((a, b) => b.released.localeCompare(a.released))
|
||||
.flatMap((entry) =>
|
||||
entry.sizes.flatMap((size) => {
|
||||
const build = size.builds.find((b) => b.repo.startsWith('ggml-org/')) ?? size.builds[0];
|
||||
|
||||
return build ? [build] : [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Byte sizes of every non-sidecar quant file in a tree, shards collapsed. */
|
||||
private quantSizesOf(tree: HfModelSibling[]): number[] {
|
||||
return HuggingFaceService.collapseGgufShards(
|
||||
HuggingFaceService.filterByExtension(tree, '.gguf')
|
||||
)
|
||||
.filter((f) => {
|
||||
const { quant, sidecar } = HuggingFaceService.extractQuantMeta(f.path) ?? {};
|
||||
|
||||
return Boolean(quant) && (sidecar === null || sidecar === undefined);
|
||||
})
|
||||
.map((f) => f.size ?? 0)
|
||||
.filter((size) => size > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Size range of one catalog build: every quant in the repo's file tree, plus
|
||||
* the draft sidecars those files carry (mtp, dflash, ...) so the downloaded
|
||||
* model fits within the range. Falls back to the catalog `size` / `sizeBytes`
|
||||
* strings when the tree yielded nothing (partial fetch, sharded-only repo).
|
||||
*/
|
||||
private sizeRangeFor(build: HfCatalogBuild, tree: HfModelSibling[]): ModelsDiscoverSizeRange {
|
||||
const quantSizes = this.quantSizesOf(tree);
|
||||
|
||||
if (quantSizes.length === 0) {
|
||||
const listed = this.buildSizeBytes(build);
|
||||
|
||||
return { max: Math.max(...listed), min: Math.min(...listed) };
|
||||
}
|
||||
|
||||
const draftSizes = tree
|
||||
.filter((f) => {
|
||||
const sidecar = HuggingFaceService.extractQuantMeta(f.path)?.sidecar;
|
||||
|
||||
return sidecar !== null && sidecar !== undefined && !isAuxSidecar(sidecar);
|
||||
})
|
||||
.map((f) => f.size ?? 0)
|
||||
.filter((size) => size > 0);
|
||||
const extra = draftSizes.length > 0 ? Math.max(...draftSizes) : 0;
|
||||
|
||||
return {
|
||||
max: Math.max(...quantSizes) + extra,
|
||||
min: Math.min(...quantSizes) + (draftSizes.length > 0 ? Math.min(...draftSizes) : 0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Size range across the main-model quants of a file tree, draft sidecars
|
||||
* excluded (they only widen the range when a row advertises them).
|
||||
*/
|
||||
private sizeRangeOfMainQuants(tree: HfModelSibling[]): ModelsDiscoverSizeRange | undefined {
|
||||
const sizes = this.quantSizesOf(tree);
|
||||
|
||||
if (sizes.length === 0) return undefined;
|
||||
|
||||
return { max: Math.max(...sizes), min: Math.min(...sizes) };
|
||||
}
|
||||
}
|
||||
|
||||
export const modelsDiscoverStore = new ModelsDiscoverStore();
|
||||
@@ -111,6 +111,14 @@ export type {
|
||||
ModalityCapabilities
|
||||
} from './models';
|
||||
|
||||
// Models discover types
|
||||
export type {
|
||||
ModelBitDepthRow,
|
||||
ModelDownloadEntryState,
|
||||
ModelQuantOption,
|
||||
ModelSelectableFile
|
||||
} from './models-discover';
|
||||
|
||||
// Settings types
|
||||
export type {
|
||||
SettingsConfigValue,
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import type { ModelSelectableFileKind } from '$lib/enums';
|
||||
import type { HfModelSibling } from '$lib/types/huggingface';
|
||||
|
||||
/**
|
||||
* Option of a quant `<select>` in the download-options command builder; the
|
||||
* picks only compose the serve command and are not bound to the quant chips.
|
||||
*/
|
||||
export interface ModelQuantOption {
|
||||
/** Quant token, or the file name when the file carries no quant (e.g. BF16). */
|
||||
label: string;
|
||||
/** Repo-relative file path the option stands for. */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Download state of a single repo entry, injected by the integration layer. */
|
||||
export interface ModelDownloadEntryState {
|
||||
/** Server identifier the entry's actions (pause / resume / cancel / retry) target. */
|
||||
repoWithTag: string;
|
||||
isDownloading: boolean;
|
||||
progress: ModelDownloadProgress | null;
|
||||
isDownloaded: boolean;
|
||||
isPaused: boolean;
|
||||
isFailed: boolean;
|
||||
}
|
||||
|
||||
/** A group of GGUF files sharing one bit-depth bucket. */
|
||||
export type ModelBitDepthRow = { bitDepth: number; files: HfModelSibling[] };
|
||||
|
||||
/** A selectable GGUF, tagged with its role: main weights, draft, or aux (mmproj). */
|
||||
export type ModelSelectableFile = HfModelSibling & { kind: ModelSelectableFileKind };
|
||||
Reference in New Issue
Block a user