mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-03 19:37:29 -05:00
ui : add the shared model row and list primitives
The manager and later surfaces share one set of row parts: the collapsible section and grouped list containers, the avatar with its org and quant badges, the capability and context columns with their Hub fallbacks, the load control, the shared row action set, and the download row with its progress bar. The toggle and toggle-group controls and the tertiary button variant arrive with them. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Generated
+4
-4
@@ -12,7 +12,7 @@
|
||||
"@eslint/compat": "1.4.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@internationalized/date": "3.12.2",
|
||||
"@lucide/svelte": "1.25.0",
|
||||
"@lucide/svelte": "1.46.0",
|
||||
"@modelcontextprotocol/sdk": "1.30.0",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@storybook/addon-a11y": "10.5.6",
|
||||
@@ -3225,9 +3225,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lucide/svelte": {
|
||||
"version": "1.25.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.25.0.tgz",
|
||||
"integrity": "sha512-v9m+dD68jxVnqkU3K59mG/RSRFlPGzmKCGSyMfnXcaGv9jODDQMyQkcp1CGvk3Y/cUj9v7f8rw1n//K0B53xGQ==",
|
||||
"version": "1.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.46.0.tgz",
|
||||
"integrity": "sha512-Ru8jDjoCVLJ7J7pPRUdY+odSBV2BI6rj68JTmT+ENq/ZwYOPGPAiKVvKz+XLyAoK4HAwlRTzZHpsMvrHfsetqg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@eslint/compat": "1.4.1",
|
||||
"@eslint/js": "9.39.2",
|
||||
"@internationalized/date": "3.12.2",
|
||||
"@lucide/svelte": "1.25.0",
|
||||
"@lucide/svelte": "1.46.0",
|
||||
"@modelcontextprotocol/sdk": "1.30.0",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@storybook/addon-a11y": "10.5.6",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
onClose?: () => void;
|
||||
onKeyDown?: (event: KeyboardEvent) => void;
|
||||
class?: string;
|
||||
/** Input height, matching the small size of the other controls. */
|
||||
size?: 'sm' | 'default';
|
||||
id?: string;
|
||||
ref?: HTMLInputElement | null;
|
||||
isCancelAlwaysVisible?: boolean;
|
||||
@@ -26,6 +28,7 @@
|
||||
onKeyDown,
|
||||
placeholder = 'Search...',
|
||||
ref = $bindable(null),
|
||||
size = 'default',
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
@@ -58,7 +61,7 @@
|
||||
bind:ref
|
||||
bind:value
|
||||
{autofocus}
|
||||
class="pl-9 {showClearButton ? 'pr-9' : ''}"
|
||||
class="pl-9 {showClearButton ? 'pr-9' : ''} {size === 'sm' ? 'h-8' : ''}"
|
||||
{id}
|
||||
oninput={handleInput}
|
||||
onkeydown={onKeyDown}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { children, open }: Props = $props();
|
||||
|
||||
// rows stay mounted through the collapse transition so it can play
|
||||
const EXPAND_TRANSITION_MS = 200;
|
||||
// the initial state only: the effect below owns it afterwards
|
||||
// svelte-ignore state_referenced_locally
|
||||
let contentMounted = $state(open);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
contentMounted = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => (contentMounted = false), EXPAND_TRANSITION_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="collapsible-region" data-expanded={open}>
|
||||
{#if contentMounted}
|
||||
<div class="collapsible-region-content">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/*
|
||||
* The region animates between height 0 and height auto. `interpolate-size`
|
||||
* lets the auto keyword take part in the interpolation, so the content needs
|
||||
* no measured height to stay in sync with. Browsers without it fall back to
|
||||
* an interpolating grid row, which snaps only in the oldest engines.
|
||||
*/
|
||||
.collapsible-region {
|
||||
height: 0;
|
||||
/* clip, not hidden: hidden would make the region a scrollport, and the
|
||||
sticky rows inside it would then never leave their opening position */
|
||||
overflow: clip;
|
||||
visibility: hidden;
|
||||
interpolate-size: allow-keywords;
|
||||
transition:
|
||||
height 200ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
visibility 200ms;
|
||||
}
|
||||
|
||||
.collapsible-region[data-expanded='true'] {
|
||||
height: auto;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@supports not (interpolate-size: allow-keywords) {
|
||||
.collapsible-region {
|
||||
display: grid;
|
||||
/* minmax(0, 1fr) pins the column to the region width: a plain auto
|
||||
column would size to the content and push wide rows out of the list */
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 0fr;
|
||||
/* the row owns the height here: leaving height: 0 in place would snap
|
||||
the region shut before the row could interpolate */
|
||||
height: auto;
|
||||
transition:
|
||||
grid-template-rows 200ms cubic-bezier(0.23, 1, 0.32, 1),
|
||||
visibility 200ms;
|
||||
}
|
||||
|
||||
.collapsible-region[data-expanded='true'] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.collapsible-region-content {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import CollapsibleRegion from './CollapsibleRegion.svelte';
|
||||
import { ChevronDown, ChevronUp } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Collapsible content. */
|
||||
children: Snippet;
|
||||
/** Trigger content; this component appends the chevron. */
|
||||
trigger: Snippet;
|
||||
/** Render the trigger as a dropdown menu item, so menu keyboard navigation reaches it. */
|
||||
inMenu?: boolean;
|
||||
/** Start expanded. The section owns the state, so a parent rebuild cannot
|
||||
* snap it back open. Bind `open` instead to control it from outside. */
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state, for a caller that binds it. */
|
||||
open?: boolean;
|
||||
/** Called on every toggle, for a caller that persists the state. */
|
||||
ontoggle?: ((open: boolean) => void) | null;
|
||||
/** Extra classes for the chevron, to line it up with a row's own control. */
|
||||
chevronClass?: string;
|
||||
/** Hide the chevron while expanded, until the trigger is hovered. */
|
||||
revealChevronOnHover?: boolean;
|
||||
/** Where the trigger sits relative to the content. */
|
||||
triggerPosition?: 'bottom' | 'top';
|
||||
triggerClass?: string;
|
||||
triggerStyle?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
chevronClass = '',
|
||||
children,
|
||||
defaultOpen = true,
|
||||
inMenu = false,
|
||||
ontoggle = null,
|
||||
open = $bindable(defaultOpen),
|
||||
revealChevronOnHover = false,
|
||||
trigger,
|
||||
triggerClass = '',
|
||||
triggerPosition = 'top',
|
||||
triggerStyle = ''
|
||||
}: Props = $props();
|
||||
|
||||
function toggle(): void {
|
||||
open = !open;
|
||||
ontoggle?.(open);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet chevron()}
|
||||
<span
|
||||
class="ml-auto shrink-0 text-muted-foreground {chevronClass} {open && revealChevronOnHover
|
||||
? 'opacity-0 group-hover:opacity-100'
|
||||
: ''}"
|
||||
>
|
||||
{#if open}
|
||||
<ChevronUp class={ICON_CLASS_DEFAULT} />
|
||||
{:else}
|
||||
<ChevronDown class={ICON_CLASS_DEFAULT} />
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#snippet triggerButton()}
|
||||
{#if inMenu}
|
||||
<!-- A menu item (for keyboard nav) wrapping the trigger via `child`;
|
||||
closeOnSelect keeps the menu open while the section toggles. -->
|
||||
<DropdownMenu.Item
|
||||
class="group w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left text-sm"
|
||||
closeOnSelect={false}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<!-- No `class` here: a static attribute would override the spread props.class. -->
|
||||
<button {...props} aria-expanded={open} onclick={toggle} style={triggerStyle} type="button">
|
||||
{@render trigger()}
|
||||
|
||||
{@render chevron()}
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<button
|
||||
aria-expanded={open}
|
||||
class="group {triggerClass}"
|
||||
onclick={toggle}
|
||||
style={triggerStyle}
|
||||
type="button"
|
||||
>
|
||||
{@render trigger()}
|
||||
|
||||
{@render chevron()}
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet region()}
|
||||
<!-- Custom expand region instead of bits-ui Collapsible (whose conditional
|
||||
rendering kills the transition). -->
|
||||
{#snippet body()}
|
||||
{@render children()}
|
||||
{/snippet}
|
||||
|
||||
<CollapsibleRegion {open}>{@render body()}</CollapsibleRegion>
|
||||
{/snippet}
|
||||
|
||||
{#if triggerPosition === 'top'}
|
||||
{@render triggerButton()}
|
||||
|
||||
{@render region()}
|
||||
{:else}
|
||||
{@render region()}
|
||||
|
||||
{@render triggerButton()}
|
||||
{/if}
|
||||
@@ -0,0 +1,167 @@
|
||||
<script lang="ts" module>
|
||||
/** One group of a grouped list, rendered under its own heading. */
|
||||
export interface GroupedListGroup<G, E> {
|
||||
entries: E[];
|
||||
group: G;
|
||||
key: string;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script generics="G, E" lang="ts">
|
||||
import CollapsibleRegion from './CollapsibleRegion.svelte';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
/** Heading of one group. Omit it for a list whose rows need no heading. */
|
||||
group?: Snippet<
|
||||
[{ depth: number; expanded: boolean; group: G; key: string; toggle: () => void }]
|
||||
>;
|
||||
/** Groups to render. Omit to render `items` as a flat list. */
|
||||
groups?: GroupedListGroup<G, E>[] | null;
|
||||
/** Entries of one group shown before its show more row. 0 shows every entry. */
|
||||
groupWindow?: number;
|
||||
/** Namespace of the persisted open state, e.g. a section key. Omit to keep it in memory. */
|
||||
groupStateKey?: string;
|
||||
/** One row. Depth is 1 under a group, 0 in a flat list. */
|
||||
item: Snippet<[{ depth: number; entry: E }]>;
|
||||
/** Identity of a row, used for the keyed each. */
|
||||
keyOf: (entry: E) => string;
|
||||
/** The show more row. Without it the rest of a window stays hidden. */
|
||||
more?: Snippet<[{ count: number; onMore: () => void; unit: 'entries' | 'families' }]>;
|
||||
/** Flat entries, rendered when the list has no groups. */
|
||||
items?: E[];
|
||||
/** Entries of the list shown before its show more row. 0 shows every entry. */
|
||||
sectionWindow?: number;
|
||||
/** Sticky class of a group heading, so a surface can shade it differently. */
|
||||
stickyClass?: string;
|
||||
/** Sticky offset of a group heading, e.g. `top: 2.25rem`. Empty keeps it scrolling. */
|
||||
stickyStyle?: string;
|
||||
/** Weight of one entry against a window, e.g. the rows it renders. */
|
||||
weightOf?: (entry: E) => number;
|
||||
}
|
||||
|
||||
let {
|
||||
group,
|
||||
groups = null,
|
||||
groupStateKey,
|
||||
groupWindow = 0,
|
||||
item,
|
||||
items = [],
|
||||
keyOf,
|
||||
more,
|
||||
sectionWindow = 0,
|
||||
stickyClass = 'bg-popover',
|
||||
stickyStyle = '',
|
||||
weightOf
|
||||
}: Props = $props();
|
||||
|
||||
// groups start open; this tracks the ones the user folded away, and a list with
|
||||
// a state key opens with the ones the user had folded away before the reload
|
||||
// the initial namespace only
|
||||
// svelte-ignore state_referenced_locally
|
||||
const collapsed = new SvelteSet<string>(
|
||||
groupStateKey ? modelsStore.collapsedGroupsUnder(groupStateKey) : []
|
||||
);
|
||||
// windows grow one step at a time, per group and per list
|
||||
const groupSteps = new SvelteMap<string, number>();
|
||||
let sectionSteps = $state(0);
|
||||
|
||||
function toggleGroup(key: string): void {
|
||||
if (collapsed.has(key)) {
|
||||
collapsed.delete(key);
|
||||
} else {
|
||||
collapsed.add(key);
|
||||
}
|
||||
|
||||
if (groupStateKey) modelsStore.setGroupOpen(`${groupStateKey}-${key}`, !collapsed.has(key));
|
||||
}
|
||||
|
||||
function growGroup(key: string): void {
|
||||
groupSteps.set(key, (groupSteps.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let groupCap = $derived((key: string) =>
|
||||
groupWindow > 0 ? groupWindow * (1 + (groupSteps.get(key) ?? 0)) : Infinity
|
||||
);
|
||||
let weight = $derived(weightOf ?? (() => 1));
|
||||
|
||||
/** Groups with their entries cut to the window, and what each cut leaves behind. */
|
||||
let windowed = $derived.by((): Array<{ group: G; hidden: number; key: string; rows: E[] }> => {
|
||||
const shown: Array<{ group: G; hidden: number; key: string; rows: E[] }> = [];
|
||||
const cap = sectionWindow > 0 ? sectionWindow + sectionSteps : Infinity;
|
||||
|
||||
let used = 0;
|
||||
|
||||
for (const entry of groups ?? []) {
|
||||
if (used >= cap) break;
|
||||
|
||||
const rows = entry.entries.slice(0, groupCap(entry.key));
|
||||
|
||||
shown.push({
|
||||
group: entry.group,
|
||||
hidden: entry.entries.length - rows.length,
|
||||
key: entry.key,
|
||||
rows
|
||||
});
|
||||
used += rows.reduce((sum, row) => sum + weight(row), 0);
|
||||
}
|
||||
|
||||
return shown;
|
||||
});
|
||||
|
||||
let flatRows = $derived(sectionWindow > 0 ? items.slice(0, sectionWindow + sectionSteps) : items);
|
||||
</script>
|
||||
|
||||
{#if groups}
|
||||
{#each windowed as entry (entry.key)}
|
||||
{@const expanded = !collapsed.has(entry.key)}
|
||||
|
||||
{#if group}
|
||||
<div class="sticky z-10 {stickyClass}" style={stickyStyle}>
|
||||
{@render group({
|
||||
depth: 0,
|
||||
expanded,
|
||||
group: entry.group,
|
||||
key: entry.key,
|
||||
toggle: () => toggleGroup(entry.key)
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<CollapsibleRegion open={expanded}>
|
||||
{#each entry.rows as row (keyOf(row))}
|
||||
{@render item({ depth: 1, entry: row })}
|
||||
{/each}
|
||||
|
||||
{#if entry.hidden > 0 && more}
|
||||
{@render more({
|
||||
count: Math.min(groupWindow, entry.hidden),
|
||||
onMore: () => growGroup(entry.key),
|
||||
unit: 'entries'
|
||||
})}
|
||||
{/if}
|
||||
</CollapsibleRegion>
|
||||
{/each}
|
||||
|
||||
{#if windowed.length < (groups?.length ?? 0) && more}
|
||||
{@render more({
|
||||
count: Math.min(sectionWindow, (groups?.length ?? 0) - windowed.length),
|
||||
onMore: () => (sectionSteps += 1),
|
||||
unit: 'families'
|
||||
})}
|
||||
{/if}
|
||||
{:else}
|
||||
{#each flatRows as row (keyOf(row))}
|
||||
{@render item({ depth: 0, entry: row })}
|
||||
{/each}
|
||||
|
||||
{#if flatRows.length < items.length && more}
|
||||
{@render more({
|
||||
count: Math.min(sectionWindow, items.length - flatRows.length),
|
||||
onMore: () => (sectionSteps += 1),
|
||||
unit: 'entries'
|
||||
})}
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -59,3 +59,12 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
|
||||
* `class` and `style` props and inherits color via `currentColor`.
|
||||
*/
|
||||
export { default as Logo } from './Logo.svelte';
|
||||
/**
|
||||
* **CollapsibleSection** - Section with a trigger that expands in place
|
||||
*
|
||||
* The trigger can sit above or below the content, and the chevron can stay out
|
||||
* of sight while the section is open. Collapsed content unmounts after the
|
||||
* transition, so hidden rows never reach menu keyboard navigation.
|
||||
*/
|
||||
export { default as CollapsibleSection } from './CollapsibleSection.svelte';
|
||||
export { default as GroupedList, type GroupedListGroup } from './GroupedList.svelte';
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverAvatar from './discover/ModelsDiscoverAvatar.svelte';
|
||||
import { HF_BASE_MODEL_TAG_REGEX } from '$lib/constants';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { orgOf } from '$lib/utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Rendered when the model has neither an org avatar nor a provider mark. */
|
||||
fallback?: Snippet;
|
||||
option: ModelOption;
|
||||
quantPositionClass?: string;
|
||||
quantSize?: string;
|
||||
/** Show the base model's org as the main image and the repo (quantizer) org as the
|
||||
* corner badge. Resolving the base org costs one Hugging Face request per repo, so
|
||||
* it waits until the row is near the viewport. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
/** Show the repo's own org as the main image, skipping the base model.
|
||||
* Used inside a heading that already carries the base org. */
|
||||
showRepoOrgAvatar?: boolean;
|
||||
/** Keep the quantizer badge off, e.g. when a row stands for a whole family. */
|
||||
showQuantBadge?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
fallback,
|
||||
option,
|
||||
quantPositionClass = '-bottom-1 -right-1',
|
||||
quantSize = 'h-3 w-3',
|
||||
showBaseModelAvatar = false,
|
||||
showQuantBadge = true,
|
||||
showRepoOrgAvatar = false,
|
||||
size = 'size-5'
|
||||
}: Props = $props();
|
||||
|
||||
let parsedId = $derived(ModelsService.parseModelId(option.model));
|
||||
let orgName = $derived(parsedId.orgName);
|
||||
let tagBaseModel = $derived(
|
||||
(option.tags ?? [])
|
||||
.find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
|
||||
?.match(HF_BASE_MODEL_TAG_REGEX)?.[1] ?? null
|
||||
);
|
||||
let fetchedBaseModelOrg = $state<string | null>(null);
|
||||
let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
|
||||
let avatarEl = $state<HTMLElement | null>(null);
|
||||
let isNearViewport = $state(false);
|
||||
|
||||
// Long lists mount hundreds of avatars at once; resolving every base model up
|
||||
// front means one request per row, so wait until a row is actually near the viewport.
|
||||
$effect(() => {
|
||||
if (isNearViewport || !avatarEl) return;
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
isNearViewport = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
isNearViewport = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
observer.observe(avatarEl);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
fetchedBaseModelOrg = null;
|
||||
|
||||
if (!isNearViewport || !showBaseModelAvatar || !orgName || tagBaseModel) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getBaseModel(option.model)
|
||||
.then((base) => {
|
||||
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
|
||||
})
|
||||
// best-effort lookup: offline or unknown repos keep the repo org
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if orgName}
|
||||
<span bind:this={avatarEl} class={['inline-flex shrink-0', className]}>
|
||||
<ModelsDiscoverAvatar
|
||||
class="mt-0"
|
||||
org={showRepoOrgAvatar ? orgName : (baseModelOrg ?? orgName)}
|
||||
quantOrg={showBaseModelAvatar && showQuantBadge && !showRepoOrgAvatar ? orgName : undefined}
|
||||
{quantPositionClass}
|
||||
{quantSize}
|
||||
{size}
|
||||
/>
|
||||
</span>
|
||||
{:else}
|
||||
{@render fallback?.()}
|
||||
{/if}
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import ModelId from './ModelId.svelte';
|
||||
import { Package } from '@lucide/svelte';
|
||||
import { ActionIconCopyToClipboard, BadgeInfo } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
@@ -29,7 +29,7 @@
|
||||
{#snippet badgeContent(triggerProps?: Record<string, unknown>)}
|
||||
<BadgeInfo {...triggerProps ?? {}} class={className} {onclick}>
|
||||
{#snippet icon()}
|
||||
<Package class="h-3 w-3" />
|
||||
<MODEL_ICON class="h-3 w-3" />
|
||||
{/snippet}
|
||||
|
||||
{#if model}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
|
||||
import { ModelCapability } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
option: ModelOption;
|
||||
}
|
||||
|
||||
let { class: className = '', option }: Props = $props();
|
||||
|
||||
// a listing that declares its capabilities (Ollama-compatible backends) needs no lookup
|
||||
let declared = $derived(option.capabilities);
|
||||
let el = $state<HTMLElement | null>(null);
|
||||
let isNearViewport = $state(false);
|
||||
let template = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (isNearViewport || !el) return;
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
isNearViewport = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
isNearViewport = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
template = '';
|
||||
|
||||
if (!isNearViewport) return;
|
||||
|
||||
// the listing says nothing about tools and reasoning; only the model's chat
|
||||
// template does, and the Hub carries that template for a local GGUF whether
|
||||
// it is loaded or not
|
||||
const repo = option.model.split(':')[0] ?? '';
|
||||
|
||||
if (!repo.includes('/')) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getDetails(repo)
|
||||
.then((details) => {
|
||||
if (!cancelled) template = details?.gguf?.chat_template ?? '';
|
||||
})
|
||||
// best-effort: offline or a repo we cannot read leaves the icons off
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
let supportsThinking = $derived(
|
||||
declared.includes(ModelCapability.REASONING) || detectThinkingSupport(template)
|
||||
);
|
||||
let supportsToolUse = $derived(
|
||||
declared.includes(ModelCapability.TOOL_USE) || detectToolUseSupport(template)
|
||||
);
|
||||
</script>
|
||||
|
||||
<span bind:this={el} class={className}>
|
||||
<ModelCapabilityIcons modalities={option.modalities} {supportsThinking} {supportsToolUse} />
|
||||
</span>
|
||||
@@ -52,7 +52,7 @@
|
||||
{/if}
|
||||
|
||||
{#if hasModalityIcons && !hideModalities}
|
||||
<span class="inline-flex items-center text-muted-foreground">
|
||||
<span class="inline-flex items-center {gapClass} text-muted-foreground">
|
||||
{#if modalities?.vision}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { formatParameters } from '$lib/utils/formatters';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Context the model is set to run with; paired with the supported one. */
|
||||
configured?: number | null;
|
||||
option: ModelOption;
|
||||
}
|
||||
|
||||
let { class: className = '', configured = null, option }: Props = $props();
|
||||
|
||||
// a listing that reports a context window (OpenRouter, Groq, HF) needs no lookup
|
||||
let reported = $derived(option.contextLength ?? null);
|
||||
let el = $state<HTMLElement | null>(null);
|
||||
let isNearViewport = $state(false);
|
||||
let fetched = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (isNearViewport || !el) return;
|
||||
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
isNearViewport = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
isNearViewport = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
fetched = null;
|
||||
|
||||
if (reported || !isNearViewport) return;
|
||||
|
||||
// a local GGUF carries its trained context in the model metadata
|
||||
const repo = option.model.split(':')[0] ?? '';
|
||||
|
||||
if (!repo) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getDetails(repo)
|
||||
.then((details) => {
|
||||
if (!cancelled) fetched = details?.gguf?.context_length ?? null;
|
||||
})
|
||||
// best-effort: offline or a repo we cannot read keeps the dash
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
let context = $derived(reported ?? fetched);
|
||||
// `configured / supported`, or whichever of the two is known
|
||||
let values = $derived(
|
||||
[configured, context].filter((value): value is number => typeof value === 'number')
|
||||
);
|
||||
</script>
|
||||
|
||||
<span bind:this={el} class={['text-sm text-muted-foreground', className]}>
|
||||
{values.length ? `${values.map((value) => formatParameters(value)).join(' / ')} tokens` : '—'}
|
||||
</span>
|
||||
@@ -4,6 +4,7 @@
|
||||
import { TruncatedText } from '$lib/components/app';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { type ModelSidecar } from '$lib/constants';
|
||||
import { MODEL_BADGE_CLASS, MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
@@ -67,12 +68,10 @@
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const badgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';
|
||||
const badgeClass = MODEL_BADGE_CLASS;
|
||||
const tagBadgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground';
|
||||
const variantBadgeClass =
|
||||
'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md bg-primary px-1.5 py-0 text-[10px] font-mono font-semibold uppercase tracking-wide text-primary-foreground';
|
||||
const variantBadgeClass = MODEL_VARIANT_BADGE_CLASS;
|
||||
|
||||
/** Alias badges beyond this many collapse into a single `+x more` badge. */
|
||||
const MAX_ALIAS_BADGES = 2;
|
||||
@@ -114,7 +113,11 @@
|
||||
{/if}
|
||||
|
||||
{#if hasBadges}
|
||||
<span class="inline-flex items-center gap-1 {wrap ? 'flex-wrap' : ''}">
|
||||
<!-- the badges do not shrink, so the group has to clip them: without this
|
||||
they paint over the row actions when the row is narrow -->
|
||||
<span
|
||||
class="inline-flex min-w-0 items-center gap-1 overflow-hidden {wrap ? 'flex-wrap' : ''}"
|
||||
>
|
||||
{#if parsed.sidecar}
|
||||
<span class={variantBadgeClass} title={`${parsed.sidecar.toUpperCase()} draft model`}>
|
||||
{parsed.sidecar}
|
||||
@@ -127,18 +130,18 @@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#each uniqueDraftSidecars as sidecar (sidecar)}
|
||||
<span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
|
||||
{sidecar}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if parsed.quantization && !resolvedHideQuantization}
|
||||
<span class={badgeClass}>
|
||||
{parsed.quantization}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#each uniqueDraftSidecars as sidecar (sidecar)}
|
||||
<span class={variantBadgeClass} title={`${sidecar.toUpperCase()} draft model available`}>
|
||||
{sidecar}
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if primaryAlias}
|
||||
{#if primaryAlias !== parsed.modelName}
|
||||
<span class="{badgeClass} max-w-32 truncate" title={parsed.modelName ?? modelId}>
|
||||
@@ -168,10 +171,12 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<!-- overflow-hidden bounds the whole id: the badges, tags and capability icons do
|
||||
not shrink, so without it they spill past the row and over its actions -->
|
||||
<span
|
||||
class="flex min-w-0 items-center gap-1.5 {wrap ? 'flex-wrap' : ''} {iconsOnNewLine
|
||||
? 'flex-col items-start'
|
||||
: ''} {className}"
|
||||
class="flex min-w-0 items-center gap-1.5 overflow-hidden {wrap
|
||||
? 'flex-wrap'
|
||||
: ''} {iconsOnNewLine ? 'flex-col items-start' : ''} {className}"
|
||||
{title}
|
||||
{...rest}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { CircleAlert, Eject, Loader2, Power, RotateCw } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
isFailed?: boolean;
|
||||
isLoaded: boolean;
|
||||
isLoading?: boolean;
|
||||
isSleeping?: boolean;
|
||||
option: ModelOption;
|
||||
/** Table rows keep the load action visible, selector rows reveal it on hover. */
|
||||
revealOnHover?: boolean;
|
||||
/** Renders the state alone, for a caller that moves load and unload elsewhere. */
|
||||
showAction?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
isFailed = false,
|
||||
isLoaded,
|
||||
isLoading = false,
|
||||
isSleeping = false,
|
||||
option,
|
||||
revealOnHover = true,
|
||||
showAction = true
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={['flex w-5 shrink-0 items-center justify-center', className]}>
|
||||
{#if isLoading}
|
||||
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
<!-- the state dot is what the row shows at rest; the action takes its place on hover -->
|
||||
{#if showAction && revealOnHover}
|
||||
{#if isFailed}
|
||||
<CircleAlert
|
||||
class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
class="h-2 w-2 rounded-full group-hover:hidden [@media(pointer:coarse)]:hidden {isSleeping
|
||||
? 'bg-orange-400'
|
||||
: isLoaded
|
||||
? 'bg-green-500'
|
||||
: 'bg-muted-foreground/50'}"
|
||||
></span>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if isFailed}
|
||||
<CircleAlert class="h-3.5 w-3.5 text-red-500" />
|
||||
{:else}
|
||||
<span
|
||||
class="h-2 w-2 rounded-full {isSleeping
|
||||
? 'bg-orange-400'
|
||||
: isLoaded
|
||||
? 'bg-green-500'
|
||||
: 'bg-muted-foreground/50'}"
|
||||
></span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- the wrapper has to pick one display value: flex and hidden together
|
||||
leave the winner to stylesheet order -->
|
||||
<div
|
||||
class={!showAction
|
||||
? 'hidden'
|
||||
: revealOnHover
|
||||
? 'hidden group-hover:flex [@media(pointer:coarse)]:flex'
|
||||
: 'flex'}
|
||||
>
|
||||
{#if isFailed}
|
||||
<ActionIcon
|
||||
class="h-5 w-5 text-red-500 hover:text-foreground"
|
||||
icon={RotateCw}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Retry loading model"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
{:else if isLoaded || isSleeping}
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={Eject}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.status.unload(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Unload model"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
{:else}
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={Power}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.status.load(option.model)}
|
||||
stopPropagationOnClick
|
||||
tooltip="Load model"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { Heart, HeartOff, Info } from '@lucide/svelte';
|
||||
import { ActionIcon } from '$lib/components/app';
|
||||
import { modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
isFav: boolean;
|
||||
option: ModelOption;
|
||||
/** Selector rows reveal the actions on hover, table rows keep them visible. */
|
||||
revealOnHover?: boolean;
|
||||
}
|
||||
|
||||
let { isFav, option, revealOnHover = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'flex items-center justify-center gap-1',
|
||||
revealOnHover
|
||||
? 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100'
|
||||
: ''
|
||||
]}
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
onkeydown={(event) => event.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={Info}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => uiStore.openModelsManager(option.id)}
|
||||
tooltip="Manage model"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
|
||||
{#if isFav}
|
||||
<span class="flex h-5 w-5 items-center justify-center">
|
||||
<span class="flex group-hover:hidden [@media(pointer:coarse)]:hidden">
|
||||
<ActionIcon
|
||||
class="h-5 w-5 text-rose-500 hover:text-foreground"
|
||||
icon={Heart}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip="Remove from favorites"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={HeartOff}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip="Remove from favorites"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
{:else}
|
||||
<ActionIcon
|
||||
class="h-5 w-5 hover:text-foreground"
|
||||
icon={Heart}
|
||||
iconSize="h-4 w-4"
|
||||
onclick={() => modelsStore.toggleFavorite(option.model)}
|
||||
tooltip="Add to favorites"
|
||||
tooltipAsTitle
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
|
||||
import { CollapsibleSection } from '$lib/components/app';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/** Section rows. */
|
||||
children: Snippet;
|
||||
/** Extra classes for the chevron, to line it up with a row's own control. */
|
||||
chevronClass?: string;
|
||||
/** Number shown next to the label, omitted when undefined. */
|
||||
count?: number;
|
||||
error?: boolean;
|
||||
/** Overrides the backend logo, used for sections without a backend. */
|
||||
icon?: Snippet;
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
/** Renders the back control, for a drilled-in provider. */
|
||||
onBack?: () => void;
|
||||
/** Persists the open state under this id, e.g. a section key. */
|
||||
persistKey?: string;
|
||||
/** Start expanded; the manager collapses its hidden block. */
|
||||
defaultOpen?: boolean;
|
||||
revealChevronOnHover?: boolean;
|
||||
sectionHeaderClass?: string;
|
||||
/** Sticky class of the header, so a surface can shade it differently. */
|
||||
stickyClass?: string;
|
||||
/** Sticks the header to the top of the scrollport. */
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
chevronClass = '',
|
||||
children,
|
||||
count,
|
||||
defaultOpen = true,
|
||||
error = false,
|
||||
icon,
|
||||
label,
|
||||
loading = false,
|
||||
onBack,
|
||||
persistKey,
|
||||
revealChevronOnHover = false,
|
||||
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground select-none',
|
||||
sticky = false,
|
||||
stickyClass = 'sticky z-10 bg-popover'
|
||||
}: Props = $props();
|
||||
|
||||
let triggerClass = $derived(
|
||||
`${sectionHeaderClass} flex w-full cursor-pointer items-center gap-1.5 text-left${sticky ? ` ${stickyClass}` : ''}`
|
||||
);
|
||||
// the dropdown publishes its search block height, surfaces without one fall back to 0
|
||||
let triggerStyle = $derived(sticky ? 'top: var(--dropdown-sticky-height, 0px)' : '');
|
||||
</script>
|
||||
|
||||
<CollapsibleSection
|
||||
{chevronClass}
|
||||
defaultOpen={persistKey ? modelsStore.isGroupOpen(persistKey, defaultOpen) : defaultOpen}
|
||||
ontoggle={persistKey ? (open: boolean) => modelsStore.setGroupOpen(persistKey, open) : null}
|
||||
{revealChevronOnHover}
|
||||
{triggerClass}
|
||||
{triggerStyle}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
{#if onBack}
|
||||
<button
|
||||
aria-label="Back to all providers"
|
||||
class="-ml-1 inline-flex shrink-0 cursor-pointer items-center rounded-sm p-0.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
|
||||
onclick={(event) => {
|
||||
// the surrounding trigger toggles the section, the back control
|
||||
// must not collapse the list it is leaving
|
||||
event.stopPropagation();
|
||||
onBack?.();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if icon}
|
||||
{@render icon()}
|
||||
{/if}
|
||||
|
||||
<span class="truncate">{label}</span>
|
||||
|
||||
{#if loading}
|
||||
<Loader2 class="h-3 w-3 shrink-0 animate-spin" />
|
||||
{:else if error}
|
||||
<CircleAlert class="h-3 w-3 shrink-0 text-destructive" />
|
||||
{/if}
|
||||
|
||||
{#if count !== undefined}
|
||||
<span class="shrink-0 text-xs text-muted-foreground/70">{count}</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{@render children()}
|
||||
</CollapsibleSection>
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverAvatar from '../discover/ModelsDiscoverAvatar.svelte';
|
||||
import ModelsDiscoverDownloadProgressBar from '../discover/ModelsDiscoverDownloadProgressBar.svelte';
|
||||
import { Info, Loader2, Pause, Play, X } from '@lucide/svelte';
|
||||
import { ModelId } from '$lib/components/app';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore, uiStore } from '$lib/stores';
|
||||
import type { ModelDownloadProgress } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** One entry from the status feed: an in-flight or paused download. */
|
||||
entry: { isPaused: boolean; progress: ModelDownloadProgress | null; repoWithTag: string };
|
||||
/**
|
||||
* Ask the list to confirm cancelling this download. The row owns no dialog;
|
||||
* the list renders a single shared confirmation.
|
||||
*/
|
||||
onRequestCancel?: (repoWithTag: string) => void;
|
||||
/** Show the organization name in the repo id, as the other selector rows do. */
|
||||
showOrgName?: boolean;
|
||||
}
|
||||
|
||||
let { entry, onRequestCancel, showOrgName = true }: Props = $props();
|
||||
|
||||
let percent = $derived(
|
||||
entry.progress && entry.progress.totalBytes > 0
|
||||
? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
|
||||
: null
|
||||
);
|
||||
|
||||
let actionText = $derived(entry.isPaused ? 'Resume downloading' : 'Pause downloading');
|
||||
|
||||
// Avatar: the repo org with the quantizer org corner badge, as in the model
|
||||
// option rows; the base model org resolves lazily via HF when unknown
|
||||
let orgName = $derived(ModelsService.parseModelId(entry.repoWithTag).orgName);
|
||||
let fetchedBaseModelOrg = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const repoWithTag = entry.repoWithTag;
|
||||
|
||||
fetchedBaseModelOrg = null;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getBaseModel(repoWithTag)
|
||||
.then((base) => {
|
||||
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
|
||||
})
|
||||
// best-effort lookup: offline or unknown repos keep the repo org
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- One in-flight download; same actions as the discover quant chips: the row
|
||||
itself pauses / resumes, the trailing X cancels (stops and discards the
|
||||
partial files). Both affordances fade in on hover, the slots are reserved
|
||||
so the list never reflows. -->
|
||||
<div
|
||||
class="group relative flex items-center gap-2 rounded-sm p-2 text-left text-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<button
|
||||
aria-label={actionText}
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
|
||||
onclick={() => {
|
||||
if (entry.isPaused) void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
|
||||
else void modelsStore.status.pauseDownload(entry.repoWithTag);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if orgName}
|
||||
<ModelsDiscoverAvatar
|
||||
org={fetchedBaseModelOrg ?? orgName}
|
||||
quantOrg={orgName}
|
||||
quantPositionClass="-bottom-1 -right-1"
|
||||
quantSize="h-3 w-3"
|
||||
size="h-6 w-6"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ModelId class="flex-1" hideOrgName={!showOrgName} modelId={entry.repoWithTag} showRawTooltip />
|
||||
|
||||
{#if percent !== null}
|
||||
<span class="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
|
||||
{:else if entry.isPaused}
|
||||
<span class="shrink-0 text-xs text-muted-foreground">Paused</span>
|
||||
{/if}
|
||||
|
||||
<!-- status action: spinner -> pause on hover while in flight, play on hover
|
||||
when paused; opacity only for the spinner, the spin owns the transform -->
|
||||
<span class="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{#if entry.isPaused}
|
||||
<Play
|
||||
class="absolute h-4 w-4 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"
|
||||
/>
|
||||
{:else}
|
||||
<Loader2
|
||||
class="absolute h-4 w-4 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-4 w-4 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Manage model"
|
||||
class="inline-flex h-4 w-4 shrink-0 scale-75 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-foreground group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
|
||||
onclick={() => uiStore.openModelsManager(entry.repoWithTag)}
|
||||
type="button"
|
||||
>
|
||||
<Info class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Cancel downloading"
|
||||
class="inline-flex h-4 w-4 shrink-0 scale-75 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
|
||||
onclick={() => onRequestCancel?.(entry.repoWithTag)}
|
||||
type="button"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{#if entry.progress && entry.progress.totalBytes > 0}
|
||||
<ModelsDiscoverDownloadProgressBar
|
||||
downloadedBytes={entry.progress.downloadedBytes}
|
||||
overlay
|
||||
totalBytes={entry.progress.totalBytes}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="relative mt-0.5 inline-flex shrink-0 {className}">
|
||||
<span class="relative inline-flex shrink-0 {className}">
|
||||
{#if orgAvatarFailed}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
downloadedBytes: number;
|
||||
totalBytes: number;
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
let { downloadedBytes, overlay = false, totalBytes }: Props = $props();
|
||||
|
||||
let fraction = $derived.by(() => {
|
||||
if (totalBytes <= 0) return 0;
|
||||
|
||||
return Math.min(Math.max(downloadedBytes / totalBytes, 0), 1);
|
||||
});
|
||||
let percent = $derived(Math.round(fraction * 100));
|
||||
</script>
|
||||
|
||||
{#if overlay}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
|
||||
<div
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
|
||||
style="width: {percent}%"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -118,3 +118,16 @@ export { default as ModelId } from './ModelId.svelte';
|
||||
* styling stay consistent across every model-id surface.
|
||||
*/
|
||||
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
|
||||
/**
|
||||
* **ModelId** - Parsed model identifier display
|
||||
*
|
||||
* Displays a model ID with optional org name, parameter badges, quantization,
|
||||
* aliases, and tags. Supports raw mode to show the unprocessed model name.
|
||||
* Respects the user's `showRawModelNames` setting.
|
||||
*/
|
||||
export { default as ModelCapabilities } from './ModelCapabilities.svelte';
|
||||
export { default as ModelContext } from './ModelContext.svelte';
|
||||
export { default as ModelAvatar } from './ModelAvatar.svelte';
|
||||
export { default as ModelLoadControl } from './ModelLoadControl.svelte';
|
||||
export { default as ModelRowActions } from './ModelRowActions.svelte';
|
||||
export { default as ModelsSection } from './ModelsSection.svelte';
|
||||
|
||||
@@ -27,7 +27,9 @@
|
||||
outline:
|
||||
'shadow-sm hover:text-accent-foreground hover:bg-muted-foreground/10 backdrop-blur-sm dark:border-input border',
|
||||
secondary:
|
||||
'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25'
|
||||
'bg-background dark:bg-muted-foreground/15 dark:text-secondary-foreground shadow-sm text-foreground hover:bg-muted-foreground/20 dark:hover:bg-muted-foreground/25',
|
||||
tertiary:
|
||||
'bg-muted/60 dark:bg-muted/75 shadow-sm border border-border/30 text-foreground hover:bg-muted/80 dark:border-border/20 dark:hover:bg-muted'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import Root from './toggle-group.svelte';
|
||||
import Item from './toggle-group-item.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
//
|
||||
Root as ToggleGroup,
|
||||
Item as ToggleGroupItem
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { getToggleGroupCtx } from './toggle-group.svelte';
|
||||
import { type ToggleVariants, toggleVariants } from '$lib/components/ui/toggle/index.js';
|
||||
import { cn } from '$lib/components/ui/utils.js';
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
ref = $bindable(null),
|
||||
size,
|
||||
value = $bindable(),
|
||||
variant,
|
||||
...restProps
|
||||
}: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
|
||||
|
||||
const ctx = getToggleGroupCtx();
|
||||
</script>
|
||||
|
||||
<ToggleGroupPrimitive.Item
|
||||
bind:ref
|
||||
class={cn(
|
||||
'group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-md group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-md group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-md group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-md shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t',
|
||||
toggleVariants({
|
||||
size: ctx.size || size,
|
||||
variant: ctx.variant || variant
|
||||
}),
|
||||
className
|
||||
)}
|
||||
data-size={ctx.size || size}
|
||||
data-slot="toggle-group-item"
|
||||
data-spacing={ctx.spacing}
|
||||
data-variant={ctx.variant || variant}
|
||||
{value}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts" module>
|
||||
import { toggleVariants } from '$lib/components/ui/toggle/index.js';
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import type { VariantProps } from 'tailwind-variants';
|
||||
|
||||
type ToggleVariants = VariantProps<typeof toggleVariants>;
|
||||
|
||||
interface ToggleGroupContext extends ToggleVariants {
|
||||
spacing?: number;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
export function setToggleGroupCtx(props: ToggleGroupContext) {
|
||||
setContext('toggleGroup', props);
|
||||
}
|
||||
|
||||
export function getToggleGroupCtx() {
|
||||
return getContext<Required<ToggleGroupContext>>('toggleGroup');
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils.js';
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
orientation = 'horizontal',
|
||||
ref = $bindable(null),
|
||||
size = 'default',
|
||||
spacing = 0,
|
||||
value = $bindable(),
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: ToggleGroupPrimitive.RootProps &
|
||||
ToggleVariants & {
|
||||
spacing?: number;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
} = $props();
|
||||
|
||||
setToggleGroupCtx({
|
||||
get orientation() {
|
||||
return orientation;
|
||||
},
|
||||
get size() {
|
||||
return size;
|
||||
},
|
||||
get spacing() {
|
||||
return spacing;
|
||||
},
|
||||
get variant() {
|
||||
return variant;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<ToggleGroupPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
class={cn(
|
||||
'rounded-md group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch',
|
||||
className
|
||||
)}
|
||||
data-size={size}
|
||||
data-slot="toggle-group"
|
||||
data-spacing={spacing}
|
||||
data-variant={variant}
|
||||
{orientation}
|
||||
style={`--gap: ${spacing}`}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,13 @@
|
||||
import Root from './toggle.svelte';
|
||||
export {
|
||||
toggleVariants,
|
||||
type ToggleSize,
|
||||
type ToggleVariant,
|
||||
type ToggleVariants
|
||||
} from './toggle.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Toggle
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts" module>
|
||||
import { tv, type VariantProps } from 'tailwind-variants';
|
||||
|
||||
export const toggleVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive gap-1 rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] inline-flex items-center justify-center whitespace-nowrap disabled:pointer-events-none disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 group/toggle",
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
variant: 'default'
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default:
|
||||
'h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
lg: 'h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
sm: "h-7 min-w-7 rounded-md px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5"
|
||||
},
|
||||
variant: {
|
||||
default:
|
||||
'bg-transparent hover:bg-muted-foreground/10 hover:text-accent-foreground backdrop-blur-sm data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
outline:
|
||||
'border bg-background shadow-sm hover:bg-muted-foreground/10 hover:text-accent-foreground backdrop-blur-sm data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:border-input'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ToggleVariant = VariantProps<typeof toggleVariants>['variant'];
|
||||
export type ToggleSize = VariantProps<typeof toggleVariants>['size'];
|
||||
export type ToggleVariants = VariantProps<typeof toggleVariants>;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/components/ui/utils.js';
|
||||
import { Toggle as TogglePrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
class: className,
|
||||
pressed = $bindable(false),
|
||||
ref = $bindable(null),
|
||||
size = 'default',
|
||||
variant = 'default',
|
||||
...restProps
|
||||
}: TogglePrimitive.RootProps & {
|
||||
variant?: ToggleVariant;
|
||||
size?: ToggleSize;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<TogglePrimitive.Root
|
||||
bind:pressed
|
||||
bind:ref
|
||||
class={cn(toggleVariants({ size, variant }), className)}
|
||||
data-slot="toggle"
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Icon mappings for file types and model modalities
|
||||
* Icon mappings for file types, models and model modalities
|
||||
* Centralized configuration to ensure consistent icon usage across the app
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Image as ImageIcon,
|
||||
Lightbulb as ReasoningIcon,
|
||||
Mic as AudioIcon,
|
||||
Package as ModelIcon,
|
||||
Video as VideoIcon,
|
||||
Wrench as ToolUseIcon
|
||||
} from '@lucide/svelte';
|
||||
@@ -27,6 +28,12 @@ export const FILE_TYPE_ICONS = {
|
||||
|
||||
export const DEFAULT_FILE_ICON = FileIcon;
|
||||
|
||||
/** The mark for a model, wherever one is listed. */
|
||||
export const MODEL_ICON = ModelIcon;
|
||||
|
||||
/** Alias kept for the selector until it adopts MODEL_ICON. */
|
||||
export const MODEL_SELECTOR_ICON = ModelIcon;
|
||||
|
||||
export const MODALITY_ICONS = {
|
||||
[ModelModality.AUDIO]: AudioIcon,
|
||||
[ModelModality.VIDEO]: VideoIcon,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export * from './agentic.constants';
|
||||
export * from './api-endpoints.constants';
|
||||
export * from './app.constants';
|
||||
export * from './backend.constants';
|
||||
export * from './chat-tabs.constants';
|
||||
export * from './database.constants';
|
||||
export * from './reasoning-effort.constants';
|
||||
@@ -46,6 +47,7 @@ export * from './path-display.constants';
|
||||
export * from './model-id.constants';
|
||||
export * from './model-loading.constants';
|
||||
export * from './models-discover.constants';
|
||||
export * from './models-discover-download.constants';
|
||||
export * from './model-compatibility.constants';
|
||||
export * from './huggingface.constants';
|
||||
export * from './precision.constants';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Models Discover - download options constants.
|
||||
*
|
||||
* Shared values backing the download-options area of the details pane: the
|
||||
* serve `--spec-type` mapping, the bit-depth buckets, and the standalone
|
||||
* command builder. Kept here (not component-local) so they sit with the rest
|
||||
* of the discover domain constants.
|
||||
*/
|
||||
|
||||
import type { ModelSidecar } from '$lib/constants';
|
||||
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* `--spec-type` value for each draft sidecar; aux sidecars (mmproj, imatrix)
|
||||
* carry none and stay empty.
|
||||
*/
|
||||
export const SPEC_TYPE: Record<ModelSidecar, string> = {
|
||||
[ModelAuxSidecar.IMATRIX]: '',
|
||||
[ModelAuxSidecar.MMPROJ]: '',
|
||||
[ModelDraftSidecar.DFLASH]: 'draft-dflash',
|
||||
[ModelDraftSidecar.DSPARK]: 'draft-dspark',
|
||||
[ModelDraftSidecar.EAGLE3]: 'draft-eagle3',
|
||||
[ModelDraftSidecar.MTP]: 'draft-mtp'
|
||||
};
|
||||
|
||||
/** Bit-depth bucket for files that carry no quant token; rendered as "Other". */
|
||||
export const OTHER_BIT_DEPTH = 99;
|
||||
|
||||
/** Label for the OTHER_BIT_DEPTH bucket. */
|
||||
export const OTHER_BIT_DEPTH_LABEL = 'Other';
|
||||
|
||||
/** Suffix appended after a bit-depth number to label a row, e.g. `4-bit`. */
|
||||
export const BIT_DEPTH_LABEL_SUFFIX = '-bit';
|
||||
|
||||
/**
|
||||
* Fixed tokens of the standalone `llama serve` command preview, so the command
|
||||
* builder and its rendered spans share one source of the CLI spelling.
|
||||
*/
|
||||
export const SERVE_COMMAND = {
|
||||
BIN: 'llama',
|
||||
/** Draft model repo:tag argument (download sidecar / draft weights). */
|
||||
DRAFT_FLAG: '-hfd',
|
||||
/** Main model repo:tag argument. */
|
||||
MODEL_FLAG: '-hf',
|
||||
/** Speculative-decoding type argument for the draft. */
|
||||
SPEC_TYPE_FLAG: '--spec-type',
|
||||
SUBCOMMAND: 'serve'
|
||||
} as const;
|
||||
|
||||
/** Bit depth preferred for the command's default base quant. */
|
||||
export const DEFAULT_BASE_BIT_DEPTH = 4;
|
||||
|
||||
/** Label shown for a draft-sidecar chip whose quant is unknown. */
|
||||
export const DRAFT_FILE_LABEL = 'draft';
|
||||
@@ -72,6 +72,7 @@ import type {
|
||||
HfModelSibling
|
||||
} from '$lib/types/huggingface';
|
||||
import { sidecarFromFileToken } from '$lib/utils';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
/**
|
||||
* HuggingFaceService - Service for browsing and searching GGUF models on Hugging Face Hub
|
||||
@@ -88,6 +89,13 @@ export class HuggingFaceService {
|
||||
Promise<{ org: string; name: string } | null>
|
||||
>();
|
||||
|
||||
// Model details and file trees fetched this session, keyed by repo id. The
|
||||
// Hub rate limits aggressively, so each repo costs at most one request per
|
||||
// app load; failed fetches are not cached so the next mount can retry.
|
||||
private static detailsCache = new SvelteMap<string, HfModelDetailInfo | null>();
|
||||
|
||||
private static detailsPending = new Map<string, Promise<HfModelDetailInfo | null>>();
|
||||
|
||||
/**
|
||||
* Map of quant token to its average bit-depth in bits-per-weight (bpw).
|
||||
*/
|
||||
@@ -127,6 +135,23 @@ export class HuggingFaceService {
|
||||
Q8_0: 8
|
||||
};
|
||||
|
||||
private static treeCache = new Map<string, HfModelSibling[]>();
|
||||
|
||||
private static treePending = new Map<string, Promise<HfModelSibling[]>>();
|
||||
|
||||
/**
|
||||
* Details already fetched for a model, for a caller that cannot await, such as a
|
||||
* table ordering its rows. `undefined` means no lookup has happened yet.
|
||||
*/
|
||||
static cachedDetails(modelId: string): HfModelDetailInfo | null | undefined {
|
||||
// llama.cpp model ids carry the quant tag after a colon
|
||||
const [hfRepoId] = modelId.split(':');
|
||||
|
||||
return HuggingFaceService.detailsCache.get(hfRepoId);
|
||||
}
|
||||
|
||||
// GGUF Model Browsing
|
||||
|
||||
/**
|
||||
* Collapse split GGUF shard sets (`-00001-of-00015.gguf`, ...) to their first
|
||||
* shard, summing every shard's size so the kept entry reflects the whole
|
||||
@@ -166,8 +191,6 @@ export class HuggingFaceService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// GGUF Model Browsing
|
||||
|
||||
/**
|
||||
* Extract the GGUF quantization token (e.g. `Q4_K_M`) and any sidecar type
|
||||
* (`mtp`, `dflash`, `mmproj`, ...) from a `.gguf` filename. The sidecar token
|
||||
@@ -345,6 +368,8 @@ export class HuggingFaceService {
|
||||
return `${Math.floor(diffDays / DAYS_PER_YEAR)} ${YEARS_AGO_LABEL}`;
|
||||
}
|
||||
|
||||
// Model Details & Files
|
||||
|
||||
/**
|
||||
* Format a min-max size range with a single shared unit and no spaces
|
||||
* around the dash, e.g. `19.0-28.6 GB`.
|
||||
@@ -371,8 +396,6 @@ export class HuggingFaceService {
|
||||
return `${fmt(min)}-${fmt(max)} ${unit}`;
|
||||
}
|
||||
|
||||
// Model Details & Files
|
||||
|
||||
/**
|
||||
* Avatar URL for an author (org or user). 404s when the author does not
|
||||
* exist, so callers should provide a fallback.
|
||||
@@ -389,16 +412,20 @@ export class HuggingFaceService {
|
||||
* base model. Results are cached per repo.
|
||||
*/
|
||||
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
|
||||
const cached = this.baseModelCache.get(repoId);
|
||||
// llama.cpp model ids carry the quant tag after a colon
|
||||
// (`org/repo:Q4_K_XL`); the HF repo id is the part before it, and all
|
||||
// quants of one repo share the cached lookup
|
||||
const [hfRepoId] = repoId.split(':');
|
||||
const cached = this.baseModelCache.get(hfRepoId);
|
||||
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const pending = this.baseModelPending.get(repoId);
|
||||
const pending = this.baseModelPending.get(hfRepoId);
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
const details = await this.getDetails(repoId);
|
||||
const details = await this.getDetails(hfRepoId);
|
||||
const base = this.getBaseModels(details)[0];
|
||||
|
||||
if (!base) return null;
|
||||
@@ -408,11 +435,11 @@ export class HuggingFaceService {
|
||||
return { name: rest.join(PATH_SEPARATOR), org };
|
||||
})();
|
||||
|
||||
this.baseModelPending.set(repoId, promise);
|
||||
this.baseModelPending.set(hfRepoId, promise);
|
||||
|
||||
promise
|
||||
.then((result) => this.baseModelCache.set(repoId, result))
|
||||
.finally(() => this.baseModelPending.delete(repoId));
|
||||
.then((result) => this.baseModelCache.set(hfRepoId, result))
|
||||
.finally(() => this.baseModelPending.delete(hfRepoId));
|
||||
|
||||
return promise;
|
||||
}
|
||||
@@ -476,26 +503,57 @@ export class HuggingFaceService {
|
||||
return (await response.json()) as HfCatalogEntry[];
|
||||
}
|
||||
|
||||
static async getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
|
||||
// Do not encode the modelId, it contains slashes for author/name.
|
||||
// `full=true` includes cardData (description, base_model) and safetensors.
|
||||
const url = `${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}?${HF_FULL_DETAIL_PARAM}`;
|
||||
static getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
|
||||
const cached = HuggingFaceService.detailsCache.get(modelId);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
if (response.status === HF_HTTP_NOT_FOUND) return null;
|
||||
const pending = HuggingFaceService.detailsPending.get(modelId);
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
|
||||
if (pending) return pending;
|
||||
|
||||
const data = (await response.json()) as HfModelDetailInfo;
|
||||
const promise = (async () => {
|
||||
// Do not encode the modelId, it contains slashes for author/name.
|
||||
// `full=true` includes cardData (description, base_model) and safetensors.
|
||||
const url = `${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}?${HF_FULL_DETAIL_PARAM}`;
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching details for ${modelId}:`, error);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
return null;
|
||||
}
|
||||
// a missing model is a definitive answer, cache it
|
||||
if (response.status === HF_HTTP_NOT_FOUND) {
|
||||
HuggingFaceService.detailsCache.set(modelId, null);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch model details: ${response.status}`);
|
||||
|
||||
// the hub answers with a list of provider entries for one repo, and the
|
||||
// gguf and card data sit on the entry that carries them
|
||||
const payload = (await response.json()) as HfModelDetailInfo | HfModelDetailInfo[];
|
||||
const entries = Array.isArray(payload) ? payload : [payload];
|
||||
const data = entries.find((entry) => entry?.gguf ?? entry?.cardData) ?? entries[0];
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
HuggingFaceService.detailsCache.set(modelId, data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// not cached: a rate limited or failed fetch should retry on the
|
||||
// next mount instead of hiding the model for the whole session
|
||||
console.error(`Error fetching details for ${modelId}:`, error);
|
||||
|
||||
return null;
|
||||
} finally {
|
||||
HuggingFaceService.detailsPending.delete(modelId);
|
||||
}
|
||||
})();
|
||||
|
||||
HuggingFaceService.detailsPending.set(modelId, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -505,8 +563,6 @@ export class HuggingFaceService {
|
||||
return `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}`;
|
||||
}
|
||||
|
||||
// Utility Methods
|
||||
|
||||
/**
|
||||
* Get most liked GGUF models
|
||||
*/
|
||||
@@ -514,6 +570,8 @@ export class HuggingFaceService {
|
||||
return this.search({ limit, sort: HfModelSort.LIKES });
|
||||
}
|
||||
|
||||
// Utility Methods
|
||||
|
||||
/**
|
||||
* Get newly released GGUF models
|
||||
*/
|
||||
@@ -555,19 +613,28 @@ export class HuggingFaceService {
|
||||
* repos that keep quants in per-quant subdirectories (e.g. `UD-Q4_K_XL/`)
|
||||
* are included; follows cursor pagination for repos over one page.
|
||||
*/
|
||||
static async getTree(modelId: string): Promise<HfModelSibling[]> {
|
||||
const files: HfModelSibling[] = [];
|
||||
const firstUrl =
|
||||
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
|
||||
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
|
||||
static getTree(modelId: string): Promise<HfModelSibling[]> {
|
||||
const cached = HuggingFaceService.treeCache.get(modelId);
|
||||
|
||||
let url: string | null = firstUrl;
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const pending = HuggingFaceService.treePending.get(modelId);
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
const files: HfModelSibling[] = [];
|
||||
const firstUrl =
|
||||
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
|
||||
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
|
||||
|
||||
let url: string | null = firstUrl;
|
||||
|
||||
try {
|
||||
for (let page = 0; url && page < HF_TREE_MAX_PAGES; page++) {
|
||||
const response: Response = await fetch(url);
|
||||
|
||||
if (!response.ok) return files;
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch tree for ${modelId}: ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as HfModelSibling[];
|
||||
|
||||
@@ -575,11 +642,24 @@ export class HuggingFaceService {
|
||||
|
||||
url = HuggingFaceService.parseNextPageUrl(response.headers.get(HF_LINK_HEADER));
|
||||
}
|
||||
} catch {
|
||||
// Return whatever was fetched before the failure.
|
||||
}
|
||||
|
||||
return files;
|
||||
HuggingFaceService.treeCache.set(modelId, files);
|
||||
|
||||
return files;
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
// not cached: a rate limited or failed fetch should retry on the
|
||||
// next mount; an empty tree makes the store fall back to the
|
||||
// catalog's advertised sizes
|
||||
console.error(`Error fetching tree for ${modelId}:`, error);
|
||||
|
||||
return [] as HfModelSibling[];
|
||||
})
|
||||
.finally(() => HuggingFaceService.treePending.delete(modelId));
|
||||
|
||||
HuggingFaceService.treePending.set(modelId, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -589,6 +669,14 @@ export class HuggingFaceService {
|
||||
return this.search({ limit, sort: HfModelSort.TRENDING_SCORE });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter count a repo reports: a GGUF repo spells it out in its metadata,
|
||||
* a transformers repo reports it in its SafeTensors index.
|
||||
*/
|
||||
static parameterCount(details?: HfModelDetailInfo | null): number | null {
|
||||
return details?.gguf?.total ?? details?.safetensors?.total ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a local HF cache file path
|
||||
* (`.../models--<org>--<name>/snapshots/<sha>/<file>`) into its repo id and
|
||||
|
||||
+7
@@ -154,6 +154,13 @@ export interface HfModelDetailInfo extends HfModelInfo {
|
||||
cardData?: HfModelCardData;
|
||||
/** GGUF metadata (only present when full=true for GGUF repos) */
|
||||
gguf?: HfModelGguf;
|
||||
/** SafeTensors counts (only present when full=true for transformers repos) */
|
||||
safetensors?: {
|
||||
/** Total parameter count */
|
||||
total?: number;
|
||||
/** Per dtype parameter counts */
|
||||
parameters?: Record<string, number>;
|
||||
};
|
||||
/** Model config (only present when full=true) */
|
||||
config?: Record<string, unknown>;
|
||||
/** Total repo storage in bytes (only present when full=true) */
|
||||
|
||||
Vendored
+17
@@ -18,6 +18,8 @@ export interface ModelOption {
|
||||
model: string;
|
||||
description?: string;
|
||||
capabilities: string[];
|
||||
/** Context size reported by the provider's model listing, when it reports one. */
|
||||
contextLength?: number;
|
||||
modalities?: ModelModalities;
|
||||
details?: ApiModelDetails['details'];
|
||||
meta?: ApiModelDataEntry['meta'];
|
||||
@@ -64,3 +66,18 @@ export interface ModalityCapabilities {
|
||||
hasAudio: boolean;
|
||||
hasVideo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft sidecar file a model listing reports as its own entry. The router lists a
|
||||
* downloaded sidecar as a model, so this is what pairs it back with its model.
|
||||
*/
|
||||
export interface ModelSidecarFile {
|
||||
id: string;
|
||||
kind: ModelSidecar;
|
||||
/** Repo the sidecar belongs to, e.g. `ggml-org/Qwen3.6-35B-A3B-GGUF`. */
|
||||
model: string;
|
||||
/** Parameter count the sidecar reports, e.g. `35B-A3B`. */
|
||||
params: string | null;
|
||||
/** Quantization of the sidecar file, e.g. `Q4_0`. */
|
||||
quant: string | null;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export {
|
||||
} from './modality-file-validation';
|
||||
|
||||
// Model name utilities
|
||||
export { normalizeModelName, isValidModelName } from './model-names';
|
||||
export { isValidModelName, normalizeModelName, orgOf } from './model-names';
|
||||
|
||||
// Sidecar token utilities
|
||||
export { isAuxSidecar, isDraftSidecar, sidecarFromFileToken } from './sidecars';
|
||||
@@ -360,3 +360,4 @@ export { remToPx } from './css';
|
||||
|
||||
// Audio format helper (used by agentic store and chat service)
|
||||
export { getAudioInputFormat } from './audio-format';
|
||||
export { groupModelFamilies, modelFamilyKey, type ModelFamilyGroup } from './model-families';
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** One family of a model list, and the entries it covers. */
|
||||
export interface ModelFamilyGroup<T> {
|
||||
entries: T[];
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Family a repo belongs to, from its name. The version is dropped, so `Qwen3.5`,
|
||||
* `Qwen3.8-27B` and `Qwen3.8-Flash-Next` all read as `Qwen`. A name that does not
|
||||
* start with letters keeps its first segment.
|
||||
*/
|
||||
export function modelFamilyKey(repo: string): string {
|
||||
const name = repo.split('/').pop() ?? repo;
|
||||
const letters = name.match(/^[A-Za-z]+/);
|
||||
|
||||
return letters ? letters[0] : (name.split(/[-_.]/)[0] ?? name);
|
||||
}
|
||||
|
||||
/** Fold entries into families, so `Qwen` collects its sizes and variants. */
|
||||
export function groupModelFamilies<T>(
|
||||
entries: T[],
|
||||
modelOf: (entry: T) => string
|
||||
): ModelFamilyGroup<T>[] {
|
||||
const families = new Map<string, ModelFamilyGroup<T>>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = modelFamilyKey(modelOf(entry));
|
||||
const family = families.get(key);
|
||||
|
||||
if (family) {
|
||||
family.entries.push(entry);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
families.set(key, { entries: [entry], key, label: key });
|
||||
}
|
||||
|
||||
return Array.from(families.values());
|
||||
}
|
||||
Reference in New Issue
Block a user