ui : models discover dialog

Add the Models Discover explorer behind a new sidebar action and full-screen
dialog: a searchable HuggingFace GGUF list (org avatars, capability icons,
catalog size ranges) and a detail pane with header, metadata chips, the
sanitized model-card readme and the download area - per-quant action chips
grouped by bit depth, sidecar queuing and a scrollable serve-command preview
with dynamic quant selects. Searchable dropdowns gain sticky headers/footers.

Assisted-by: pi:GLM-5.3-Flash
This commit is contained in:
Aleksander Grygier
2026-09-07 22:10:09 +02:00
parent ca5c3be7f6
commit 6ec02a4c81
35 changed files with 1991 additions and 29 deletions
@@ -0,0 +1,25 @@
<script lang="ts">
import { ModelsDiscover } from '$lib/components/app/models/discover';
import * as Dialog from '$lib/components/ui/dialog';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380!"
style="grid-template-columns: auto 1fr;"
>
<ModelsDiscover />
</Dialog.Content>
</Dialog.Root>
@@ -537,3 +537,13 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
* ```
*/
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
/**
* **DialogModelsDiscover** - full-screen model discovery dialog.
*
* Two-pane layout: searchable model list (Hugging Face + llama.app catalog)
* on the left, model details with download options on the right.
*
* @see ModelsDiscover in $lib/components/app/models/discover
*/
export { default as DialogModelsDiscover } from './DialogModelsDiscover.svelte';
@@ -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:p-4 h-full space-y-1"
>
<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>
@@ -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 px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
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>
@@ -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">
<ModelsDiscoverDetailsHeader
{baseModels}
{details}
{gguf}
{hasReasoning}
{hasTools}
{hasVision}
{licenseTag}
{modelId}
/>
<ModelsDiscoverDetailsDownloadOptions {bitDepthRows} {modelId} />
<ModelsDiscoverDetailsReadme {readme} />
</div>
{/if}
@@ -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-xs 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}
/>
@@ -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>
@@ -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-xs 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-xs 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-xs 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}
@@ -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>
@@ -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, '');
}
@@ -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';
@@ -0,0 +1,93 @@
<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 { 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 px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-muted"
href={HuggingFaceService.getModelUrl(modelId)}
rel="noopener noreferrer"
target="_blank"
>
<img alt="" class="h-3.5 w-3.5" src="/recommended-mcp/huggingface.ico" />
View on Hugging Face
<ExternalLink class={ICON_CLASS_SM} />
</a>
</div>
<ModelsDiscoverDetailsHfHubStats {details} />
<ModelsDiscoverDetailsMetadata {details} {gguf} {licenseTag} {modelId} />
</header>
@@ -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>
@@ -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 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-muted"
onclick={() => (chatTemplateOpen = true)}
type="button"
>
<MessageSquareCode class="h-3 w-3" />
Chat template
</button>
{/if}
{#if licenseTag}
<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}
@@ -0,0 +1,15 @@
<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 text-xs">
<span class="px-2.5 py-1 text-muted-foreground">{label}</span>
<span class="px-2.5 py-1 font-medium">{value}</span>
</span>
@@ -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-xs dark:border-border/20"
>
<MarkdownContent allowHtml class="prose-sm max-w-none" content={readme} />
</section>
{/if}
@@ -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-xs 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';
@@ -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 p-2">
{#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>
@@ -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,
type ModelSidecar
} 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 } from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport, isAuxSidecar, orgOf } from '$lib/utils';
import { SvelteSet } 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<ModelSidecar[]>(() => {
const set = new SvelteSet<ModelSidecar>();
for (const sibling of model.siblings ?? []) {
const sidecar = HuggingFaceService.extractQuantMeta(sibling.rfilename)?.sidecar;
if (sidecar && !isAuxSidecar(sidecar)) set.add(sidecar);
}
return [...set];
});
// 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>
@@ -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>
@@ -0,0 +1,22 @@
<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...', value = $bindable('') }: Props = $props();
function handleInput(next: string) {
value = next;
onSearch?.(value);
}
</script>
<div class="sticky top-0 z-99 p-2">
<SearchInput bind:value onInput={(v) => handleInput(v)} {placeholder} />
</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';
@@ -5,6 +5,7 @@
import {
ActionIcon,
DialogConversationRename,
DialogModelsDiscover,
DialogSettingsChat,
Logo,
SidebarNavigationActions,
@@ -93,6 +94,7 @@
let renameDialogOpen = $state(false);
let settingsDialogOpen = $state(false);
let modelsDiscoverOpen = $state(false);
let renameTargetConversationId = $state<string | null>(null);
let renameDraft = $state('');
let renameOriginalTitle = $state('');
@@ -389,6 +391,7 @@
bind:searchQuery
class="px-2"
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
onDiscoverModelsClick={() => (modelsDiscoverOpen = true)}
onNewChat={() => {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
@@ -452,6 +455,8 @@
<DialogSettingsChat bind:open={settingsDialogOpen} />
<DialogModelsDiscover bind:open={modelsDiscoverOpen} />
<style>
aside {
@media (max-width: 768px) {
@@ -12,7 +12,7 @@
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { SidebarAction, TooltipSide } from '$lib/enums';
import { conversationsStore, deviceStore } from '$lib/stores';
import { conversationsStore, deviceStore, serverStore, settingsStore } from '$lib/stores';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
import { circIn } from 'svelte/easing';
@@ -26,6 +26,7 @@
onSearchDeactivated?: () => void;
onSearchClick?: () => void;
onNewChat?: () => void;
onDiscoverModelsClick?: () => void;
onSettingsClick?: () => void;
}
@@ -33,6 +34,7 @@
class: className,
isExpandedMode = false,
isSearchModeActive = $bindable(false),
onDiscoverModelsClick,
onNewChat,
onSearchClick,
onSearchDeactivated,
@@ -46,6 +48,14 @@
const isOnMobile = $derived(deviceStore.isMobile);
// Discover models is opt-in (General settings) and needs the router's
// download endpoints; hide it when either gate is closed
const actionsItems = $derived(
serverStore.isRouterMode && settingsStore.config.enableDiscoverModels
? SIDEBAR_ACTIONS_ITEMS
: SIDEBAR_ACTIONS_ITEMS.filter((item) => item.action !== SidebarAction.DISCOVER_MODELS)
);
$effect(() => {
if (isSearchModeActive && searchInputRef) {
searchInputRef.focus();
@@ -57,7 +67,7 @@
setTimeout(() => {
initialized = true;
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * actionsItems.length);
});
function handleSearchModeDeactivate() {
@@ -107,7 +117,7 @@
? 'hidden pointer-events-none'
: ''}"
>
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{#each actionsItems as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
@@ -117,16 +127,18 @@
onNewChat?.();
void conversationsStore.openNewChat();
}
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
: item.action === SidebarAction.DISCOVER_MODELS
? () => onDiscoverModelsClick?.()
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -164,7 +176,7 @@
</div>
{:else}
<div class="{className} flex-col gap-1 hidden md:flex">
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{#each actionsItems as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick =
@@ -173,16 +185,18 @@
onNewChat?.();
void conversationsStore.openNewChat();
}
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
: item.action === SidebarAction.DISCOVER_MODELS
? () => onDiscoverModelsClick?.()
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -11,18 +11,24 @@
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-input bg-transparent px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors 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:bg-input/30 dark:hover:bg-input/50 [&_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-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"
);
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'
);
+1
View File
@@ -46,6 +46,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';
@@ -26,6 +26,7 @@ export const SETTINGS_KEYS = {
DYNATEMP_EXPONENT: 'dynatemp_exponent',
DYNATEMP_RANGE: 'dynatemp_range',
ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
ENABLE_DISCOVER_MODELS: 'enableDiscoverModels',
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
FREQUENCY_PENALTY: 'frequency_penalty',
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
@@ -125,6 +125,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Enable "Continue" button',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: true,
help: 'Show the Discover Models sidebar action to browse and download HuggingFace GGUF models. Only available in router mode.',
key: SETTINGS_KEYS.ENABLE_DISCOVER_MODELS,
label: 'Enable Discover Models',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: true,
help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
+6 -1
View File
@@ -1,4 +1,4 @@
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import { Package, PackageSearch, Search, Settings, SquarePen } from '@lucide/svelte';
import { SidebarAction, ToolSource } from '$lib/enums';
import type { DesktopIconStripItem } from '$lib/types';
@@ -64,6 +64,11 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
tooltip: 'New chat'
},
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
action: SidebarAction.DISCOVER_MODELS,
icon: PackageSearch,
tooltip: 'Discover models'
},
{
action: SidebarAction.SETTINGS,
icon: Settings,
+1
View File
@@ -23,6 +23,7 @@ export enum ScrollCarouselVariant {
* Sidebar icon strip actions handled directly by the sidebar.
*/
export enum SidebarAction {
DISCOVER_MODELS = 'discover-models',
NEW_CHAT = 'new-chat',
SETTINGS = 'settings'
}
+1 -1
View File
@@ -105,7 +105,7 @@ export {
} from './modality-file-validation';
// Model name utilities
export { normalizeModelName, isValidModelName } from './model-names';
export { isValidModelName, normalizeModelName, orgOf } from './model-names';
// Sidecar token utilities
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';