mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-17 20:31:47 +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';
|
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 {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
DialogConversationRename,
|
DialogConversationRename,
|
||||||
|
DialogModelsDiscover,
|
||||||
DialogSettingsChat,
|
DialogSettingsChat,
|
||||||
Logo,
|
Logo,
|
||||||
SidebarNavigationActions,
|
SidebarNavigationActions,
|
||||||
@@ -93,6 +94,7 @@
|
|||||||
|
|
||||||
let renameDialogOpen = $state(false);
|
let renameDialogOpen = $state(false);
|
||||||
let settingsDialogOpen = $state(false);
|
let settingsDialogOpen = $state(false);
|
||||||
|
let modelsDiscoverOpen = $state(false);
|
||||||
let renameTargetConversationId = $state<string | null>(null);
|
let renameTargetConversationId = $state<string | null>(null);
|
||||||
let renameDraft = $state('');
|
let renameDraft = $state('');
|
||||||
let renameOriginalTitle = $state('');
|
let renameOriginalTitle = $state('');
|
||||||
@@ -389,6 +391,7 @@
|
|||||||
bind:searchQuery
|
bind:searchQuery
|
||||||
class="px-2"
|
class="px-2"
|
||||||
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
|
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
|
||||||
|
onDiscoverModelsClick={() => (modelsDiscoverOpen = true)}
|
||||||
onNewChat={() => {
|
onNewChat={() => {
|
||||||
if (deviceStore.isMobile) {
|
if (deviceStore.isMobile) {
|
||||||
scheduleMobileCollapse();
|
scheduleMobileCollapse();
|
||||||
@@ -452,6 +455,8 @@
|
|||||||
|
|
||||||
<DialogSettingsChat bind:open={settingsDialogOpen} />
|
<DialogSettingsChat bind:open={settingsDialogOpen} />
|
||||||
|
|
||||||
|
<DialogModelsDiscover bind:open={modelsDiscoverOpen} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
aside {
|
aside {
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
+38
-24
@@ -12,7 +12,7 @@
|
|||||||
SIDEBAR_ACTIONS_ITEMS
|
SIDEBAR_ACTIONS_ITEMS
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { SidebarAction, TooltipSide } from '$lib/enums';
|
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 type { Component } from 'svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { circIn } from 'svelte/easing';
|
import { circIn } from 'svelte/easing';
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
onSearchDeactivated?: () => void;
|
onSearchDeactivated?: () => void;
|
||||||
onSearchClick?: () => void;
|
onSearchClick?: () => void;
|
||||||
onNewChat?: () => void;
|
onNewChat?: () => void;
|
||||||
|
onDiscoverModelsClick?: () => void;
|
||||||
onSettingsClick?: () => void;
|
onSettingsClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
class: className,
|
class: className,
|
||||||
isExpandedMode = false,
|
isExpandedMode = false,
|
||||||
isSearchModeActive = $bindable(false),
|
isSearchModeActive = $bindable(false),
|
||||||
|
onDiscoverModelsClick,
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSearchClick,
|
onSearchClick,
|
||||||
onSearchDeactivated,
|
onSearchDeactivated,
|
||||||
@@ -46,6 +48,14 @@
|
|||||||
|
|
||||||
const isOnMobile = $derived(deviceStore.isMobile);
|
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(() => {
|
$effect(() => {
|
||||||
if (isSearchModeActive && searchInputRef) {
|
if (isSearchModeActive && searchInputRef) {
|
||||||
searchInputRef.focus();
|
searchInputRef.focus();
|
||||||
@@ -57,7 +67,7 @@
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
initialized = true;
|
initialized = true;
|
||||||
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * SIDEBAR_ACTIONS_ITEMS.length);
|
}, ICON_STRIP_TRANSITION_DELAY_MULTIPLIER * actionsItems.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSearchModeDeactivate() {
|
function handleSearchModeDeactivate() {
|
||||||
@@ -107,7 +117,7 @@
|
|||||||
? 'hidden pointer-events-none'
|
? '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 isActive = isItemActive(item)}
|
||||||
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
||||||
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
|
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
|
||||||
@@ -117,16 +127,18 @@
|
|||||||
onNewChat?.();
|
onNewChat?.();
|
||||||
void conversationsStore.openNewChat();
|
void conversationsStore.openNewChat();
|
||||||
}
|
}
|
||||||
: item.action === SidebarAction.SETTINGS
|
: item.action === SidebarAction.DISCOVER_MODELS
|
||||||
? () => onSettingsClick?.()
|
? () => onDiscoverModelsClick?.()
|
||||||
: item.route
|
: item.action === SidebarAction.SETTINGS
|
||||||
? () => {
|
? () => onSettingsClick?.()
|
||||||
onNewChat?.();
|
: item.route
|
||||||
goto(item.route!);
|
? () => {
|
||||||
}
|
onNewChat?.();
|
||||||
: isSearchOnMobile
|
goto(item.route!);
|
||||||
? undefined
|
}
|
||||||
: onSearchClick}
|
: isSearchOnMobile
|
||||||
|
? undefined
|
||||||
|
: onSearchClick}
|
||||||
{@const itemTransition = {
|
{@const itemTransition = {
|
||||||
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
||||||
duration: ICON_STRIP_TRANSITION_DURATION,
|
duration: ICON_STRIP_TRANSITION_DURATION,
|
||||||
@@ -164,7 +176,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="{className} flex-col gap-1 hidden md:flex">
|
<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 isActive = isItemActive(item)}
|
||||||
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
|
||||||
{@const itemOnClick =
|
{@const itemOnClick =
|
||||||
@@ -173,16 +185,18 @@
|
|||||||
onNewChat?.();
|
onNewChat?.();
|
||||||
void conversationsStore.openNewChat();
|
void conversationsStore.openNewChat();
|
||||||
}
|
}
|
||||||
: item.action === SidebarAction.SETTINGS
|
: item.action === SidebarAction.DISCOVER_MODELS
|
||||||
? () => onSettingsClick?.()
|
? () => onDiscoverModelsClick?.()
|
||||||
: item.route
|
: item.action === SidebarAction.SETTINGS
|
||||||
? () => {
|
? () => onSettingsClick?.()
|
||||||
onNewChat?.();
|
: item.route
|
||||||
goto(item.route!);
|
? () => {
|
||||||
}
|
onNewChat?.();
|
||||||
: isSearchOnMobile
|
goto(item.route!);
|
||||||
? undefined
|
}
|
||||||
: onSearchClick}
|
: isSearchOnMobile
|
||||||
|
? undefined
|
||||||
|
: onSearchClick}
|
||||||
{@const itemTransition = {
|
{@const itemTransition = {
|
||||||
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
|
||||||
duration: ICON_STRIP_TRANSITION_DURATION,
|
duration: ICON_STRIP_TRANSITION_DURATION,
|
||||||
|
|||||||
@@ -11,18 +11,24 @@
|
|||||||
variant = 'default',
|
variant = 'default',
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
}: WithoutChild<SelectPrimitive.TriggerProps> & {
|
||||||
size?: 'sm' | 'default';
|
size?: 'xs' | 'sm' | 'default';
|
||||||
variant?: 'default' | 'plain';
|
variant?: 'default' | 'plain';
|
||||||
} = $props();
|
} = $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(
|
const baseClasses = $derived(
|
||||||
variant === 'plain'
|
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"
|
? "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(
|
const chevronClasses = $derived(
|
||||||
variant === 'plain'
|
variant === 'plain' || size === 'xs'
|
||||||
? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
|
? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180'
|
||||||
: 'size-4 opacity-50'
|
: 'size-4 opacity-50'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export * from './path-display.constants';
|
|||||||
export * from './model-id.constants';
|
export * from './model-id.constants';
|
||||||
export * from './model-loading.constants';
|
export * from './model-loading.constants';
|
||||||
export * from './models-discover.constants';
|
export * from './models-discover.constants';
|
||||||
|
export * from './models-discover-download.constants';
|
||||||
export * from './model-compatibility.constants';
|
export * from './model-compatibility.constants';
|
||||||
export * from './huggingface.constants';
|
export * from './huggingface.constants';
|
||||||
export * from './precision.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_EXPONENT: 'dynatemp_exponent',
|
||||||
DYNATEMP_RANGE: 'dynatemp_range',
|
DYNATEMP_RANGE: 'dynatemp_range',
|
||||||
ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
|
ENABLE_CONTINUE_GENERATION: 'enableContinueGeneration',
|
||||||
|
ENABLE_DISCOVER_MODELS: 'enableDiscoverModels',
|
||||||
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
|
EXCLUDE_REASONING_FROM_CONTEXT: 'excludeReasoningFromContext',
|
||||||
FREQUENCY_PENALTY: 'frequency_penalty',
|
FREQUENCY_PENALTY: 'frequency_penalty',
|
||||||
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
|
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
|
|||||||
label: 'Enable "Continue" button',
|
label: 'Enable "Continue" button',
|
||||||
type: SettingsFieldType.CHECKBOX
|
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,
|
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.',
|
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 { SidebarAction, ToolSource } from '$lib/enums';
|
||||||
import type { DesktopIconStripItem } from '$lib/types';
|
import type { DesktopIconStripItem } from '$lib/types';
|
||||||
|
|
||||||
@@ -64,6 +64,11 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
|
|||||||
tooltip: 'New chat'
|
tooltip: 'New chat'
|
||||||
},
|
},
|
||||||
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
|
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
|
||||||
|
{
|
||||||
|
action: SidebarAction.DISCOVER_MODELS,
|
||||||
|
icon: PackageSearch,
|
||||||
|
tooltip: 'Discover models'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: SidebarAction.SETTINGS,
|
action: SidebarAction.SETTINGS,
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export enum ScrollCarouselVariant {
|
|||||||
* Sidebar icon strip actions handled directly by the sidebar.
|
* Sidebar icon strip actions handled directly by the sidebar.
|
||||||
*/
|
*/
|
||||||
export enum SidebarAction {
|
export enum SidebarAction {
|
||||||
|
DISCOVER_MODELS = 'discover-models',
|
||||||
NEW_CHAT = 'new-chat',
|
NEW_CHAT = 'new-chat',
|
||||||
SETTINGS = 'settings'
|
SETTINGS = 'settings'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export {
|
|||||||
} from './modality-file-validation';
|
} from './modality-file-validation';
|
||||||
|
|
||||||
// Model name utilities
|
// Model name utilities
|
||||||
export { normalizeModelName, isValidModelName } from './model-names';
|
export { isValidModelName, normalizeModelName, orgOf } from './model-names';
|
||||||
|
|
||||||
// Sidecar token utilities
|
// Sidecar token utilities
|
||||||
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
|
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
|
||||||
|
|||||||
Reference in New Issue
Block a user