feat: WIP

This commit is contained in:
Aleksander Grygier
2026-08-24 15:40:13 +02:00
parent 44be2a6db8
commit a3d04fa1e8
24 changed files with 301 additions and 138 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import { Boxes } from '@lucide/svelte';
import { SearchInput } from '$lib/components/app';
import { ModelsHubList, ModelsHubModelDetails } from '$lib/components/app/models-hub';
import { ModelsDiscoverList, ModelsDiscoverDetails } from '$lib/components/app/models/discover';
import * as Dialog from '$lib/components/ui/dialog';
import { modelsHubStore } from '$lib/stores';
import { untrack } from 'svelte';
@@ -59,11 +60,14 @@
class="flex flex-col md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!"
>
<Dialog.Header>
<Dialog.Title>Discover Models</Dialog.Title>
<Dialog.Title class="flex items-center gap-2">
<Boxes class="h-4 w-4" />
Discover Models
</Dialog.Title>
</Dialog.Header>
<div class="flex min-h-0 flex-1">
<aside class="flex w-72 shrink-0 flex-col border-r border-border/40">
<aside class="flex w-88 shrink-0 flex-col border-r border-border/40">
<div class="p-2">
<SearchInput
bind:value={searchQuery}
@@ -80,7 +84,7 @@
{:else if modelsHubStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsHubList
<ModelsDiscoverList
models={modelsHubStore.models}
activeId={selectedId}
showBaseModelAvatar
@@ -92,7 +96,7 @@
<main class="min-w-0 flex-1 overflow-y-auto">
{#if selectedId}
<ModelsHubModelDetails modelId={selectedId} />
<ModelsDiscoverDetails modelId={selectedId} />
{/if}
</main>
</div>
@@ -27,14 +27,14 @@ export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte
export { default as DialogMcpServers } from './DialogMcpServers.svelte';
/**
* **DialogModelsHub** - Models Hub discovery dialog
* **DialogModelsDiscover** - Models Hub discovery dialog
*
* Two-pane HuggingFace GGUF browser in a modal dialog: a sidebar model list
* (ModelsHubList) and a detail view (ModelsHubModelDetails). Always opens the
* (ModelsDiscoverList) and a detail view (ModelsDiscoverDetails). Always opens the
* first model. Used for discovery and downloading; the `/models-hub` route is
* reserved for model management.
*/
export { default as DialogModelsHub } from './DialogModelsHub.svelte';
export { default as DialogModelsDiscover } from './DialogModelsDiscover.svelte';
/**
* **DialogSettingsChat** - Chat settings shown in a modal dialog
+1 -1
View File
@@ -8,6 +8,6 @@ export * from './mcp';
export * from './misc';
export * from './settings';
export * from './models';
export * from './models-hub';
export * from './models/discover';
export * from './navigation';
export * from './server';
@@ -1,104 +0,0 @@
<script lang="ts">
import { Download, Eye, Heart } from '@lucide/svelte';
import { HuggingFaceService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
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 { model, active = false, showBaseModelAvatar = false, onSelect }: Props = $props();
let org = $derived(model.id.split('/')[0] ?? model.id);
let name = $derived(model.id.split('/')[1] ?? model.id);
let params = $derived(HuggingFaceService.parseParamCount(model.id));
let avatarError = $state(false);
// 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;
const base = HuggingFaceService.getBaseModels(model)[0];
return base?.split('/')[0] || org;
});
// Vision = a multimodal pipeline tag or an mmproj projector in the repo.
let hasVision = $derived(
model.pipeline_tag === 'image-text-to-text' ||
Boolean(model.siblings?.some((s) => s.rfilename.toLowerCase().includes('mmproj')))
);
// Monogram fallback: avatar org initial on a hue derived from its name, so
// each org gets a stable distinct color.
let hue = $derived.by(() => {
let h = 0;
for (let i = 0; i < avatarOrg.length; i++) h = (h * 31 + avatarOrg.charCodeAt(i)) >>> 0;
return h % 360;
});
</script>
<li>
<button
type="button"
onclick={() => onSelect?.(model.id)}
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'}"
>
{#if avatarError}
<span
class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-sm font-semibold text-white"
style="background-color: hsl({hue} 60% 45%)"
aria-hidden="true"
>
{avatarOrg.charAt(0).toUpperCase()}
</span>
{:else}
<img
src={HuggingFaceService.getAvatarUrl(avatarOrg)}
onerror={() => (avatarError = true)}
class="mt-0.5 h-9 w-9 shrink-0 rounded-md bg-gray-200 p-0.5"
alt=""
loading="lazy"
/>
{/if}
<span class="min-w-0 flex-1">
<span class="flex items-center justify-between gap-2">
<span class="truncate text-sm font-medium">{name}</span>
{#if hasVision}
<Eye class="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-label="Vision" />
{/if}
</span>
<span class="mt-0.5 block truncate text-xs text-muted-foreground">
{org}
{#if model.lastModified}
· {HuggingFaceService.formatRelativeTime(model.lastModified)}
{/if}
</span>
<span class="mt-1 flex items-center gap-2.5 text-xs text-muted-foreground">
{#if params}
<span class="font-medium tabular-nums">{params}</span>
{/if}
<span class="flex items-center gap-1">
<Download class="h-3 w-3" />
{HuggingFaceService.formatDownloads(model.downloads)}
</span>
<span class="flex items-center gap-1">
<Heart class="h-3 w-3" />
{HuggingFaceService.formatLikes(model.likes)}
</span>
</span>
</span>
</button>
</li>
@@ -1,5 +1,6 @@
<script lang="ts">
import { Image, Lightbulb, Mic, Video } from '@lucide/svelte';
import { type DraftVariant } from '$lib/constants';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ModelsService } from '$lib/services/models.service';
@@ -17,12 +18,14 @@
tags?: string[];
modalities?: ModelModalities;
supportsThinking?: boolean;
draftVariants?: DraftVariant[];
class?: string;
}
let {
aliases,
class: className = '',
draftVariants,
hideOrgName = false,
hideQuantization,
hideTags,
@@ -53,6 +56,7 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
let uniqueDraftVariants = $derived([...new Set(draftVariants ?? [])]);
let hasModalityIcons = $derived(
supportsThinking || modalities?.vision || modalities?.video || modalities?.audio
);
@@ -76,6 +80,12 @@
</span>
{/if}
{#each uniqueDraftVariants as variant (variant)}
<span class={variantBadgeClass} title={`${variant.toUpperCase()} draft model available`}>
{variant}
</span>
{/each}
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
@@ -0,0 +1,84 @@
<script lang="ts">
import { HuggingFaceService } from '$lib/services';
import * as Tooltip from '$lib/components/ui/tooltip';
interface Props {
/** Org whose avatar is shown (may differ from the repo's org for base models). */
org: string;
/** Repo's own org, shown as a small corner badge when provided. */
quantOrg?: string;
}
let { org, quantOrg }: Props = $props();
let avatarError = $state(false);
let quantError = $state(false);
// Monogram fallback: org initial on a hue derived from its name, so each org
// gets a stable distinct color.
let hue = $derived.by(() => {
let h = 0;
for (let i = 0; i < org.length; i++) h = (h * 31 + org.charCodeAt(i)) >>> 0;
return h % 360;
});
let quantHue = $derived.by(() => {
const name = quantOrg ?? '';
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return h % 360;
});
</script>
<span class="relative mt-0.5 inline-flex shrink-0">
{#if avatarError}
<span
class="flex h-9 w-9 items-center justify-center rounded-md text-sm font-semibold text-white"
style="background-color: hsl({hue} 60% 45%)"
aria-hidden="true"
>
{org.charAt(0).toUpperCase()}
</span>
{:else}
<img
src={HuggingFaceService.getAvatarUrl(org)}
onerror={() => (avatarError = true)}
class="h-9 w-9 rounded-md bg-gray-200 p-0.5"
alt=""
loading="lazy"
/>
{/if}
{#if quantOrg}
<Tooltip.Root>
<Tooltip.Trigger
class="absolute -bottom-0.75 -right-0.75 h-4.25 w-4.25 overflow-hidden rounded-full border border-background"
>
{#if quantError}
<span
class="flex h-full w-full items-center justify-center rounded-full text-[8px] font-semibold text-white"
style="background-color: hsl({quantHue} 60% 45%)"
aria-hidden="true"
>
{quantOrg.charAt(0).toUpperCase()}
</span>
{:else}
<img
src={HuggingFaceService.getAvatarUrl(quantOrg)}
onerror={() => (quantError = true)}
class="h-full w-full rounded-full bg-gray-200"
alt=""
loading="lazy"
/>
{/if}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{quantOrg}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
@@ -0,0 +1,74 @@
<script lang="ts">
import { Download, Heart } from '@lucide/svelte';
import { type DraftVariant } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import { formatParameters } from '$lib/utils';
import type { ModelModalities } from '$lib/types/models';
import type { HfModelInfo } from '$lib/types/huggingface';
import ModelId from '../ModelId.svelte';
interface Props {
model: HfModelInfo;
}
let { model }: Props = $props();
let contextLength = $derived(model.gguf?.context_length);
// 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 =
['image-text-to-text', 'image-to-text', 'text-to-image', 'image-to-video'].includes(tag) ||
Boolean(model.siblings?.some((s) => s.rfilename.toLowerCase().includes('mmproj')));
const audio = [
'audio-classification',
'audio-to-audio',
'automatic-speech-recognition',
'text-to-speech',
'voice-activity-detection'
].includes(tag);
const video = ['text-to-video', 'image-to-video', 'video-to-video'].includes(tag);
return { vision, audio, video };
});
// 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 draftVariants = $derived.by<DraftVariant[]>(() => {
const set = new Set<DraftVariant>();
for (const sibling of model.siblings ?? []) {
const variant = HuggingFaceService.extractQuantMeta(sibling.rfilename)?.variant;
if (variant && variant !== 'mmproj') set.add(variant);
}
return [...set];
});
</script>
<span class="min-w-0 flex-1">
<ModelId modelId={model.id} hideOrgName {modalities} {draftVariants} class="min-w-0" />
<span class="mt-0.5 block truncate text-xs text-muted-foreground">
<span class="inline-flex items-center gap-1">
<Download class="h-3 w-3" />
{HuggingFaceService.formatDownloads(model.downloads)}
</span>
<span class="ml-2.5 inline-flex items-center gap-1">
<Heart class="h-3 w-3" />
{HuggingFaceService.formatLikes(model.likes)}
</span>
{#if contextLength}
<span class="ml-2.5 inline-flex items-center gap-1">
{formatParameters(contextLength)} ctx
</span>
{/if}
</span>
</span>
@@ -0,0 +1,42 @@
<script lang="ts">
import { HuggingFaceService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverInfo from './ModelsDiscoverInfo.svelte';
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 { model, active = false, showBaseModelAvatar = false, onSelect }: Props = $props();
let org = $derived(model.id.split('/')[0] ?? 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;
const base = HuggingFaceService.getBaseModels(model)[0];
return base?.split('/')[0] || org;
});
</script>
<li>
<button
type="button"
onclick={() => onSelect?.(model.id)}
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'}"
>
<ModelsDiscoverAvatar org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
<ModelsDiscoverInfo {model} />
</button>
</li>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HfModelInfo } from '$lib/types/huggingface';
import ModelsHubListItem from './ModelsHubListItem.svelte';
import ModelsDiscoverItem from './ModelsDiscoverItem.svelte';
interface Props {
models: HfModelInfo[];
@@ -15,6 +15,6 @@
<ul class="space-y-0.5 p-2">
{#each models as model (model.id)}
<ModelsHubListItem {model} active={model.id === activeId} {showBaseModelAvatar} {onSelect} />
<ModelsDiscoverItem {model} active={model.id === activeId} {showBaseModelAvatar} {onSelect} />
{/each}
</ul>
@@ -8,27 +8,43 @@
*/
/**
* **ModelsHubList** - Sidebar model list
* **ModelsDiscoverList** - Sidebar model list
*
* Renders the hub's model list as a navigable column. Each row links to the
* model's detail route and highlights the active one.
*/
export { default as ModelsHubList } from './ModelsHubList.svelte';
export { default as ModelsDiscoverList } from './ModelsDiscoverList.svelte';
/**
* **ModelsHubListItem** - Single sidebar row
* **ModelsDiscoverItem** - Single sidebar row
*
* One model entry in the sidebar list. Links to `/models-hub/[org]/[model]`.
*/
export { default as ModelsHubListItem } from './ModelsHubListItem.svelte';
export { default as ModelsDiscoverItem } from './ModelsDiscoverItem.svelte';
/**
* **ModelsHubModelDetails** - Model detail view
* **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.
*/
export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte';
/**
* **ModelsDiscoverInfo** - Model name + metadata for a row
*
* Renders the model name via ModelId and the remaining metadata (org, last
* modified, params, downloads, likes, vision) below it.
*/
export { default as ModelsDiscoverInfo } from './ModelsDiscoverInfo.svelte';
/**
* **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 ModelsHubModelDetails } from './ModelsHubModelDetails.svelte';
export { default as ModelsDiscoverDetails } from './ModelsDiscoverDetails.svelte';
/**
* **DialogModelDownload** - Download confirmation / progress dialog
@@ -42,7 +42,7 @@
* />
* ```
*/
export { default as ModelsSelectorDropdown } from './ModelsSelector/ModelsSelectorDropdown.svelte';
export { default as ModelsSelectorDropdown } from './selector/ModelsSelectorDropdown.svelte';
/**
* **ModelsSelectorList** - Grouped model options list
@@ -54,7 +54,7 @@ export { default as ModelsSelectorDropdown } from './ModelsSelector/ModelsSelect
* Accepts an optional `renderOption` snippet to customize how each option is
* rendered (e.g., to add keyboard navigation or highlighting).
*/
export { default as ModelsSelectorList } from './ModelsSelector/ModelsSelectorList.svelte';
export { default as ModelsSelectorList } from './selector/ModelsSelectorList.svelte';
/**
* **ModelsSelectorOption** - Single model option row
@@ -63,7 +63,7 @@ export { default as ModelsSelectorList } from './ModelsSelector/ModelsSelectorLi
* load/unload actions, status indicators, and an info button.
* Used inside ModelsSelectorList or directly in custom render snippets.
*/
export { default as ModelsSelectorOption } from './ModelsSelector/ModelsSelectorOption.svelte';
export { default as ModelsSelectorOption } from './selector/ModelsSelectorOption.svelte';
/**
* **ModelsSelectorSheet** - Mobile model selection sheet
@@ -72,7 +72,7 @@ export { default as ModelsSelectorOption } from './ModelsSelector/ModelsSelector
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
* instead of DropdownMenu.
*/
export { default as ModelsSelectorSheet } from './ModelsSelector/ModelsSelectorSheet.svelte';
export { default as ModelsSelectorSheet } from './selector/ModelsSelectorSheet.svelte';
/** * **ModelBadge** - Model name display badge
*
@@ -5,7 +5,7 @@
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DialogModelsHub,
DialogModelsDiscover,
DropdownMenuSearchable,
ModelId,
ModelsSelectorList,
@@ -429,4 +429,4 @@
/>
{/if}
<DialogModelsHub bind:open={modelsHubOpen} />
<DialogModelsDiscover bind:open={modelsHubOpen} />
+1
View File
@@ -44,6 +44,7 @@ export * from './message-export.constants';
export * from './path-display.constants';
export * from './model-id.constants';
export * from './model-loading.constants';
export * from './models-discover.constants';
export * from './precision.constants';
export * from './pwa.constants';
export * from './routes.constants';
@@ -0,0 +1,17 @@
/**
* Curated default models shown in the Discover Models sidebar, in display order.
*/
export const CURATED_MODEL_IDS = [
'ggml-org/Qwen3.8-27B-GGUF',
'ggml-org/DeepSeek-V4-Flash-0731-GGUF',
'ggml-org/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-GGUF',
'ggml-org/Qwen3.6-35B-A3B-GGUF',
'ggml-org/gemma-4-31B-it-GGUF',
'ggml-org/gemma-4-26B-A4B-it-GGUF',
'ggml-org/gemma-4-12B-it-GGUF',
'ggml-org/Qwen3.5-0.8B-GGUF',
'ggml-org/gemma-4-E2B-it-GGUF',
'ggml-org/gemma-4-E4B-it-GGUF',
'ggml-org/gpt-oss-120b-GGUF',
'ggml-org/gpt-oss-20b-GGUF',
];
@@ -2,13 +2,14 @@
* modelsHubStore - Model Hub browse state
*
* Owns the HuggingFace GGUF model list shown in the hub sidebar
* (DialogModelsHub). The hub has no "nothing selected" screen: it always opens
* (DialogModelsDiscover). The hub has no "nothing selected" screen: it always opens
* a model, so `firstModel` drives the initial selection. By default the list
* shows the official ggml-org GGUF models sorted by popularity (downloads);
* shows a curated set of official ggml-org GGUF models in a fixed display order;
* search replaces the list with matching models across all of HuggingFace.
* Detail data is loaded by ModelsHubModelDetails, not here.
* Detail data is loaded by ModelsDiscoverDetails, not here.
*/
import { CURATED_MODEL_IDS } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { HfModelInfo } from '$lib/types/huggingface';
@@ -28,8 +29,8 @@ class ModelsHubStore {
private searchRequestId = 0;
/**
* Fetch the default list: official ggml-org GGUF models sorted by popularity
* (downloads). No-op when already loaded or in flight.
* Fetch the default list: the curated ggml-org models in display order.
* No-op when already loaded or in flight.
*/
async fetch(): Promise<void> {
if (this.loading || this.fetched) return;
@@ -45,8 +46,24 @@ class ModelsHubStore {
full: true
});
// `models-moved` is a redirect placeholder, not a browsable model
this.defaultModels = results.filter((m) => m.id !== 'ggml-org/models-moved');
const byId = new Map(results.map((m) => [m.id, m]));
// Keep only the curated models, in the curated display order.
const curated = CURATED_MODEL_IDS.flatMap((id) => {
const model = byId.get(id);
return model ? [model] : [];
});
// The list endpoint omits gguf metadata (context length, architecture),
// so fetch it per model and merge it in.
this.defaultModels = await Promise.all(
curated.map(async (model) => {
const details = await HuggingFaceService.getDetails(model.id);
return details?.gguf ? { ...model, gguf: details.gguf } : model;
})
);
this.models = this.defaultModels;
this.fetched = true;
} catch (err) {
+2
View File
@@ -67,6 +67,8 @@ export interface HfModelInfo {
lastModified?: string;
/** Repository file listing (present when full=true) */
siblings?: HfModelSiblingRef[];
/** GGUF metadata (context length, architecture, etc.) */
gguf?: HfModelGguf;
}
// Model Details (with full=true)
@@ -11,7 +11,7 @@
Trash2,
X
} from '@lucide/svelte';
import { ActionIcon, DialogModelsHub, ModelId } from '$lib/components/app';
import { ActionIcon, DialogModelsDiscover, ModelId } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore, serverStore } from '$lib/stores';
@@ -261,4 +261,4 @@
{/if}
</div>
<DialogModelsHub bind:open={modelsHubOpen} />
<DialogModelsDiscover bind:open={modelsHubOpen} />
@@ -1,7 +1,7 @@
<script lang="ts" module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
import ModelsSelectorList from '$lib/components/app/models/selector/ModelsSelectorList.svelte';
import ModelsSelectorOption from '$lib/components/app/models/selector/ModelsSelectorOption.svelte';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';