feat: WIP

This commit is contained in:
Aleksander Grygier
2026-08-25 11:43:36 +02:00
parent caef7df650
commit 393c8ca1d8
6 changed files with 169 additions and 163 deletions
@@ -1,9 +1,6 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
import { ModelsDiscoverDetails, ModelsDiscoverList } from '$lib/components/app/models/discover';
import { ModelsDiscover } from '$lib/components/app/models/discover';
import * as Dialog from '$lib/components/ui/dialog';
import { modelsHubStore } from '$lib/stores';
import { untrack } from 'svelte';
interface Props {
open?: boolean;
@@ -12,46 +9,10 @@
let { onOpenChange, open = $bindable(false) }: Props = $props();
let selectedId = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
// Load the sidebar list when the dialog opens, and reset browsing state.
// Store writes are untracked so this effect only depends on `open`.
$effect(() => {
if (open) {
untrack(() => {
void modelsHubStore.fetch();
void modelsHubStore.search('');
});
searchQuery = '';
selectedId = null;
}
});
// Auto-select the first model only when the dialog opens (selection reset).
$effect(() => {
const first = modelsHubStore.firstModel;
if (!selectedId && first) {
selectedId = first.id;
}
});
function handleSearchInput(value: string) {
searchQuery = value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
void modelsHubStore.search(value);
}, 300);
}
</script>
<Dialog.Root {open} onOpenChange={handleOpenChange}>
@@ -59,40 +20,6 @@
class="grid gap-0 p-0 md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360!"
style="grid-template-columns: auto 1fr;"
>
<aside
class="w-108 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
>
<div class="p-2 sticky top-0 z-99">
<SearchInput
class=""
bind:value={searchQuery}
placeholder="Search models..."
onInput={handleSearchInput}
/>
</div>
<div>
{#if modelsHubStore.loading}
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
{:else if modelsHubStore.error}
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
{:else if modelsHubStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsDiscoverList
models={modelsHubStore.models}
activeId={selectedId}
showBaseModelAvatar
onSelect={(id) => (selectedId = id)}
/>
{/if}
</div>
</aside>
<main class="overflow-y-auto">
{#if selectedId}
<ModelsDiscoverDetails modelId={selectedId} />
{/if}
</main>
<ModelsDiscover />
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,70 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
import { ModelsDiscoverDetails, ModelsDiscoverList } from '$lib/components/app/models/discover';
import { modelsHubStore } from '$lib/stores';
let selectedId = $state<string | null>(null);
let searchQuery = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Load the sidebar list on mount (the component is mounted when the dialog opens).
$effect(() => {
void modelsHubStore.fetch();
void modelsHubStore.search('');
});
// Auto-select the first model.
$effect(() => {
const first = modelsHubStore.firstModel;
if (!selectedId && first) {
selectedId = first.id;
}
});
function handleSearchInput(value: string) {
searchQuery = value;
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
void modelsHubStore.search(value);
}, 300);
}
</script>
<aside
class="w-108 shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:p-4 h-full space-y-1"
>
<div class="p-2 sticky top-0 z-99">
<SearchInput
class=""
bind:value={searchQuery}
placeholder="Search models..."
onInput={handleSearchInput}
/>
</div>
<div>
{#if modelsHubStore.loading}
<p class="p-4 text-sm text-muted-foreground">Loading models...</p>
{:else if modelsHubStore.error}
<p class="p-4 text-sm text-destructive">{modelsHubStore.error}</p>
{:else if modelsHubStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsDiscoverList
models={modelsHubStore.models}
activeId={selectedId}
showBaseModelAvatar
onSelect={(id) => (selectedId = id)}
/>
{/if}
</div>
</aside>
<main class="overflow-y-auto">
{#if selectedId}
<ModelsDiscoverDetails modelId={selectedId} />
{/if}
</main>
@@ -1,7 +1,7 @@
<script lang="ts">
import DialogModelDownload from './DialogModelDownload.svelte';
import DownloadProgressBar from './DownloadProgressBar.svelte';
import { Check, Cpu, MessageSquareCode, TriangleAlert, X } from '@lucide/svelte';
import { Check, Cpu, Download, MessageSquareCode, Monitor, TriangleAlert, X } from '@lucide/svelte';
import { browser } from '$app/environment';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { GgufVariantTagInput } from '$lib/services';
@@ -65,13 +65,11 @@
</script>
{#if bitDepthRows.length}
<section class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2
class="flex items-center gap-1.5 text-xs font-semibold tracking-wide text-muted-foreground uppercase"
>
<MessageSquareCode class="h-3.5 w-3.5" />
Download options
<section class="rounded-xl border">
<div class="flex flex-wrap items-center justify-between gap-2 px-4 pt-3 pb-1">
<h2 class="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Download class="h-4 w-4" />
Downloadable options
</h2>
<span
@@ -84,17 +82,17 @@
{/if}
</span>
</div>
<div class="space-y-2">
<div class="divide-y px-4 pb-1">
{#each bitDepthRows as row (row.bitDepth)}
<div class="grid grid-cols-[5rem_1fr] items-start gap-3">
<div class="pt-1 text-xs font-semibold tabular-nums text-muted-foreground">
<div class="grid grid-cols-[5rem_1fr] items-start gap-3 py-3">
<div class="pt-1 text-sm tabular-nums text-muted-foreground">
{#if row.bitDepth === 99}
Other
{:else}
{row.bitDepth}-bit
{/if}
</div>
<div class="flex flex-wrap gap-1.5">
<div class="flex flex-wrap justify-end gap-1.5">
{#each row.files as file (file.path)}
{@const meta = HuggingFaceService.extractQuantMeta(file.path)}
{@const basename = file.path.split('/').pop() ?? file.path}
@@ -180,6 +178,7 @@
<span class="font-medium {isDownloaded ? '' : 'text-muted-foreground/80'}"
>{label}</span
>
<span class="-my-1 w-px self-stretch bg-border"></span>
<span class={isDownloaded ? '' : 'text-muted-foreground/80'}>
{#if isDownloading && progress && progress.totalBytes > 0}
{Math.round((progress.downloadedBytes / progress.totalBytes) * 100)}%
@@ -2,20 +2,12 @@
import ModelsDiscoverAvatar from './ModelsDiscoverAvatar.svelte';
import ModelsDiscoverChatTemplateDialog from './ModelsDiscoverChatTemplateDialog.svelte';
import ModelsDiscoverDetailsName from './ModelsDiscoverDetailsName.svelte';
import {
Download,
ExternalLink,
Heart,
Image,
Lightbulb,
MessageSquareCode,
Wrench
} from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Download, ExternalLink, Heart, MessageSquareCode } from '@lucide/svelte';
import { HuggingFaceService } from '$lib/services';
import { modelsHubStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelGguf } from '$lib/types/huggingface';
import { formatParameters } from '$lib/utils';
import { ICON_CLASS_DEFAULT, ICON_CLASS_SM } from '$lib/constants';
interface Props {
modelId: string;
@@ -50,16 +42,26 @@
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<ModelsDiscoverAvatar org={avatarOrg} {quantOrg} />
<ModelsDiscoverDetailsName modelId={details.id ?? modelId} {baseModels} />
<ModelsDiscoverDetailsName
modelId={details.id ?? modelId}
{baseModels}
{hasVision}
{hasTools}
{hasReasoning}
/>
</div>
<a
href={HuggingFaceService.getModelUrl(modelId)}
target="_blank"
rel="noopener noreferrer"
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"
>
<ExternalLink class="h-3.5 w-3.5" />
View on HF
<img src="/recommended-mcp/huggingface.ico" alt="" class="h-3.5 w-3.5" />
View on Hugging Face
<ExternalLink class={ICON_CLASS_SM} />
</a>
</div>
@@ -85,38 +87,42 @@
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
<!-- Metadata chips -->
<!-- Metadata chips: label | value pairs, matching the HF model page style -->
<div class="flex flex-wrap items-center gap-1.5">
{#if gguf?.total}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{formatParameters(gguf.total)} params
</span>
{/if}
{#if gguf?.architecture}
<span
class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground capitalize"
>
{gguf.architecture.replace(/_/g, ' ')}
<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">Model size</span>
<span class="px-2.5 py-1 font-medium">{formatParameters(gguf.total)} params</span>
</span>
{/if}
{#if gguf?.context_length}
<span class="rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground">
{gguf.context_length.toLocaleString()} ctx
<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">Context</span>
<span class="px-2.5 py-1 font-medium">{gguf.context_length.toLocaleString()}</span>
</span>
{/if}
{#if gguf?.architecture}
<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">Architecture</span>
<span class="px-2.5 py-1 font-medium">{gguf.architecture}</span>
</span>
{/if}
{#if gguf?.chat_template}
<button
type="button"
onclick={() => (chatTemplateOpen = true)}
class="inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground transition-colors hover:bg-secondary/70"
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"
>
<MessageSquareCode class="h-3 w-3" />
Chat template
</button>
{/if}
{#if licenseTag}
<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">License</span>
<span class="px-2.5 py-1 font-medium">{licenseTag}</span>
</span>
<span class="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{licenseTag}
</span>
{/if}
{#if details.gated === true}
@@ -127,42 +133,6 @@
</span>
{/if}
</div>
<!-- Capability / modality icons, matching the list item's icon style -->
{#if hasVision || hasTools || hasReasoning}
<div class="flex flex-wrap items-center gap-2.5 text-muted-foreground">
{#if hasVision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasTools}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasReasoning}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
</header>
{#if gguf?.chat_template}
@@ -1,29 +1,59 @@
<script lang="ts">
import { Copy, ExternalLink } from '@lucide/svelte';
import { ExternalLink, Image, Lightbulb, Wrench } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { HuggingFaceService } from '$lib/services';
import { copyToClipboard } from '$lib/utils';
interface Props {
/** Full HuggingFace model id (quant org + name), e.g. `ggml-org/Qwen3.8-27B-GGUF`. */
modelId: string;
/** Base model ids, shown as small text under the quant name. */
baseModels: string[];
hasVision: boolean;
hasTools: boolean;
hasReasoning: boolean;
}
let { baseModels, modelId }: Props = $props();
let { baseModels, hasReasoning, hasTools, hasVision, modelId }: Props = $props();
</script>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h1 class="truncate text-lg font-semibold">{modelId}</h1>
<button
type="button"
onclick={() => copyToClipboard(modelId)}
class="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Copy model id"
>
<Copy class="h-4 w-4" />
</button>
{#if hasVision || hasTools || hasReasoning}
<div class="flex shrink-0 items-center gap-2.5 text-muted-foreground">
{#if hasVision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasTools}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasReasoning}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-4 w-4" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
</div>
{#if baseModels.length}
@@ -7,6 +7,15 @@
*
*/
/**
* **ModelsDiscover** - Models hub 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';
/**
* **ModelsDiscoverList** - Sidebar model list
*
@@ -57,8 +66,9 @@ export { default as ModelsDiscoverDetailsHeader } from './ModelsDiscoverDetailsH
/**
* **ModelsDiscoverDetailsName** - Model name block
*
* Shows the quant model name with a copy button, and the base model name with a
* smaller external-link icon on the line below.
* Shows the quant model name with capability icons (vision, tool use,
* reasoning) beside it, and the base model name with a smaller external-link
* icon on the line below.
*/
export { default as ModelsDiscoverDetailsName } from './ModelsDiscoverDetailsName.svelte';