mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-11 04:56:56 +02:00
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:
@@ -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>
|
||||
+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 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>
|
||||
+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">
|
||||
<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-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}
|
||||
/>
|
||||
+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-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}
|
||||
+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';
|
||||
+93
@@ -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>
|
||||
+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 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}
|
||||
+15
@@ -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>
|
||||
+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-xs 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-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';
|
||||
+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 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>
|
||||
+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,
|
||||
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>
|
||||
+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>
|
||||
+22
@@ -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) {
|
||||
|
||||
+38
-24
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user