ui : drop an orphaned doc comment left by a moved method

Assisted-by: pi:zai-org/GLM-5.3-Flash
This commit is contained in:
Aleksander Grygier
2026-10-01 16:42:02 +02:00
parent 9059a02e7a
commit 39f5b368fd
63 changed files with 1072 additions and 1219 deletions
+5 -9
View File
@@ -404,8 +404,8 @@ bool server_http_context::init_listener(const common_params & params) {
static constexpr auto cache_revalidate = "no-cache";
// Serves an asset with ETag/304 handling, under the given caching policy.
auto serve_asset_cached = [](const std::string & name, bool isolation, const char * cache_control) {
return [name, isolation, cache_control](const httplib::Request & req, httplib::Response & res) {
auto serve_asset_cached = [](const std::string & name, const char * cache_control) {
return [name, cache_control](const httplib::Request & req, httplib::Response & res) {
if (!handle_gzip_header(req, res)) {
return true; // returns error message
}
@@ -417,10 +417,6 @@ bool server_http_context::init_listener(const common_params & params) {
res.status = 304;
return false;
}
if (isolation) {
res.set_header("Cross-Origin-Embedder-Policy", "require-corp");
res.set_header("Cross-Origin-Opener-Policy", "same-origin");
}
res.set_header("Cache-Control", cache_control);
res.set_content(reinterpret_cast<const char*>(a->data), a->size, a->type.c_str());
return false;
@@ -444,8 +440,8 @@ bool server_http_context::init_listener(const common_params & params) {
};
// main index file -- revalidated, so a new build is picked up on the next load
srv->Get(params.api_prefix + "/", serve_asset_cached("index.html", true, cache_revalidate));
srv->Get(params.api_prefix + "/index.html", serve_asset_cached("index.html", true, cache_revalidate));
srv->Get(params.api_prefix + "/", serve_asset_cached("index.html", cache_revalidate));
srv->Get(params.api_prefix + "/index.html", serve_asset_cached("index.html", cache_revalidate));
// All remaining assets registered directly from the embedded asset table.
// PWA revalidation files (sw.js, manifest, version.json) use no-cache;
@@ -463,7 +459,7 @@ bool server_http_context::init_listener(const common_params & params) {
SRV_DBG("serve nocache for %s\n", a.name.c_str());
srv->Get(params.api_prefix + "/" + a.name, serve_asset_nocache(a.name));
} else {
srv->Get(params.api_prefix + "/" + a.name, serve_asset_cached(a.name, false, cache_immutable));
srv->Get(params.api_prefix + "/" + a.name, serve_asset_cached(a.name, cache_immutable));
}
}
@@ -65,8 +65,7 @@
<DropdownMenu.Trigger
{...props}
class={cn(
// the composer sits on a muted surface, so this keeps a lighter one:
// ghost brings no fill of its own for the variant to fight over
// ghost brings no fill of its own for the muted composer surface to fight over
buttonVariants({ variant: 'ghost' }),
'file-upload-button h-8 w-8 cursor-pointer rounded-full bg-background p-0 shadow-sm dark:bg-muted-foreground/15'
)}
@@ -1,14 +1,6 @@
<script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import {
backendsModelsStore,
backendsStore,
conversationsStore,
deviceStore,
modelsStore,
serverStore
} from '$lib/stores';
import { conversationsStore, deviceStore, modelsStore, serverStore } from '$lib/stores';
import { getConversationModel } from '$lib/utils';
interface Props {
@@ -36,14 +28,7 @@
}: Props = $props();
let isRouter = $derived(serverStore.isRouterMode);
// the provider this selector is pointed at, which is the one its colours report
let selectorError = $derived.by(() => {
const backendId = backendsStore.active.id;
return backendId === LOCAL_BACKEND_ID
? Boolean(serverStore.error)
: backendsModelsStore.get(backendId).error !== null;
});
let isOffline = $derived(!!serverStore.error);
let conversationModel = $derived(
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
@@ -67,16 +52,10 @@
$effect(() => {
if (conversationModel && conversationModel !== lastSyncedConversationModel) {
const option = modelsStore.models.find((m) => m.model === conversationModel);
// only sync models served by the active backend; a model from another
// backend must not yank the active tab (and trigger a full backend
// switch) just because the conversation used it. sends resolve their
// backend explicitly via ensureModelBackend
if (option && option.backendId === backendsStore.active.id) {
if (modelsStore.models.some((m) => m.model === conversationModel)) {
modelsStore.selectedModelName = conversationModel;
modelsStore.selectModelByName(conversationModel);
} else if (!option) {
} else {
modelsStore.selectedModelName = null;
modelsStore.clearSelection();
}
@@ -176,8 +155,7 @@
<ModelsSelectorSheet
bind:this={selectorModelRef}
currentModel={selectorModel}
{disabled}
error={selectorError}
disabled={disabled || isOffline}
{forceForegroundText}
{useGlobalSelection}
/>
@@ -185,8 +163,7 @@
<ModelsSelectorDropdown
bind:this={selectorModelRef}
currentModel={selectorModel}
{disabled}
error={selectorError}
disabled={disabled || isOffline}
{forceForegroundText}
{useGlobalSelection}
/>
@@ -13,8 +13,7 @@
let isDefault = $derived(reasoning.currentEffort === ReasoningEffort.DEFAULT);
</script>
<!-- Reasoning effort sits beside the model selector: the level belongs to the chat, not
to the model list, and the label says which one is in force without opening it. -->
<!-- the level belongs to the chat, not to the model list -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
@@ -1,12 +1,10 @@
<script lang="ts">
import { FolderOpen, Sparkles } from '@lucide/svelte';
import {
ChatFormPickerList,
ChatFormPickerListItem,
ChatFormPickerPopover
} from '$lib/components/app/chat';
import { MODEL_ICON } from '$lib/constants';
import { ChatFormCommandAction } from '$lib/enums';
import { CHAT_FORM_COMMAND_ICONS } from '$lib/constants';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import type { ChatFormCommand } from '$lib/types';
@@ -26,12 +24,6 @@
let { class: className = '', commands, isOpen, onClose, onSelect, query }: Props = $props();
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
[ChatFormCommandAction.CWD]: FolderOpen,
[ChatFormCommandAction.MODEL]: MODEL_ICON,
[ChatFormCommandAction.PROMPT]: Sparkles
};
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
const filteredCommands = $derived(
@@ -119,7 +111,7 @@
showSearchInput={false}
>
{#snippet item(command, index, isSelected)}
{@const Icon = commandIcon[command.action]}
{@const Icon = CHAT_FORM_COMMAND_ICONS[command.action]}
<ChatFormPickerListItem
dataIndex={index}
disabled={command.disabled}
@@ -2,17 +2,10 @@
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import * as Alert from '$lib/components/ui/alert';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { useBackendAvailability } from '$lib/hooks/use-backend-availability.svelte';
import { backendsStore, serverStore } from '$lib/stores';
const availability = useBackendAvailability();
import { serverStore } from '$lib/stores';
let hasError = $derived(!!serverStore.error);
let isLoadingModel = $derived(serverStore.status === 503);
// A single provider leaves nowhere to switch to, so the banner is the only
// place the failure can be reported. With another provider available the
// selector carries it instead, and stays usable.
let isOnlyProvider = $derived(backendsStore.enabled.length <= 1);
let hasError = $derived(availability.isOffline && isOnlyProvider);
</script>
{#if hasError || isLoadingModel}
@@ -221,7 +221,6 @@ export { default as ChatFormActionReasoning } from './ChatForm/ChatFormActions/C
*/
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/**
* Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip.
*/
@@ -36,12 +36,8 @@
</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.
*/
/* `interpolate-size` lets `height: auto` take part in the transition, so the content
needs no measured height. Older engines fall back to an interpolating grid row. */
.collapsible-region {
height: 0;
/* clip, not hidden: hidden would make the region a scrollport, and the
@@ -6,14 +6,13 @@
import type { Snippet } from 'svelte';
interface Props {
/** Collapsible content. */
children: Snippet;
/** Trigger content; this component appends the chevron. */
/** Trigger content; the chevron is appended. */
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. */
/** Start expanded. The section owns the state, so a parent rebuild cannot snap
* it back open; bind `open` to control it from outside. */
defaultOpen?: boolean;
/** Controlled open state, for a caller that binds it. */
open?: boolean;
@@ -5,6 +5,14 @@
group: G;
key: string;
}
/** What a show-more row counts: rows of one group, or whole groups of the list. */
export const GroupedListUnit = {
ENTRIES: 'entries',
GROUPS: 'families'
} as const;
export type GroupedListUnit = (typeof GroupedListUnit)[keyof typeof GroupedListUnit];
</script>
<script generics="G, E" lang="ts">
@@ -29,7 +37,7 @@
/** 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' }]>;
more?: Snippet<[{ count: number; onMore: () => void; unit: GroupedListUnit }]>;
/** 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. */
@@ -57,9 +65,8 @@
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
// groups start open: this tracks the ones the user folded away, and a state key
// restores the ones folded away before the reload
// svelte-ignore state_referenced_locally
const collapsed = new SvelteSet<string>(
groupStateKey ? modelsStore.collapsedGroupsUnder(groupStateKey) : []
@@ -139,7 +146,7 @@
{@render more({
count: Math.min(groupWindow, entry.hidden),
onMore: () => growGroup(entry.key),
unit: 'entries'
unit: GroupedListUnit.ENTRIES
})}
{/if}
</CollapsibleRegion>
@@ -149,7 +156,7 @@
{@render more({
count: Math.min(sectionWindow, (groups?.length ?? 0) - windowed.length),
onMore: () => (sectionSteps += 1),
unit: 'families'
unit: GroupedListUnit.GROUPS
})}
{/if}
{:else}
@@ -161,7 +168,7 @@
{@render more({
count: Math.min(sectionWindow, items.length - flatRows.length),
onMore: () => (sectionSteps += 1),
unit: 'entries'
unit: GroupedListUnit.ENTRIES
})}
{/if}
{/if}
@@ -60,12 +60,11 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
*/
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.
*/
/** Section with a trigger that expands in place. */
export { default as CollapsibleSection } from './CollapsibleSection.svelte';
export { default as GroupedList, type GroupedListGroup } from './GroupedList.svelte';
export {
default as GroupedList,
type GroupedListGroup,
GroupedListUnit
} from './GroupedList.svelte';
export { default as CollapsibleRegion } from './CollapsibleRegion.svelte';
@@ -1,5 +1,5 @@
<script lang="ts">
import ModelsDiscoverAvatar from './discover/ModelsDiscoverAvatar.svelte';
import ModelOrgAvatar from './ModelOrgAvatar.svelte';
import { HF_BASE_MODEL_TAG_REGEX } from '$lib/constants';
import { HuggingFaceService, ModelsService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
@@ -13,9 +13,8 @@
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. */
/** Show the base model's org as the main image, the repo (quantizer) org as the
* corner badge. The base org costs one Hub request per repo. */
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. */
@@ -44,8 +43,8 @@
.find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
?.match(HF_BASE_MODEL_TAG_REGEX)?.[1] ?? null
);
// Long lists mount hundreds of avatars at once; resolving every base model up
// front means one request per row, so the avatar waits until its row is near the viewport.
// long lists mount hundreds of avatars at once, so the base org lookup waits
// until the row is near the viewport
let fetchedBaseModelOrg = $state<string | null>(null);
let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
let isNearViewport = $state(false);
@@ -75,7 +74,7 @@
use:nearViewport={() => (isNearViewport = true)}
class={['inline-flex shrink-0', className]}
>
<ModelsDiscoverAvatar
<ModelOrgAvatar
class="mt-0"
org={showRepoOrgAvatar ? orgName : (baseModelOrg ?? orgName)}
quantOrg={showBaseModelAvatar && showQuantBadge && !showRepoOrgAvatar ? orgName : undefined}
@@ -1,6 +1,6 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import { isLocalOption } from './ModelsManager/utils';
import { MODEL_ID } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
@@ -21,14 +21,13 @@
$effect(() => {
template = '';
if (!isNearViewport || !isLocalOption(option)) return;
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] ?? '';
// only the chat template says whether tools and reasoning work, and the Hub
// carries it for a local GGUF whether the model is loaded or not
const repo = option.model.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0] ?? '';
if (!repo.includes('/')) return;
if (!repo.includes(MODEL_ID.ORG_SEPARATOR)) return;
let cancelled = false;
@@ -1,6 +1,14 @@
<script lang="ts">
import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
CAPABILITY_ICONS,
CAPABILITY_LABELS,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS,
MODALITY_ORDER
} from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import type { ModelModalities } from '$lib/types/models';
interface Props {
@@ -23,71 +31,48 @@
supportsToolUse = false
}: Props = $props();
let hasModalityIcons = $derived(modalities?.vision || modalities?.video || modalities?.audio);
let capabilities = $derived([
...(supportsToolUse ? [ModelCapability.TOOL_USE] : []),
...(supportsThinking ? [ModelCapability.REASONING] : [])
]);
let shownModalities = $derived(
MODALITY_ORDER.filter((modality) => modalities?.[MODALITY_FLAG_KEYS[modality]])
);
</script>
<span class="inline-flex items-center {gapClass}">
{#if supportsToolUse && !hideCapabilities}
<Tooltip.Root>
<Tooltip.Trigger>
<Wrench class="{iconSize} text-muted-foreground" />
</Tooltip.Trigger>
{#if !hideCapabilities}
{#each capabilities as capability (capability)}
{@const Icon = CAPABILITY_ICONS[capability]}
<Tooltip.Content>
<p>Tool use</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger>
<Icon class="{iconSize} text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>{CAPABILITY_LABELS[capability]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
{/if}
{#if supportsThinking && !hideCapabilities}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="{iconSize} text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if hasModalityIcons && !hideModalities}
{#if shownModalities.length > 0 && !hideModalities}
<span class="inline-flex items-center {gapClass} text-muted-foreground">
{#if modalities?.vision}
{#each shownModalities as modality (modality)}
{@const Icon = MODALITY_ICONS[modality]}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class={iconSize} />
<Icon class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
<p>{MODALITY_LABELS[modality]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.video}
<Tooltip.Root>
<Tooltip.Trigger>
<Video class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Video</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<Mic class={iconSize} />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Audio</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
</span>
{/if}
</span>
@@ -1,9 +1,9 @@
<script lang="ts">
import { isLocalOption } from './ModelsManager/utils';
import { MODEL_ID } from '$lib/constants';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { nearViewport } from '$lib/utils';
import { formatParameters } from '$lib/utils/formatters';
import { formatContextLength } from '$lib/utils/formatters';
interface Props {
class?: string;
@@ -22,10 +22,10 @@
$effect(() => {
fetched = null;
if (reported || !isNearViewport || !isLocalOption(option)) return;
if (reported || !isNearViewport) return;
// a local GGUF carries its trained context in the model metadata
const repo = option.model.split(':')[0] ?? '';
const repo = option.model.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0] ?? '';
if (!repo) return;
@@ -44,7 +44,6 @@
});
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')
);
@@ -54,5 +53,5 @@
use:nearViewport={() => (isNearViewport = true)}
class={['text-sm text-muted-foreground', className]}
>
{values.length ? `${values.map((value) => formatParameters(value)).join(' / ')} tokens` : '-'}
{formatContextLength(values)}
</span>
@@ -1,25 +1,45 @@
<script lang="ts">
import { MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
import { MODEL_BADGE_CLASS, MODEL_VARIANT_BADGE_CLASS } from '$lib/constants';
import type { ModelSidecarBadge } from '$lib/types/models';
import { isAuxSidecar } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
/** Draft sidecars available for the model. */
/** Draft sidecars available for the model, each with its own quant. */
draftSidecars?: ModelSidecarBadge[];
}
let { draftSidecars = [] }: Props = $props();
let kinds = $derived(
[...new SvelteSet(draftSidecars.map((badge) => badge.kind))].filter(
(kind) => !isAuxSidecar(kind)
)
);
// the router listing and the --model-draft args both report a model's drafts, so
// the same sidecar can arrive twice
let badges = $derived.by(() => {
const seen = new SvelteSet<string>();
return draftSidecars.filter((badge) => {
if (isAuxSidecar(badge.kind)) return false;
const key = `${badge.kind}:${badge.quant ?? ''}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
});
</script>
{#each kinds as kind (kind)}
<span class={MODEL_VARIANT_BADGE_CLASS} title={`${kind.toUpperCase()} draft model available`}>
{kind}
{#each badges as badge (badge.kind + (badge.quant ?? ''))}
<span aria-hidden="true" class="text-muted-foreground">+</span>
<span class={MODEL_VARIANT_BADGE_CLASS} title="{badge.kind.toUpperCase()} draft model">
{badge.kind}
</span>
{#if badge.quant}
<span class={MODEL_BADGE_CLASS} title="{badge.kind.toUpperCase()} draft model at {badge.quant}">
{badge.quant}
</span>
{/if}
{/each}
@@ -116,8 +116,8 @@
{/if}
{#if hasBadges}
<!-- 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 -->
<!-- the badges do not shrink, so the group clips them instead of letting
them paint over the row actions -->
<span
class="inline-flex min-w-0 items-center gap-1 overflow-hidden {wrap ? 'flex-wrap' : ''}"
>
@@ -170,8 +170,7 @@
{/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 -->
<!-- badges, tags and icons do not shrink, so overflow-hidden bounds the id -->
<span
class="flex min-w-0 items-center gap-1.5 overflow-hidden {wrap
? 'flex-wrap'
@@ -1,7 +1,6 @@
<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 { ICON_CLASS_DEFAULT, MODEL_LOAD_ICONS } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
@@ -32,12 +31,12 @@
<div class={['flex w-5 shrink-0 items-center justify-center', className]}>
{#if isLoading}
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
<MODEL_LOAD_ICONS.loading 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
<MODEL_LOAD_ICONS.failed
class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
/>
{:else}
@@ -51,7 +50,7 @@
{/if}
{:else}
{#if isFailed}
<CircleAlert class="h-3.5 w-3.5 text-red-500" />
<MODEL_LOAD_ICONS.failed class="h-3.5 w-3.5 text-red-500" />
{:else}
<span
class="h-2 w-2 rounded-full {isSleeping
@@ -75,7 +74,7 @@
{#if isFailed}
<ActionIcon
class="h-5 w-5 text-red-500 hover:text-foreground"
icon={RotateCw}
icon={MODEL_LOAD_ICONS.retry}
iconSize="h-4 w-4"
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
@@ -85,7 +84,7 @@
{:else if isLoaded || isSleeping}
<ActionIcon
class="h-5 w-5 hover:text-foreground"
icon={Eject}
icon={MODEL_LOAD_ICONS.unload}
iconSize="h-4 w-4"
onclick={() => modelsStore.status.unload(option.model)}
stopPropagationOnClick
@@ -95,7 +94,7 @@
{:else}
<ActionIcon
class="h-5 w-5 hover:text-foreground"
icon={Power}
icon={MODEL_LOAD_ICONS.load}
iconSize="h-4 w-4"
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
@@ -29,8 +29,11 @@
size = 'h-9 w-9'
}: Props = $props();
let orgAvatarFailed = $derived(failedAvatarOrgs.has(org));
let quantAvatarFailed = $derived(failedAvatarOrgs.has(quantOrg ?? ''));
// with the Hub off there is nothing to fetch: render the monogram straight away
let orgAvatarFailed = $derived(!HuggingFaceService.isEnabled() || failedAvatarOrgs.has(org));
let quantAvatarFailed = $derived(
!HuggingFaceService.isEnabled() || failedAvatarOrgs.has(quantOrg ?? '')
);
let invertAvatar = $derived(DARK_INVERT_AVATAR_ORGS.includes(org));
let invertQuant = $derived(DARK_INVERT_AVATAR_ORGS.includes(quantOrg ?? ''));
@@ -1,20 +1,23 @@
<script lang="ts">
import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte';
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
import {
groupModelQuants,
type ModalityKey,
modelContextLength,
type ModelQuantGroup,
MODELS_TABLE_GROUP_LABELS,
type ModelsTableGroup,
ModelsTableGroupKind,
modelSupports
} from './utils';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import {
LOCAL_BACKEND_ID,
type ModalityKey,
MODELS_TABLE_GROUP_LABELS,
ModelsTableGroupKind
} from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { modelsStore, uiStore } from '$lib/stores';
import { conversationsStore, modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { type Snippet } from 'svelte';
import { type Snippet, untrack } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
interface Props {
@@ -32,6 +35,68 @@
let selectedId = $state<string | null>(null);
let allModels = $derived(modelsStore.models);
let selected = $derived(allModels.find((option) => option.id === selectedId) ?? null);
// The pane is laid out before it is ever opened, so the first open only slides a
// finished panel in. It renders the selection, else the model it last showed.
let lastPicked = $state<ModelOption | null>(null);
let target = $derived(selected ?? lastPicked ?? allModels[0] ?? null);
let shownId = $state<string | null>(null);
let isSwapping = $state(false);
let fade = $state<'open' | 'swap'>('open');
let shownOption = $derived(allModels.find((option) => option.id === shownId) ?? null);
$effect(() => {
const id = selectedId;
if (!id) return;
// untracked: the effect must not track the state it writes
untrack(() => {
lastPicked = allModels.find((option) => option.id === id) ?? null;
});
});
// Another model fades the panel out, swaps it, then fades it back in.
$effect(() => {
const next = target?.id ?? null;
if (!next) return;
if (shownId === null) {
untrack(() => (shownId = next));
return;
}
if (next === shownId) {
if (selected) {
untrack(() => {
isSwapping = false;
fade = 'open';
});
}
return;
}
untrack(() => {
isSwapping = true;
fade = 'swap';
});
const timer = setTimeout(() => {
untrack(() => {
shownId = next;
isSwapping = false;
});
}, SWAP_FADE_MS);
return () => clearTimeout(timer);
});
/** How long the panel takes to fade out before it swaps to another model. */
const SWAP_FADE_MS = 120;
let isFavorite = $derived((option: ModelOption) =>
modelsStore.favoriteModelIds.has(option.model)
);
@@ -59,8 +124,7 @@
});
});
// recently used models lead their section, the rest keep the server's order;
// derived, so a pick made while the manager is open reorders the sections
// recently used models lead their section, the rest keep the server's order
let rank = $derived.by(() => {
const map = new SvelteMap<string, number>();
@@ -73,12 +137,10 @@
Math.min(...entry.quants.map((quant) => rank.get(quant.id) ?? Number.MAX_SAFE_INTEGER));
const byRecency = (list: ModelQuantGroup[]) =>
rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
// one entry per repo, so a model with several quants is a single table row;
// loaded models lead the table, then favorites, then the local block
// one entry per repo, so a model with several quants takes a single table row
let entries = $derived(byRecency(groupModelQuants(matching)));
let groups = $derived.by(() => {
// A loaded quant is a model of its own: it moves to the loaded section, and
// the quants of its repo that are not loaded stay behind as that repo.
// a loaded quant is a model of its own: its repo keeps the quants left behind
const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
const loaded: ModelQuantGroup[] = [];
const rest: ModelQuantGroup[] = [];
@@ -140,6 +202,22 @@
uiStore.manageModelFocus = null;
});
async function toggleLoad(option: ModelOption): Promise<void> {
if (modelsStore.isModelLoaded(option.model)) {
await modelsStore.status.unload(option.model);
return;
}
await modelsStore.status.load(option.model);
}
async function useInNewChat(option: ModelOption): Promise<void> {
await modelsStore.selectModelById(option.id);
await conversationsStore.openNewChat();
// the chat is behind the dialog, so it takes focus once the dialog is out of the way
uiStore.requestComposerFocus();
}
</script>
<div class={['flex min-h-0 flex-1', className]}>
@@ -156,4 +234,68 @@
{toolbarEnd}
/>
</div>
<div class="pane-drawer shrink-0" data-open={selected !== null}>
<!-- the content box keeps the open width, so it never reflows with the drawer -->
<div
class="pane-content flex h-full min-h-0 w-[30rem] max-w-[30rem] flex-col border-l border-border/40"
data-fade={fade}
data-visible={selected !== null && !isSwapping}
>
{#if shownOption}
{#key shownId}
<ModelsManagerModelConfiguration
onClose={() => (selectedId = null)}
onToggleLoad={() => void toggleLoad(shownOption)}
onUseInNewChat={() => void useInNewChat(shownOption)}
option={shownOption}
/>
{/key}
{/if}
</div>
</div>
</div>
<style>
/*
* The drawer moves by width because the table behind it gets that space back, so
* the content box inside holds the open width and only the container changes.
* Opening takes the iOS-like drawer curve; closing is the system responding, so
* it snaps back on the stronger ease-out.
*/
.pane-drawer {
width: 0;
overflow: clip;
visibility: hidden;
transition:
width 120ms cubic-bezier(0.23, 1, 0.32, 1),
visibility 120ms;
}
.pane-drawer[data-open='true'] {
width: 30rem;
visibility: visible;
transition:
width 200ms cubic-bezier(0.32, 0.72, 0, 1),
visibility 200ms;
}
.pane-content {
opacity: 0;
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
.pane-content[data-visible='true'] {
opacity: 1;
}
/* opening: the fade waits for the drawer to move */
.pane-content[data-visible='true'][data-fade='open'] {
transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1) 80ms;
}
/* swapping models: out, then in, with no pause */
.pane-content[data-visible='true'][data-fade='swap'] {
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
</style>
@@ -1,10 +1,18 @@
<script lang="ts">
import { MODALITY_KEYS, type ModalityKey } from './utils';
import { Image, Lightbulb, Mic, Video, Wrench } from '@lucide/svelte';
import { ScrollCarousel } from '$lib/components/app';
import * as Select from '$lib/components/ui/select';
import * as ToggleGroup from '$lib/components/ui/toggle-group';
import { FILTER_TRIGGER_CLASS } from '$lib/constants';
import {
CAPABILITY_ICONS,
CAPABILITY_LABELS,
FILTER_TOGGLE_ITEM_CLASS,
FILTER_TRIGGER_CLASS,
MODALITY_FLAG_KEYS,
MODALITY_ICONS,
MODALITY_LABELS,
MODALITY_ORDER,
type ModalityKey
} from '$lib/constants';
import { ModelCapability } from '$lib/enums';
interface Props {
@@ -31,34 +39,29 @@
{ label: '1M or more', value: 1_048_576 }
];
const CAPABILITY_TOGGLES: { icon: typeof Wrench; label: string; value: ModelCapability }[] = [
{ icon: Wrench, label: 'Tool use', value: ModelCapability.TOOL_USE },
{ icon: Lightbulb, label: 'Reasoning', value: ModelCapability.REASONING }
];
const CAPABILITY_TOGGLES = [ModelCapability.TOOL_USE, ModelCapability.REASONING].map((value) => ({
icon: CAPABILITY_ICONS[value],
label: CAPABILITY_LABELS[value],
value
}));
const MODALITY_TOGGLES: { icon: typeof Wrench; label: string; value: ModalityKey }[] = [
{ icon: Image, label: 'Vision', value: 'vision' },
{ icon: Video, label: 'Video', value: 'video' },
{ icon: Mic, label: 'Audio', value: 'audio' }
];
const MODALITY_TOGGLES = MODALITY_ORDER.map((modality) => ({
icon: MODALITY_ICONS[modality],
label: MODALITY_LABELS[modality],
value: MODALITY_FLAG_KEYS[modality]
}));
// one group holds everything a model either has or does not: what it can do,
// and what it can accept
// one group holds what a model can do and what it can accept
const TOGGLES = [...CAPABILITY_TOGGLES, ...MODALITY_TOGGLES];
const CAPABILITY_VALUES = new Set<string>(CAPABILITY_TOGGLES.map((entry) => entry.value));
const MODALITY_VALUES = new Set<string>(MODALITY_TOGGLES.map((entry) => entry.value));
// the group holds one flat list, so a change splits back into the two filters
function setToggles(values: string[]): void {
capabilities = values.filter((value): value is ModelCapability => CAPABILITY_VALUES.has(value));
modalities = values.filter(
(value): value is ModalityKey =>
!CAPABILITY_VALUES.has(value) && (MODALITY_KEYS as string[]).includes(value)
);
modalities = values.filter((value): value is ModalityKey => MODALITY_VALUES.has(value));
}
const TOGGLE_ITEM_CLASS =
'bg-muted! border-border/30! shadow-none! dark:border-border/20! data-[state=on]:bg-muted-foreground/15! data-[state=on]:text-foreground! dark:data-[state=on]:bg-muted-foreground/25!';
let contextLabel = $derived(
CONTEXT_STEPS.find((step) => step.value === contextLimit)?.label ?? CONTEXT_STEPS[0].label
);
@@ -93,7 +96,7 @@
{#each TOGGLES as toggle (toggle.value)}
<ToggleGroup.Item
aria-label={toggle.label}
class={TOGGLE_ITEM_CLASS}
class={FILTER_TOGGLE_ITEM_CLASS}
title={toggle.label}
value={toggle.value}
>
@@ -0,0 +1,73 @@
<script lang="ts">
import ModelsManagerModelConfigurationHeader from './ModelsManagerModelConfigurationHeader.svelte';
import ModelsManagerModelConfigurationInformation from './ModelsManagerModelConfigurationInformation.svelte';
import { MODEL_ID, SETTINGS_KEYS } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import { modelsStore, settingsStore } from '$lib/stores';
import type { HfModelDetailInfo } from '$lib/types/huggingface';
import type { ModelOption } from '$lib/types/models';
interface Props {
onClose: () => void;
onToggleLoad: () => void;
onUseInNewChat: () => void;
option: ModelOption;
}
let { onClose, onToggleLoad, onUseInNewChat, option }: Props = $props();
// the props cache is a plain Map, so the read has to name its version to stay reactive
let serverProps = $derived.by(() => {
void modelsStore.props.cacheVersion;
return modelsStore.props.getModelProps(option.model);
});
let status = $derived.by(() => modelsStore.getModelStatus(option.model));
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isLoaded = $derived(
(status === ServerModelStatus.LOADED || status === ServerModelStatus.SLEEPING) &&
!isOperationInProgress
);
// The server reports the full metadata only once a model is loaded, which loads it.
// With the Hub enabled, it fills those gaps instead.
let hubDetails = $state<HfModelDetailInfo | null>(null);
$effect(() => {
const repo = option.model.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0] ?? option.model;
let cancelled = false;
hubDetails = null;
if (!settingsStore.config[SETTINGS_KEYS.USE_HUGGING_FACE_HUB] || !repo.includes('/')) {
return;
}
void HuggingFaceService.getDetails(repo)
.then((details) => {
if (!cancelled) hubDetails = details;
})
.catch(() => {});
return () => {
cancelled = true;
};
});
</script>
<div class="flex h-full min-h-0 flex-col">
<ModelsManagerModelConfigurationHeader
{isLoaded}
{onClose}
{onToggleLoad}
{onUseInNewChat}
{option}
{status}
/>
<div class="min-h-0 flex-1 overflow-y-auto py-4 pl-4">
<ModelsManagerModelConfigurationInformation hub={hubDetails} {option} {serverProps} />
</div>
</div>
@@ -0,0 +1,119 @@
<script lang="ts">
import { Eject, Loader2, Power, SquarePen, X } from '@lucide/svelte';
import { ModelAvatar, ModelId } from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ServerModelStatus } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
interface Props {
isLoaded: boolean;
onClose: () => void;
onToggleLoad: () => void;
onUseInNewChat: () => void;
option: ModelOption;
/** Load state reported by the server, null when it does not report one. */
status: ServerModelStatus | null;
}
let { isLoaded, onClose, onToggleLoad, onUseInNewChat, option, status }: Props = $props();
let isLoading = $derived(status === ServerModelStatus.LOADING);
let statusLabel = $derived.by(() => {
if (status === ServerModelStatus.LOADING) return 'Loading';
if (status === ServerModelStatus.FAILED) return 'Failed to load';
if (status === ServerModelStatus.SLEEPING) return 'Sleeping';
if (isLoaded) return 'Loaded';
return 'Not loaded';
});
let statusDot = $derived.by(() => {
if (status === ServerModelStatus.FAILED) return 'bg-red-500';
if (status === ServerModelStatus.LOADING) return 'bg-muted-foreground/50 animate-pulse';
if (status === ServerModelStatus.SLEEPING) return 'bg-orange-400';
if (isLoaded) return 'bg-green-500';
return 'bg-muted-foreground/50';
});
</script>
<header class="space-y-2.5 pt-3 pl-4">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<!-- same geometry as the discover details header: base org, quant org badge -->
<ModelAvatar
{option}
quantPositionClass="-bottom-1.5 -right-1.5"
quantSize="h-6 w-6"
showBaseModelAvatar
size="h-12 w-12"
/>
<div class="min-w-0">
<ModelId
aliases={option.aliases}
class="min-w-0"
modalities={option.modalities}
modelId={option.model}
tags={option.tags}
title={option.model}
/>
<p class="mt-1 flex items-center gap-x-2 text-xs text-muted-foreground">
<span class="flex shrink-0 items-center gap-1">
<span class="h-2 w-2 shrink-0 rounded-full {statusDot}"></span>
{statusLabel}
</span>
</p>
</div>
</div>
<Button
aria-label="Close details"
class="h-7 w-7"
onclick={onClose}
size="icon"
variant="ghost"
>
<X class="h-4 w-4" />
</Button>
</div>
<div class="flex gap-2">
<Button class="flex-1 gap-1.5" onclick={onUseInNewChat} size="sm" variant="outline">
<SquarePen class="h-3.5 w-3.5" />
Start a new chat
</Button>
<Button
class="flex-1 gap-1.5"
disabled={isLoading}
onclick={onToggleLoad}
size="sm"
variant="outline"
>
{#if isLoading}
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading...
{:else if isLoaded}
<Eject class="h-3.5 w-3.5" />
Unload model
{:else}
<Power class="h-3.5 w-3.5" />
Load model
{/if}
</Button>
</div>
</header>
@@ -0,0 +1,195 @@
<script lang="ts">
import {
ActionIconCopyToClipboard,
BadgesModality,
CollapsibleSection
} from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
import { HuggingFaceService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ApiLlamaCppServerProps } from '$lib/types/api';
import type { HfModelDetailInfo } from '$lib/types/huggingface';
import type { ModelOption } from '$lib/types/models';
import { formatFileSize, formatNumber, formatParameters } from '$lib/utils/formatters';
interface Props {
/** Hub details, when the Hub is enabled and the server reports none. */
hub?: HfModelDetailInfo | null;
option: ModelOption;
serverProps: ApiLlamaCppServerProps | null | undefined;
}
let { hub = null, option, serverProps }: Props = $props();
let meta = $derived(option.meta);
let gguf = $derived(hub?.gguf ?? null);
let quant = $derived(option.parsedId?.quantization ?? null);
// a GGUF carries the count in its metadata, a transformers repo in its SafeTensors index
let hubParameters = $derived(HuggingFaceService.parameterCount(hub));
let draftSidecars = $derived(option.draftSidecars ?? []);
let modalities = $derived.by(() => {
void modelsStore.props.cacheVersion;
return modelsStore.props.getModelModalitiesArray(option.id);
});
let rows = $derived([
{
isCopyable: true,
isMono: true,
label: 'File Path',
value: serverProps?.model_path ?? null
},
{
label: 'Context Size',
// the server reports the context it runs with once the model is loaded; until
// then the listing, or the Hub, says what the model can take
value: serverProps
? `${formatNumber(serverProps.default_generation_settings?.n_ctx ?? 0)} tokens`
: option.contextLength
? `${formatParameters(option.contextLength)} tokens`
: gguf?.context_length
? `${formatNumber(gguf.context_length)} tokens`
: null
},
{ label: 'Model Size', value: meta?.size ? formatFileSize(meta.size) : null },
{
label: 'Parameters',
value: meta?.n_params
? formatParameters(meta.n_params)
: (option.parsedId?.params ?? (hubParameters ? formatParameters(hubParameters) : null))
},
{ label: 'Embedding Size', value: meta?.n_embd ? formatNumber(meta.n_embd) : null },
{
label: 'Vocabulary Size',
value: meta?.n_vocab ? `${formatNumber(meta.n_vocab)} tokens` : null
},
{ isBadge: true, label: 'Quantization', value: quant },
{
isBadge: true,
label: 'Architecture',
value: (meta?.architecture as string) ?? gguf?.architecture ?? null
},
{
isBadge: true,
label: 'Draft sidecars',
value:
draftSidecars.length > 0
? draftSidecars.map((badge) => badge.kind.toUpperCase()).join(', ')
: null
},
{
isCopyable: true,
label: 'Draft repo',
value: draftSidecars[0]?.repo ?? null
},
{
isBadge: true,
label: 'Draft quant',
value: draftSidecars[0]?.quant ?? null
},
{
label: 'Parallel Slots',
value: serverProps?.total_slots != null ? String(serverProps.total_slots) : null
},
{ isMono: true, label: 'Build Info', value: serverProps?.build_info ?? null }
] satisfies Array<{
isBadge?: boolean;
isCopyable?: boolean;
isMono?: boolean;
label: string;
value: string | null;
}>);
// rows without a value say nothing; a loaded model fills the rest in from /props
let visibleRows = $derived(rows.filter((row) => row.value !== null));
const sectionTrigger = 'flex w-full cursor-pointer items-center gap-2 py-2 text-left';
</script>
<div class="space-y-0">
<div class="flex items-center gap-3 border-b border-border/30 py-2.5">
<span class="text-sm text-muted-foreground">Model</span>
<span class="ml-auto flex min-w-0 flex-1 items-center gap-2">
<span class="min-w-0 flex-1 overflow-x-auto font-mono text-xs whitespace-nowrap">
{option.model}
</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model name to clipboard"
canCopy
text={option.model}
/>
</span>
</div>
{#each visibleRows as row (row.label)}
<div class="flex items-center gap-3 border-b border-border/30 py-2.5 last:border-b-0">
<span class="text-sm text-muted-foreground">{row.label}</span>
<span class="ml-auto flex min-w-0 items-center gap-2 {row.isCopyable ? 'flex-1' : ''}">
{#if row.value === null}
<span class="text-muted-foreground">-</span>
{:else}
<span
class={[
'min-w-0',
// a copyable value scrolls instead of clipping, the icon stays put
row.isCopyable ? 'flex-1 overflow-x-auto whitespace-nowrap' : 'truncate',
row.isBadge ? '' : 'text-sm',
row.isMono ? 'font-mono text-xs' : ''
]}
>
{#if row.isBadge}
<Badge class="h-5 px-1.5 text-[10px]" variant="secondary">{row.value}</Badge>
{:else}
{row.value}
{/if}
</span>
{#if row.isCopyable}
<ActionIconCopyToClipboard
ariaLabel="Copy value to clipboard"
canCopy
text={row.value}
/>
{/if}
{/if}
</span>
</div>
{/each}
{#if visibleRows.length === 0}
<p class="py-2.5 text-sm text-muted-foreground">This model reports no metadata.</p>
{/if}
{#if modalities.length > 0}
<div class="flex items-center gap-3 border-b border-border/30 py-2.5 last:border-b-0">
<span class="text-sm text-muted-foreground">Modalities</span>
<span class="ml-auto flex flex-wrap gap-1"><BadgesModality {modalities} /></span>
</div>
{/if}
</div>
{#if !serverProps}
<p class="pt-2 text-xs text-muted-foreground">
/props values - file path, slots, build info - appear once the model is loaded. Reading this
page never loads a model.
</p>
{/if}
<div class="mt-4">
<CollapsibleSection triggerClass={sectionTrigger}>
{#snippet trigger()}
<span class="text-sm font-medium">Chat Template</span>
{/snippet}
<pre
class="mt-1 max-h-48 overflow-auto rounded-md bg-muted/50 p-2 text-xs whitespace-pre-wrap">{serverProps?.chat_template ??
gguf?.chat_template ??
'Not reported by the server.'}</pre>
</CollapsibleSection>
</div>
@@ -9,12 +9,12 @@
import { MoreHorizontal } from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
isFavorite: (option: ModelOption) => boolean;
/** Model this row stands for. */
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
@@ -29,9 +29,9 @@
let isHidden = $derived(modelsStore.isHidden(option.id));
function handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ') event.preventDefault();
if (event.key === KeyboardKey.SPACE) event.preventDefault();
if (event.key === 'Enter' || event.key === ' ') onSelect(option);
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) onSelect(option);
}
</script>
@@ -5,12 +5,9 @@
import ModelsManagerTableToolbar from './ModelsManagerTableToolbar.svelte';
import {
isModelRunning,
type ModalityKey,
modelContextLength,
type ModelQuantGroup,
type ModelsTableGroup,
ModelsTableGroupKind,
ModelsTableSortKey
type ModelsTableGroup
} from './utils';
import {
ArrowDown,
@@ -23,16 +20,25 @@
Power
} from '@lucide/svelte';
import {
CollapsibleRegion,
GroupedList,
type GroupedListGroup,
GroupedListUnit,
Logo,
ModelAvatar,
ModelsSection
} from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import ModelsSelectorDownloadItem from '$lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte';
import { FAMILY_ROW_WINDOW, MODEL_ROW_GRID_CLASS, MODEL_ROW_WINDOW } from '$lib/constants';
import { ModelCapability, ModelDownloadConfirmAction } from '$lib/enums';
import {
FAMILY_ROW_WINDOW,
type ModalityKey,
MODEL_ROW_GRID_CLASS,
MODEL_ROW_WINDOW,
ModelsTableGroupKind,
ModelsTableSortKey
} from '$lib/constants';
import { KeyboardKey, ModelCapability, ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
@@ -72,6 +78,12 @@
/** In-flight and paused downloads, tracked by the status feed. */
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
/** Noun the show-more row counts in, per unit of the grouped list. */
const SHOW_MORE_NOUNS: Record<GroupedListUnit, string> = {
[GroupedListUnit.ENTRIES]: 'models',
[GroupedListUnit.GROUPS]: 'families'
};
let pendingCancel = $state('');
let cancelOpen = $state(false);
let pendingDelete = $state('');
@@ -98,7 +110,6 @@
})
);
/** Families of one section. */
function familyGroups(
group: (typeof sections)[number]
): GroupedListGroup<ModelFamilyGroup<ModelQuantGroup>, ModelQuantGroup>[] {
@@ -109,8 +120,7 @@
}));
}
// Cancel is confirmed once for the whole list, so a single dialog instance
// serves however many downloads are in flight.
// cancel is confirmed once for the whole list, so one dialog serves every row
function requestCancel(repoWithTag: string): void {
pendingCancel = repoWithTag;
cancelOpen = true;
@@ -184,9 +194,9 @@
}
function handleFamilyKeydown(event: KeyboardEvent, toggle: () => void): void {
if (event.key === ' ') event.preventDefault();
if (event.key === KeyboardKey.SPACE) event.preventDefault();
if (event.key === 'Enter' || event.key === ' ') toggle();
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) toggle();
}
</script>
@@ -218,7 +228,7 @@
onToggle={() => toggleQuants(entry.key)}
/>
{#if !collapsedQuants.has(entry.key)}
<CollapsibleRegion open={!collapsedQuants.has(entry.key)}>
{#each entry.quants as quant (quant.id)}
<ModelsManagerQuantRow
indent={indent + 24}
@@ -229,7 +239,7 @@
selected={selectedId === quant.id}
/>
{/each}
{/if}
</CollapsibleRegion>
{:else}
<ModelsManagerModelRow
{indent}
@@ -302,7 +312,7 @@
}: {
count: number;
onMore: () => void;
unit: 'entries' | 'families';
unit: GroupedListUnit;
})}
<div class="px-2">
<button
@@ -310,7 +320,7 @@
onclick={onMore}
type="button"
>
Show {count} more {unit === 'families' ? 'families' : 'models'}
Show {count} more {SHOW_MORE_NOUNS[unit]}
</button>
</div>
{/snippet}
@@ -8,12 +8,12 @@
import { DropdownMenuActions } from '$lib/components/app';
import { Badge } from '$lib/components/ui/badge';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
isFavorite: (option: ModelOption) => boolean;
/** Quant this row stands for. */
option: ModelOption;
onDelete: (option: ModelOption) => void;
onSelect: (option: ModelOption) => void;
@@ -29,9 +29,9 @@
let quant = $derived(option.parsedId?.quantization ?? option.model);
function handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ') event.preventDefault();
if (event.key === KeyboardKey.SPACE) event.preventDefault();
if (event.key === 'Enter' || event.key === ' ') onSelect(option);
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) onSelect(option);
}
</script>
@@ -7,10 +7,10 @@
import { configuredContext, isModelRunning } from './utils';
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { settingsStore } from '$lib/stores';
interface Props {
/** Repo entry the row folds. */
entry: ModelQuantGroup;
expanded: boolean;
onToggle: () => void;
@@ -31,9 +31,9 @@
let mediaSource = $derived(entry.quants.find((quant) => quant.modalities) ?? entry.base);
function handleKeydown(event: KeyboardEvent): void {
if (event.key === ' ') event.preventDefault();
if (event.key === KeyboardKey.SPACE) event.preventDefault();
if (event.key === 'Enter' || event.key === ' ') onToggle();
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) onToggle();
}
</script>
@@ -60,7 +60,6 @@
<ModelId
aliases={entry.base.aliases}
class="min-w-0"
draftSidecars={entry.base.draftSidecars}
hideCapabilities
hideModalities
hideQuantization
@@ -1,9 +1,9 @@
<script lang="ts">
import ModelsManagerFilters from './ModelsManagerFilters.svelte';
import { type ModalityKey } from './utils';
import { X } from '@lucide/svelte';
import { SearchInput } from '$lib/components/app/forms';
import { Button } from '$lib/components/ui/button';
import { type ModalityKey } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { uiStore } from '$lib/stores';
import type { Snippet } from 'svelte';
@@ -1,12 +1,10 @@
import { ModelGroupKind, ModelsTableGroupKind } from '$lib/constants';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ModelModalities, ModelOption } from '$lib/types/models';
import type { ModelOption } from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
/** What a group folds, which decides its label. */
export type ModelGroupKind = 'quants' | 'variants';
/** One repo of the table, with the rows it ships as. */
export interface ModelQuantGroup {
base: ModelOption;
@@ -15,36 +13,6 @@ export interface ModelQuantGroup {
quants: ModelOption[];
}
/**
* Kind of one collapsible block of the manager's table. A plain constant map, so
* provider blocks can extend the union with their own string kinds.
*/
export const ModelsTableGroupKind = {
FAVORITES: 'favorites',
HIDDEN: 'hidden',
LOADED: 'loaded',
LOCAL: 'local'
} as const;
export type ModelsTableGroupKind = (typeof ModelsTableGroupKind)[keyof typeof ModelsTableGroupKind];
/** Column the manager's table can be ordered by. */
export const ModelsTableSortKey = {
CONTEXT: 'context',
NAME: 'name',
STATUS: 'status'
} as const;
export type ModelsTableSortKey = (typeof ModelsTableSortKey)[keyof typeof ModelsTableSortKey];
/** Header label of each manager section. */
export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
[ModelsTableGroupKind.FAVORITES]: 'Favorites',
[ModelsTableGroupKind.HIDDEN]: 'Hidden models',
[ModelsTableGroupKind.LOADED]: 'Loaded models',
[ModelsTableGroupKind.LOCAL]: 'Local models'
};
/** One collapsible block of the manager's table. */
export interface ModelsTableGroup {
/** One entry per repo, its quants hanging off it. */
@@ -54,12 +22,6 @@ export interface ModelsTableGroup {
label: string;
}
/** Modalities a model can accept, as the manager filter offers them. */
export type ModalityKey = keyof ModelModalities;
/** Modalities the manager filter offers, in display order. */
export const MODALITY_KEYS: ModalityKey[] = ['vision', 'video', 'audio'];
/** True when the model is loaded (or sleeping) and not mid-operation. */
export function isModelRunning(option: ModelOption): boolean {
const status = modelsStore.getModelStatus(option.model);
@@ -85,10 +47,7 @@ export function modelSupports(option: ModelOption, capability: ModelCapability):
: detectThinkingSupport(template);
}
/**
* Context a model reports: the provider listing first, then whatever the Hub has
* cached for it. 0 means nothing is known yet.
*/
/** Context a model reports: the provider listing first, then the cached Hub record. */
export function modelContextLength(option: ModelOption): number {
return (
option.contextLength ??
@@ -118,7 +77,7 @@ export function groupModelQuants(models: ModelOption[]): ModelQuantGroup[] {
continue;
}
groups.set(key, { base: option, key, kind: 'quants', quants: [option] });
groups.set(key, { base: option, key, kind: ModelGroupKind.QUANTS, quants: [option] });
}
return Array.from(groups.values()).flatMap((group) => {
@@ -131,7 +90,7 @@ export function groupModelQuants(models: ModelOption[]): ModelQuantGroup[] {
...group,
base: option,
key: `${group.key}::${option.id}`,
kind: 'variants' as const,
kind: ModelGroupKind.VARIANTS,
quants: [option]
}));
}
@@ -146,5 +105,5 @@ function groupKind(quants: ModelOption[]): ModelGroupKind {
(option) => (option.parsedId ?? ModelsService.parseModelId(option.model)).quantization
);
return isQuant ? 'quants' : 'variants';
return isQuant ? ModelGroupKind.QUANTS : ModelGroupKind.VARIANTS;
}
@@ -5,7 +5,6 @@
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;
@@ -26,7 +25,6 @@
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;
}
@@ -50,7 +48,7 @@
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
// surfaces without a search block fall back to a 0 sticky height
let triggerStyle = $derived(sticky ? 'top: var(--dropdown-sticky-height, 0px)' : '');
</script>
@@ -68,8 +66,7 @@
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
// the back control must not collapse the list it is leaving
event.stopPropagation();
onBack?.();
}}
@@ -1,8 +1,8 @@
<script lang="ts">
import ModelsDiscoverAvatar from '../discover/ModelsDiscoverAvatar.svelte';
import ModelsDiscoverDownloadProgressBar from '../discover/ModelsDiscoverDownloadProgressBar.svelte';
import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte';
import ModelOrgAvatar from '../ModelOrgAvatar.svelte';
import { Info, Loader2, Pause, Play, X } from '@lucide/svelte';
import { ModelId } from '$lib/components/app';
import { ActionIcon, ModelId } from '$lib/components/app';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore, uiStore } from '$lib/stores';
import type { ModelDownloadProgress } from '$lib/types';
@@ -10,10 +10,7 @@
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.
*/
/** Ask the list to confirm cancelling; the list owns the single dialog. */
onRequestCancel?: (repoWithTag: string) => void;
/** Show the organization name in the repo id, as the other selector rows do. */
showOrgName?: boolean;
@@ -29,8 +26,7 @@
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
// the base model org resolves lazily via HF when unknown
let orgName = $derived(ModelsService.parseModelId(entry.repoWithTag).orgName);
let fetchedBaseModelOrg = $state<string | null>(null);
@@ -71,7 +67,7 @@
type="button"
>
{#if orgName}
<ModelsDiscoverAvatar
<ModelOrgAvatar
org={fetchedBaseModelOrg ?? orgName}
quantOrg={orgName}
quantPositionClass="-bottom-1 -right-1"
@@ -107,26 +103,24 @@
</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"
<ActionIcon
ariaLabel="Manage model"
class="h-4 w-4 shrink-0 scale-75 cursor-pointer 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"
icon={Info}
iconSize="h-4 w-4"
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"
<ActionIcon
ariaLabel="Cancel downloading"
class="h-4 w-4 shrink-0 scale-75 cursor-pointer 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"
icon={X}
iconSize="h-4 w-4"
onclick={() => onRequestCancel?.(entry.repoWithTag)}
type="button"
>
<X class="h-4 w-4" />
</button>
/>
{#if entry.progress && entry.progress.totalBytes > 0}
<ModelsDiscoverDownloadProgressBar
<ModelDownloadProgressBar
downloadedBytes={entry.progress.downloadedBytes}
overlay
totalBytes={entry.progress.totalBytes}
@@ -8,14 +8,13 @@
ModelsSelectorOption,
ModelsSelectorTriggerIcon
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import type { ModelItem } from '$lib/components/app/navigation/utils';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR,
MODEL_ID,
MODEL_ICON,
MODEL_ID,
SETTINGS_KEYS
} from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
@@ -24,20 +23,13 @@
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
import type { ModelOption, ModelSidecarBadge } from '$lib/types/models';
import { modelLoadFraction } from '$lib/utils';
import { rawModelId } from '$lib/utils/model-option-id';
interface Props {
class?: string;
currentModel?: string | null;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
forceForegroundText?: boolean;
onModelChange?: (
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void;
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
useGlobalSelection?: boolean;
}
@@ -45,7 +37,6 @@
class: className = '',
currentModel = null,
disabled = false,
error = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
@@ -53,7 +44,6 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
let showAddBackend = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -97,9 +87,8 @@
highlightedId = null;
});
// The search input takes focus as soon as it is mounted. bits-ui auto-focuses
// the opened content by default, which can yank the page scroll, so the content
// prevents that and this waits for the input instead.
// bits-ui auto-focuses the opened content, which can yank the page scroll: the
// content prevents that and this focuses the search input instead
$effect(() => {
if (!isOpen) return;
@@ -128,9 +117,6 @@
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) order.push(item.option.id);
}
for (const provider of ms.groupedFilteredOptions.providers) {
for (const item of provider.items) order.push(item.option.id);
}
return order;
});
@@ -164,13 +150,6 @@
setTimeout(() => uiStore.openModelsManager(), 0);
}
function handleAddBackend() {
isOpen = false;
// let the menu finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
// Alt+Enter only unloads and keeps the dropdown open.
async function handleModelKeyAction(modelId: string, unload: boolean) {
if (!unload) {
@@ -179,13 +158,12 @@
return;
}
const rawId = rawModelId(modelId);
const model = modelsStore.routerModels.find((m) => m.id === rawId);
const model = modelsStore.routerModels.find((m) => m.id === modelId);
const status = model?.status?.value as ServerModelStatus | undefined;
if (status === ServerModelStatus.LOADING) return;
await modelsStore.status.unload(rawId);
await modelsStore.status.unload(modelId);
}
export function open() {
@@ -232,13 +210,7 @@
<MODEL_ICON class="h-3.5 w-3.5 shrink-0" />
</span>
{:else}
<button
class="cursor-pointer text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={handleAddBackend}
type="button"
>
No models yet. Add a backend to get started.
</button>
<span class="text-xs text-muted-foreground">No models yet.</span>
{/if}
{:else}
{@const triggerStatus = triggerModel
@@ -262,15 +234,13 @@
{...props}
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
: 'text-foreground',
isOpen && 'text-foreground',
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
]}
@@ -285,8 +255,8 @@
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
draftSidecars={selectedOption.draftSidecars}
hideOrgName={!showOrgName}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
@@ -387,8 +357,6 @@
favorites={ms.favoriteItems}
groups={ms.groupedFilteredOptions}
loaded={ms.loadedItems}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="[&:not(:first-child)]:mt-1 mb-1 px-2 py-2 text-[13px] font-semibold text-foreground/80 select-none"
@@ -440,8 +408,8 @@
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
draftSidecars={selectedOption.draftSidecars}
hideOrgName={!showOrgName}
hideQuantization
modelId={selectedOption.model}
/>
{/if}
@@ -455,15 +423,10 @@
{#if selectedOption}
<Tooltip.Content>
<p class="font-mono">{triggerTooltipLabel(selectedOption)}</p>
</Tooltip.Content>
<p class="font-mono">{triggerTooltipLabel(selectedOption)}</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
{/if}
{/if}
</div>
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -28,10 +28,6 @@
loaded?: ModelItem[];
/** Show the organization name in every model id of the list. */
showOrgName?: boolean;
/** Open one provider's full list, offered when a section is cut short. */
onProviderOpen?: (backendId: string) => void;
/** Leave the drilled-in provider; enables the back affordance. */
onProviderBack?: () => void;
}
let {
@@ -40,16 +36,14 @@
favorites = [],
groups,
loaded = [],
onProviderBack,
onProviderOpen,
onSelect,
renderOption,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
showOrgName = true
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
// A large local catalog is mounted a window at a time; the sentinel at the end
// of the list grows the window when it scrolls into view.
// a large catalog mounts a window at a time: the sentinel at the end of the list
// grows the window when it scrolls into view
let visibleCount = $state(MODEL_ROW_WINDOW);
let sentinelEl = $state<HTMLElement | null>(null);
const localGroups = $derived(windowLocalGroups(groups, visibleCount));
@@ -68,8 +62,8 @@
const onScroll = () => {
const rect = sentinel.getBoundingClientRect();
// grow only when the end of the list is actually on screen, otherwise
// every scroll event of the page would mount the whole catalog
// grow only when the end of the list is on screen: otherwise every scroll
// event of the page would mount the whole catalog
if (rect.top > window.innerHeight || rect.bottom < 0) return;
visibleCount += MODEL_ROW_WINDOW;
@@ -80,17 +74,15 @@
return () => document.removeEventListener('scroll', onScroll, { capture: true });
});
// section headers stick right below the search/tabs block of the dropdown
// scrollport; `--dropdown-sticky-height` is set by DropdownMenuSearchable
// and falls back to 0 in surfaces without one (the mobile sheet)
// section headers stick below the dropdown's search block: `--dropdown-sticky-height`
// comes from DropdownMenuSearchable and falls back to 0 in the mobile sheet
let headerClass = $derived(`${sectionHeaderClass} sticky z-10 bg-popover`);
const headerStyle = 'top: var(--dropdown-sticky-height, 0px)';
/** In-flight / paused downloads, tracked by the status feed. */
let getDownloadEntries = $derived(modelsStore.status.getDownloadEntries());
// Cancel is confirmed once for the whole list rather than per download row, so
// a single dialog instance is mounted however many downloads are in flight.
// cancel is confirmed once for the whole list, so one dialog serves every row
// The target is kept while the dialog closes so its copy stays rendered.
let pendingCancel = $state('');
let cancelOpen = $state(false);
@@ -202,7 +194,6 @@
{/if}
{#snippet localRows()}
<!-- the loaded models first -->
{#each localGroups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, !showOrgName)}
{/each}
@@ -227,39 +218,6 @@
</ModelsSection>
{/if}
<!-- One section per remote provider. -->
{#each groups.providers as provider (provider.backendId)}
<ModelsSection
backendId={provider.backendId}
error={Boolean(provider.error)}
label={provider.name}
loading={provider.loading}
onBack={onProviderBack}
persistKey={provider.backendId}
revealChevronOnHover
sticky
>
{#if provider.items.length > 0}
{@render listRows(provider.items, provider.backendId)}
{#if onProviderOpen && provider.matched > provider.items.length}
<!-- same box as a model row, it opens the provider's full list -->
<button
class="flex w-full cursor-pointer items-center gap-2 rounded-sm p-2 text-left text-sm text-muted-foreground transition hover:bg-accent hover:text-foreground focus:outline-none"
onclick={() => onProviderOpen(provider.backendId)}
type="button"
>
+ {provider.matched - provider.items.length} more
</button>
{/if}
{:else if provider.catalog === 0}
<p class="px-4 pb-2 text-xs text-muted-foreground">
{provider.error ?? (provider.loading ? 'Loading models...' : 'No models')}
</p>
{/if}
</ModelsSection>
{/each}
<DialogConfirmDownload
action={ModelDownloadConfirmAction.CANCEL}
onClose={() => (cancelOpen = false)}
@@ -1,6 +1,5 @@
<script lang="ts">
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { modelDraftsFor } from '../ModelsManager/utils';
import {
ModelAvatar,
ModelCapabilities,
@@ -13,8 +12,6 @@
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
interface Props {
option: ModelOption;
@@ -44,9 +41,6 @@
showRepoOrgAvatar = false
}: Props = $props();
// row actions follow the backend that serves the row, not the selected one
let rowBackend = $derived(getBackend(option.backendId));
let canLoad = $derived(rowBackend ? getBackendCapabilities(rowBackend).loadUnload : false);
let currentRouterModels = $derived(modelsStore.routerModels);
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
@@ -68,11 +62,8 @@
let showCapabilities = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_CAPABILITIES_IN_SELECTOR] ?? false
);
// Avatar: with showBaseModelAvatar the original base model's org is the main
// image and the repo (quantizer) org the corner badge, as in the discover
// list. Loaded models usually carry the `base_model` tag on the option; GGUF
// repos only known to HF are resolved lazily via the cached getBaseModel
// lookup.
// with showBaseModelAvatar the base model's org is the main image and the repo
// (quantizer) org the corner badge; the base org resolves lazily via HF
</script>
<div
@@ -102,7 +93,6 @@
aliases={option.aliases}
class="min-w-0 flex-1"
draftSidecars={option.draftSidecars}
drafts={modelDraftsFor(option)}
hideCapabilities
hideModalities
{hideOrgName}
@@ -119,15 +109,7 @@
<div class="flex shrink-0 items-center gap-1">
<ModelRowActions {isFav} {option} />
<ModelLoadControl
{canLoad}
{isFailed}
{isLoaded}
{isLoading}
{isSleeping}
{option}
showBackendMark
/>
<ModelLoadControl {isFailed} {isLoaded} {isLoading} {isSleeping} {option} />
</div>
{#if isLoading}
@@ -7,7 +7,6 @@
ModelsSelectorTriggerIcon,
SearchInput
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import * as Sheet from '$lib/components/ui/sheet';
import { MODEL_ICON, SETTINGS_KEYS } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
@@ -19,14 +18,8 @@
class?: string;
currentModel?: string | null;
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
onModelChange?: (
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void;
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
@@ -36,14 +29,12 @@
class: className = '',
currentModel = null,
disabled = false,
error = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
}: Props = $props();
let sheetOpen = $state(false);
let showAddBackend = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -54,7 +45,6 @@
useGlobalSelection: () => useGlobalSelection
});
// one setting for every model id in the selector: the trigger and the rows
const showOrgName = $derived(settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] ?? true);
export function open() {
@@ -73,13 +63,6 @@
// let the sheet finish closing before the dialog takes focus
setTimeout(() => uiStore.openModelsManager(), 0);
}
function handleAddBackend() {
sheetOpen = false;
// let the sheet finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
</script>
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
@@ -89,13 +72,7 @@
Loading models…
</div>
{:else if ms.options.length === 0 && ms.isMultiModel}
<button
class="cursor-pointer text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={handleAddBackend}
type="button"
>
No models yet. Add a backend to get started.
</button>
<span class="text-xs text-muted-foreground">No models yet.</span>
{:else}
{@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
@@ -114,15 +91,13 @@
<button
class={[
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
: 'text-foreground',
sheetOpen && 'text-foreground'
]}
disabled={disabled || ms.updating}
@@ -201,8 +176,6 @@
favorites={ms.favoriteItems}
groups={ms.groupedFilteredOptions}
loaded={ms.loadedItems}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
{showOrgName}
@@ -227,15 +200,13 @@
<button
class={[
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground'
: 'text-foreground'
]}
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}
@@ -257,8 +228,3 @@
{/if}
{/if}
</div>
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -1,109 +1,8 @@
/**
*
* MODELS SELECTOR
*
* Model selection UI for the chat form: a desktop dropdown and a mobile sheet,
* each backed by a shared grouped options list, option rows, in-flight download
* rows and an inline reasoning-effort picker. Supports two server modes:
* - **Single model mode**: Server runs with one model, selector shows model info
* - **Router mode**: Server runs with multiple models, selector enables switching
*
* Integrates with modelsStore for model data and serverStore for mode detection.
*
*/
/** Model selection UI for the chat form: desktop dropdown, mobile sheet and their shared parts. */
/**
* **ModelsSelectorDropdown** - Model selection dropdown (desktop)
*
* Dropdown for selecting AI models with status indicators,
* search, and model information display. Adapts UI based on server mode.
*
* **Architecture:**
* - Uses DropdownMenuSearchable for model list
* - Integrates with modelsStore for model options and selection
* - Detects router vs single mode from serverStore
* - Opens DialogModelInformation for model details
*
* **Features:**
* - Searchable model list with keyboard navigation
* - Model status indicators (loading/ready/error/updating)
* - Model capabilities badges (vision, tools, etc.)
* - Current/active model highlighting
* - Model information dialog on info button click
* - Router mode: shows all available models with status
* - Single mode: shows current model name only
* - Loading/updating skeleton states
* - Global selection support for form integration
*
* @example
* ```svelte
* <ModelsSelectorDropdown
* currentModel={conversation.modelId}
* onModelChange={(id, name) => updateModel(id)}
* useGlobalSelection
* />
* ```
*/
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
/**
*
* Switches the rendered view: the favorites of every backend, the local server's
* models, or the remote backends'. Purely a display concern - the backend that
* serves requests follows the selected model.
*/
/**
* **ModelsSelectorList** - Grouped model options list
*
* Renders grouped model options (loaded, favorites, available) with section
* headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
* to avoid template duplication.
*
* 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 './ModelsSelectorList.svelte';
/**
* **ModelsSelectorDownloadItem** - Single in-flight download row
*
* One "Download in progress" row for the models selector: live progress bar,
* pause / resume on click and a hover-revealed cancel, mirroring the discover
* quant chips.
*/
export { default as ModelsSelectorDownloadItem } from './ModelsSelectorDownloadItem.svelte';
/**
* **ModelsSelectorOption** - Single model option row
*
* Renders a single model option with selection state, favorite toggle,
* load/unload actions, status indicators, and an info button.
* Used inside ModelsSelectorList or directly in custom render snippets.
*/
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
/**
*
* Collapsible row that expands in place to reveal the reasoning effort levels.
* Used in the sticky footer of ModelsSelectorDropdown, where a flyout submenu
* would float over the model list it belongs to.
*/
/**
* **ModelsSelectorSheet** - Mobile model selection sheet
*
* Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
* instead of DropdownMenu.
*/
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
/**
* **ModelsSelectorTriggerIcon** - Icon for the selector trigger button
*
* Shows the selected model's own org avatar (the same mark the option rows
* use) and falls back to the generic model icon when the model has neither an
* org nor a provider mark.
*/
export { default as ModelsSelectorTriggerIcon } from './ModelsSelectorTriggerIcon.svelte';
@@ -1,217 +0,0 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import {
CircleAlert,
Heart,
HeartOff,
Info,
Loader2,
Power,
PowerOff,
RotateCw
} from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
interface Props {
option: ModelOption;
isSelected: boolean;
isHighlighted: boolean;
isFav: boolean;
hideOrgName?: boolean;
onSelect: (modelId: string) => void;
onMouseEnter: () => void;
onKeyDown: (e: KeyboardEvent) => void;
onInfoClick?: (modelName: string) => void;
}
let {
hideOrgName = false,
isFav,
isHighlighted,
isSelected,
onInfoClick,
onKeyDown,
onMouseEnter,
onSelect,
option
}: Props = $props();
let currentRouterModels = $derived(modelsStore.routerModels);
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
return (model?.status?.value as ServerModelStatus) ?? null;
});
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
let isLoaded = $derived(
(serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
);
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let capabilities = $derived.by(() => ({
reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
tools: option.capabilities.includes(ModelCapability.TOOL_USE)
}));
</script>
<div
aria-selected={isSelected || isHighlighted}
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer',
isSelected && !isHighlighted && 'bg-accent/50',
isHighlighted && 'bg-accent',
(isSelected || isHighlighted) && 'text-accent-foreground',
'hover:bg-accent',
'focus:bg-accent',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
onclick={() => onSelect(option.id)}
onkeydown={onKeyDown}
onmouseenter={onMouseEnter}
role="option"
tabindex="0"
title={loadTitle}
>
<ModelId
aliases={option.aliases}
class="flex-1"
draftSidecars={option.draftSidecars}
{hideOrgName}
{modalities}
modelId={option.model}
showRawTooltip
supportsThinking={capabilities.reasoning}
supportsToolUse={capabilities.tools}
tags={option.tags}
/>
<div class="flex shrink-0 items-center gap-1">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="pointer-events-none flex items-center justify-center gap-0.75 pl-2 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
onclick={(e) => e.stopPropagation()}
>
{#if isFav}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={HeartOff}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip="Remove from favorites"
/>
{:else}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={Heart}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.toggleFavorite(option.model)}
tooltip="Add to favorites"
/>
{/if}
<!-- info button: only shown when model is loaded and callback is provided -->
{#if isLoaded && onInfoClick}
<ActionIcon
class="h-3 w-3 hover:text-foreground"
icon={Info}
iconSize="h-2.5 w-2.5"
onclick={() => onInfoClick(option.model)}
tooltip="Model information"
/>
{/if}
</div>
{#if isLoading}
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin text-muted-foreground" />
</div>
{:else if isFailed}
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
<CircleAlert
class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
/>
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-3 w-3 text-red-500 hover:text-foreground"
icon={RotateCw}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
tooltip="Retry loading model"
/>
</div>
</div>
{:else if isSleeping}
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
<span
class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden [@media(pointer:coarse)]:hidden"
></span>
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-amber-500 [@media(pointer:coarse)]:hover:text-amber-600"
icon={PowerOff}
iconSize="h-2.5 w-2.5"
onclick={(e) => {
e?.stopPropagation();
modelsStore.status.unload(option.model);
}}
tooltip="Unload model"
/>
</div>
</div>
{:else if isLoaded}
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
<span
class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
></span>
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-3 w-3 text-red-500 hover:text-red-600 [@media(pointer:coarse)]:text-green-500 [@media(pointer:coarse)]:hover:text-green-600"
icon={PowerOff}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.status.unload(option.model)}
stopPropagationOnClick
tooltip="Unload model"
/>
</div>
</div>
{:else}
<div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
<span
class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden [@media(pointer:coarse)]:hidden"
></span>
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-3 w-3 [@media(pointer:coarse)]:text-muted-foreground"
icon={Power}
iconSize="h-2.5 w-2.5"
onclick={() => modelsStore.status.load(option.model)}
stopPropagationOnClick
tooltip="Load model"
/>
</div>
</div>
{/if}
</div>
{#if isLoading}
<ModelLoadHighlight percent={loadPercent} />
{/if}
</div>
@@ -1,32 +0,0 @@
<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 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 bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{/if}
@@ -8,9 +8,6 @@
*
* Integrates with modelsStore for model data and serverStore for mode detection.
*
* The selection UI lives in the ModelsSelector subfolder; the shared model
* display primitives (id / badge rendering) stay here.
*
*/
/** * **ModelBadge** - Model name display badge
@@ -51,38 +48,15 @@ export { default as ModelCapabilities } from './ModelCapabilities.svelte';
export { default as ModelContext } from './ModelContext.svelte';
export { default as ModelId } from './ModelId.svelte';
export { default as ModelAvatar } from './ModelAvatar.svelte';
export { default as ModelDownloadProgressBar } from './ModelDownloadProgressBar.svelte';
export { default as ModelLoadControl } from './ModelLoadControl.svelte';
export { default as ModelOrgAvatar } from './ModelOrgAvatar.svelte';
export { default as ModelRowActions } from './ModelRowActions.svelte';
export { default as ModelsSection } from './ModelsSection.svelte';
/**
* **ModelCapabilityIcons** - Capability and modality icon row
*
* The shared tool-use / reasoning / vision / video / audio icon cluster with
* tooltips, used by ModelId and the discover details header so the order and
* styling stay consistent across every model-id surface.
*/
/** Capability and modality icon row, shared by every model-id surface. */
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
/**
* **ModelDraftSidecars** - draft sidecar badges
*
* The `mtp|Q8_0` style badges for the draft sidecars a model pulls with it,
* shared by ModelId and the manager's quant rows so they render identically
* everywhere.
*/
export { default as ModelDraftSidecars } from './ModelDraftSidecars.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';
/** Draft sidecar badges, rendered as `+ [KIND] [QUANT]` per sidecar. */
export { default as ModelDraftSidecars } from './ModelDraftSidecars.svelte';
export * from './ModelsSelector';
@@ -15,10 +15,8 @@
searchClass?: string;
children: Snippet;
/**
* Optional sticky footer. It sticks to the bottom of the dropdown content's
* own scrollport, so it stays visible while the option list scrolls. For this
* to work, DropdownMenu.Content must be the scroll container (keep its
* overflow-y-auto and a max-height) and must not be `overflow-hidden`.
* Optional sticky footer: it sticks to the bottom of DropdownMenu.Content's own
* scrollport, which must stay the scroll container (overflow-y-auto + max-height).
*/
footer?: Snippet;
}
@@ -36,8 +34,7 @@
searchValue = $bindable('')
}: Props = $props();
// the search sticks to the top; its height is published so
// list section headers can stick right below it
// the search height is published so list headers can stick right below it
let stickyHeaderHeight = $state(0);
</script>
@@ -12,25 +12,9 @@ export interface OrgGroup {
items: ModelItem[];
}
/** One remote backend's section on the remote view. */
export interface ProviderGroup {
backendId: string;
/** Models the provider lists without any search filtering. */
catalog: number;
error: string | null;
/** Rows to render, capped by the display limit. */
items: ModelItem[];
loading: boolean;
/** Rows left after the search filter, before the cap. */
matched: number;
name: string;
}
export interface GroupedModelOptions {
available: OrgGroup[];
loaded: ModelItem[];
/** Remote backends, one section each. */
providers: ProviderGroup[];
}
function matchesModality(option: ModelOption, term: string): boolean {
@@ -66,8 +50,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
}
/**
* Favorite models across every backend, in list order. The favorites tab spans
* all backends, so they are collected from the full option list.
* Favorite models across every backend, in list order: the favorites tab spans
* all backends, so they come from the full option list.
*/
export function groupFavoriteOptions(
options: ModelOption[],
@@ -85,8 +69,8 @@ export function groupFavoriteOptions(
}
/**
* Cut the local groups down to a window of rows. Loaded models come first, then
* the org groups, so the caller can grow the window as the list is scrolled.
* Cut the local groups down to a window of rows, loaded first, so the caller can
* grow it as the list scrolls.
*/
export function windowLocalGroups(
groups: GroupedModelOptions,
@@ -130,7 +114,6 @@ export function groupModelOptions(
}
const loadedModelIds = new Set(loaded.map((item) => item.option.model));
// Available models grouped by org (excluding loaded)
const available: OrgGroup[] = [];
const orgGroups = new SvelteMap<string, ModelItem[]>();
@@ -150,48 +133,5 @@ export function groupModelOptions(
available.push({ items, orgName: orgName || null });
}
return { available, loaded, providers: [] };
}
/**
* Remote backends as sections, one per backend, in the given order. Each section
* keeps at most `limit` rows; `matched` carries the full count so the caller can
* offer the rest.
*/
export function groupProviderOptions(
options: ModelOption[],
providers: {
backendId: string;
catalog: number;
error: string | null;
loading: boolean;
name: string;
}[],
limit = Infinity,
recentIds: readonly string[] = []
): ProviderGroup[] {
const byBackend = new SvelteMap<string, ModelItem[]>();
const rank = new SvelteMap<string, number>();
recentIds.forEach((id, index) => rank.set(id, index));
const rankOf = (id: string) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
// recently used models lead their section, the rest keep the backend's order
const byRecency = (items: ModelItem[]) =>
rank.size === 0 ? items : [...items].sort((a, b) => rankOf(a.option.id) - rankOf(b.option.id));
for (let i = 0; i < options.length; i++) {
const option = options[i];
const backendId = option.backendId ?? '';
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
byBackend.get(backendId)!.push({ flatIndex: i, option });
}
return providers.map((provider) => {
const items = byRecency(byBackend.get(provider.backendId) ?? []);
return { ...provider, items: items.slice(0, limit), matched: items.length };
});
return { available, loaded };
}
@@ -23,6 +23,9 @@ export const DIALOG_SUBMENU_CONTENT = 'w-60';
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
/** Search input of an open dropdown-menu, focused after the menu mounts. */
export const DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR = '[data-slot="dropdown-menu-content"] input';
/** Filter controls above the model table: one box, one height, one fill. */
export const FILTER_TRIGGER_CLASS = `
h-8
@@ -36,6 +39,10 @@ export const FILTER_TRIGGER_CLASS = `
${INPUT_CLASSES}
`;
/** One filter toggle of the models manager toolbar. */
export const FILTER_TOGGLE_ITEM_CLASS =
'bg-muted! border-border/30! shadow-none! dark:border-border/20! data-[state=on]:bg-muted-foreground/15! data-[state=on]:text-foreground! dark:data-[state=on]:bg-muted-foreground/25!';
/** Column grid shared by every row of the models manager table. */
export const MODEL_ROW_GRID_CLASS =
'grid grid-cols-[minmax(0,1fr)_11rem_3rem_4.5rem] items-center gap-4';
+45 -2
View File
@@ -1,20 +1,32 @@
/**
* Icon mappings for file types, models and model modalities
* Icon and modality mappings for file types and models
* Centralized configuration to ensure consistent icon usage across the app
*/
import {
CircleAlert as LoadFailedIcon,
Eject as UnloadIcon,
Eye as VisionIcon,
File as FileIcon,
FileText as FileTextIcon,
FolderOpen as CwdCommandIcon,
Image as ImageIcon,
Lightbulb as ReasoningIcon,
Loader2 as LoadingIcon,
Mic as AudioIcon,
Package as ModelIcon,
Power as LoadIcon,
RotateCw as RetryIcon,
Sparkles as PromptCommandIcon,
Video as VideoIcon,
Wrench as ToolUseIcon
} from '@lucide/svelte';
import { FileTypeCategory, ModelCapability, ModelModality } from '$lib/enums';
import {
ChatFormCommandAction,
FileTypeCategory,
ModelCapability,
ModelModality
} from '$lib/enums';
import type { ModelCapabilities, ModelModalities } from '$lib/types/models';
import type { Component } from 'svelte';
@@ -53,6 +65,21 @@ export const MODALITY_FLAG_KEYS: Record<
[ModelModality.VISION]: 'vision'
};
/** Modalities in the order the filters and the model cards list them. */
export const MODALITY_ORDER: Exclude<ModelModality, ModelModality.TEXT>[] = [
ModelModality.VISION,
ModelModality.VIDEO,
ModelModality.AUDIO
];
/** Boolean flag a modality drives on the ModelModalities type. */
export type ModalityKey = (typeof MODALITY_FLAG_KEYS)[keyof typeof MODALITY_FLAG_KEYS];
/** Modality flags a model can carry, in display order. */
export const MODALITY_KEYS: ModalityKey[] = MODALITY_ORDER.map(
(modality) => MODALITY_FLAG_KEYS[modality]
);
export const CAPABILITY_ICONS: Record<ModelCapability, Component> = {
[ModelCapability.REASONING]: ReasoningIcon,
[ModelCapability.TOOL_USE]: ToolUseIcon
@@ -69,6 +96,22 @@ export const CAPABILITY_FLAG_KEYS: Record<ModelCapability, keyof ModelCapabiliti
[ModelCapability.TOOL_USE]: 'tools'
};
/** Icons of the slash-command picker, one per chat-form command. */
export const CHAT_FORM_COMMAND_ICONS: Record<ChatFormCommandAction, Component> = {
[ChatFormCommandAction.CWD]: CwdCommandIcon,
[ChatFormCommandAction.MODEL]: MODEL_ICON,
[ChatFormCommandAction.PROMPT]: PromptCommandIcon
};
/** Icons the model load control shows for its states and actions. */
export const MODEL_LOAD_ICONS = {
failed: LoadFailedIcon,
load: LoadIcon,
loading: LoadingIcon,
retry: RetryIcon,
unload: UnloadIcon
} as const;
// Shared SVG icon strings for copy and preview buttons
export const COPY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
+1
View File
@@ -46,6 +46,7 @@ export * from './message-export.constants';
export * from './path-display.constants';
export * from './model-id.constants';
export * from './model-loading.constants';
export * from './models-manager.constants';
export * from './models-discover.constants';
export * from './model-compatibility.constants';
export * from './huggingface.constants';
@@ -12,3 +12,25 @@ export const MODEL_LOAD_STAGE_LABELS: Record<ApiModelLoadStage, string> = {
* text_model fills the rest, so a plain model reaches 100% on its own.
*/
export const MODEL_LOAD_TAIL_SHARE = 0.1;
/** Load parameters a model falls back to when the server reports nothing. */
export const LOAD_DEFAULTS = {
batchSize: 2048,
contextLength: 8192,
cpuThreads: 13,
gpuOffload: 42,
speculativeDecoding: 'off',
ubatchSize: 512
};
/** Sampling parameters a model falls back to. */
export const SAMPLING_DEFAULTS = {
minP: 0.05,
repeatPenalty: 1.1,
temperature: 1,
topK: 64,
topP: 0.95
};
/** Speculative decoding modes the load form offers. */
export const SPECULATIVE_OPTIONS = ['off', 'draft-model'];
@@ -0,0 +1,36 @@
/** Constants of the models manager table. */
/** What a repo group of the table folds, which decides its label. */
export const ModelGroupKind = {
QUANTS: 'quants',
VARIANTS: 'variants'
} as const;
export type ModelGroupKind = (typeof ModelGroupKind)[keyof typeof ModelGroupKind];
/** Kind of one collapsible block of the table. */
export const ModelsTableGroupKind = {
FAVORITES: 'favorites',
HIDDEN: 'hidden',
LOADED: 'loaded',
LOCAL: 'local'
} as const;
export type ModelsTableGroupKind = (typeof ModelsTableGroupKind)[keyof typeof ModelsTableGroupKind];
/** Header label of each manager section. */
export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
[ModelsTableGroupKind.FAVORITES]: 'Favorites',
[ModelsTableGroupKind.HIDDEN]: 'Hidden models',
[ModelsTableGroupKind.LOADED]: 'Loaded models',
[ModelsTableGroupKind.LOCAL]: 'Local models'
};
/** Column the manager's table can be ordered by. */
export const ModelsTableSortKey = {
CONTEXT: 'context',
NAME: 'name',
STATUS: 'status'
} as const;
export type ModelsTableSortKey = (typeof ModelsTableSortKey)[keyof typeof ModelsTableSortKey];
@@ -77,6 +77,8 @@ export const SETTINGS_KEYS = {
TOP_K: 'top_k',
TOP_P: 'top_p',
TYP_P: 'typ_p',
// General
USE_HUGGING_FACE_HUB: 'useHuggingFaceHub',
XTC_PROBABILITY: 'xtc_probability',
XTC_THRESHOLD: 'xtc_threshold'
} as const;
@@ -135,6 +135,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Enable Discover Models',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Fetch model metadata (avatars, context length, chat template, file sizes) from the Hugging Face Hub. When off, the UI only shows what the server reports for /v1/models.',
key: SETTINGS_KEYS.USE_HUGGING_FACE_HUB,
label: 'Use Hugging Face Hub API for models metadata',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: true,
help: 'Choose how conversation titles are generated. The first non-empty line uses a fast deterministic rule; the LLM option uses a model-generated title from the first message exchange.',
+4 -4
View File
@@ -45,13 +45,13 @@ export const STATS_UNITS = {
export const DEFAULT_MOBILE_BREAKPOINT = 768;
/** Models listed per remote provider before the "+ X more" line; search covers the rest. */
export const REMOTE_PROVIDER_MODEL_LIMIT = 12;
/** Orgs whose avatar is dark and needs inverting in dark mode. */
export const DARK_INVERT_AVATAR_ORGS = ['openai'];
/**
* Model rows mounted before the list is scrolled. A local catalog can hold
* hundreds of models and mounting them all costs seconds on open.
*/
/** Model rows mounted before the list scrolls; a local catalog can hold hundreds. */
export const MODEL_ROW_WINDOW = 50;
/** Models of one family shown before the rest fold behind a "show more" row. */
@@ -2,34 +2,19 @@ import type { ModelItem } from '$lib/components/app/navigation/utils';
import {
filterModelOptions,
groupFavoriteOptions,
groupModelOptions,
groupProviderOptions
groupModelOptions
} from '$lib/components/app/navigation/utils';
import {
CHAT_INPUT_FOCUS_SELECTOR,
LOCAL_BACKEND_ID,
REMOTE_PROVIDER_MODEL_LIMIT
} from '$lib/constants';
import { backendsModelsStore, backendsStore, modelsStore, serverStore, uiStore } from '$lib/stores';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { modelsStore, serverStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import { rawModelId } from '$lib/utils/model-option-id';
import { onMount } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
/** Groups of the favorites tab, which lists favorites only. */
const EMPTY_GROUPS = { available: [], loaded: [], providers: [] };
export interface UseModelsSelectorOptions {
currentModel: () => string | null;
useGlobalSelection?: () => boolean;
onModelChange?: () =>
| ((
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void)
| ((modelId: string, modelName: string) => Promise<boolean> | boolean | void)
| undefined;
onOpenChange?: (open: boolean) => void;
}
@@ -49,14 +34,10 @@ export interface UseModelsSelectorReturn {
readonly loadedItems: ModelItem[];
readonly filteredOptions: ModelOption[];
readonly isEmpty: boolean;
readonly isProviderView: boolean;
readonly groupedFilteredOptions: ReturnType<typeof groupModelOptions>;
readonly isLoadingModel: boolean;
readonly searchTerm: string;
closeProvider(): void;
openProvider(backendId: string): void;
setSearchTerm(value: string): void;
showBackendModels(backendId: string): Promise<void>;
handleSelect(modelId: string): Promise<void>;
handleOpenChange(open: boolean): void;
isFavorite(model: string): boolean;
@@ -71,36 +52,21 @@ export interface UseModelsSelectorReturn {
* duplicating store derivations, selection handling, and model loading.
*/
export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn {
/**
* Current view: the favorites of every backend, the local server's models, or
* the remote backends'. Favorites are the default while there is at least one.
*/
/** Remote backend drilled into from its section; null while browsing. */
let providerViewId = $state<string | null>(null);
let isLoadingModel = $state(false);
let searchTerm = $state('');
const isProviderView = $derived(providerViewId !== null);
const isLocalOption = (option: ModelOption) => option.backendId === LOCAL_BACKEND_ID;
// every enabled backend's models are one list: favorites, then the local
// server, then one section per remote provider
const allOptions = $derived(
const options = $derived(
modelsStore.models.filter((option) => {
const modelProps = modelsStore.props.getModelProps(option.model);
return modelProps?.ui !== false;
})
);
const options = $derived(
providerViewId ? allOptions.filter((option) => option.backendId === providerViewId) : allOptions
);
const loading = $derived(modelsStore.loading);
const updating = $derived(modelsStore.updating);
const activeId = $derived(modelsStore.selectedModelId);
// Router mode and external backends both expose a selectable model list; only
// a lone llama.cpp server without a router has nothing to choose from.
const isMultiModel = $derived(
serverStore.isRouterMode ||
backendsStore.enabled.some((backend) => backend.id !== LOCAL_BACKEND_ID)
);
// a lone llama.cpp server without a router has nothing to choose from
const isMultiModel = $derived(serverStore.isRouterMode);
const isRouter = $derived(serverStore.isRouterMode);
const serverModel = $derived(modelsStore.singleModelName);
const currentModel = $derived(opts.currentModel());
@@ -108,54 +74,28 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
const isHighlightedCurrentModelActive = $derived.by(() => {
if (!isRouter || !currentModel) return false;
const currentOption = allOptions.find((option) => option.model === currentModel);
const currentOption = options.find((option) => option.model === currentModel);
return currentOption ? currentOption.id === activeId : false;
});
const isCurrentModelInCache = $derived.by(() => {
if (!isRouter || !currentModel) return true;
return allOptions.some((option) => option.model === currentModel);
return options.some((option) => option.model === currentModel);
});
let isLoadingModel = $state(false);
let searchTerm = $state('');
const visibleOptions = $derived(allOptions.filter((option) => !modelsStore.isHidden(option.id)));
const visibleOptions = $derived(options.filter((option) => !modelsStore.isHidden(option.id)));
const filteredOptions = $derived(filterModelOptions(options, searchTerm));
// favorites span every backend, so they come from the full option list
const favoriteItems = $derived(
groupFavoriteOptions(
filterModelOptions(visibleOptions, searchTerm),
modelsStore.favoriteModelIds
)
);
const remoteProviders = $derived(
backendsStore.enabled
.filter((backend) => backend.id !== LOCAL_BACKEND_ID)
.map((backend) => {
const state = backendsModelsStore.get(backend.id);
return {
backendId: backend.id,
catalog: state.models.length,
error: state.error,
loading: state.loading,
name: backend.name
};
})
);
// loaded models lead the list, from any llama-compat backend
const isLoadedLlamaCompat = (option: ModelOption) =>
modelsStore.isModelLoaded(option.model) &&
getBackendCapabilities(getBackend(option.backendId)).loadUnload;
const loadedItems = $derived.by(() => {
if (isProviderView) return [];
return filterModelOptions(visibleOptions, searchTerm)
const loadedItems = $derived(
filterModelOptions(visibleOptions, searchTerm)
.map((option, flatIndex) => ({ flatIndex, option }))
.filter(({ option }) => isLoadedLlamaCompat(option));
});
.filter(({ option }) => modelsStore.isModelLoaded(option.model))
);
const loadedIds = $derived(new SvelteSet(loadedItems.map((item) => item.option.id)));
// loaded models and favorites are listed once, at the top: the sections skip both
const sectionOptions = $derived(
@@ -163,35 +103,15 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
(option) => !modelsStore.favoriteModelIds.has(option.model) && !loadedIds.has(option.id)
)
);
const providerSections = $derived(
groupProviderOptions(
sectionOptions,
remoteProviders,
// a drill-in or a search reaches every model, the sections stay short
providerViewId || searchTerm ? Infinity : REMOTE_PROVIDER_MODEL_LIMIT,
modelsStore.recentModelIds
)
const groupedFilteredOptions = $derived(
groupModelOptions(sectionOptions, (model) => modelsStore.isModelLoaded(model))
);
const groupedFilteredOptions = $derived.by(() => {
if (isProviderView) {
const sections = providerSections.filter((section) => section.backendId === providerViewId);
return { ...EMPTY_GROUPS, providers: sections };
}
const local = groupModelOptions(sectionOptions.filter(isLocalOption), (m) =>
modelsStore.isModelLoaded(m)
);
return { ...local, providers: providerSections };
});
const isEmpty = $derived(
filteredOptions.length === 0 && favoriteItems.length === 0 && loadedItems.length === 0
);
const emptyMessage = $derived(searchTerm ? 'No models found.' : 'No models yet.');
// a row action can hand over to the models manager, which takes the focus;
// the selector closes so it does not sit open behind the dialog
// the manager takes the focus, so the selector closes instead of sitting behind it
$effect(() => {
if (!uiStore.manageModelsOpen) return;
@@ -207,8 +127,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function handleOpenChange(open: boolean) {
if (loading || updating) return;
// a single-model llama.cpp server with no other backend has no list to show,
// so the trigger opens the manager, which holds the one model's configuration
// a single-model server has no list: the trigger opens the manager instead
if (!isMultiModel) {
if (open) uiStore.openModelsManager();
@@ -216,7 +135,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
searchTerm = '';
providerViewId = null;
if (open && isRouter) {
modelsStore.props.fetchModalitiesForLoadedModels();
@@ -225,37 +143,15 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
opts.onOpenChange?.(open);
}
/**
* Switch the rendered view. Views are display only: the backend that serves
* requests follows the selected model, not the view.
*/
/** Drill into one remote backend's full model list. */
function openProvider(backendId: string) {
providerViewId = backendId;
searchTerm = '';
}
function closeProvider() {
providerViewId = null;
searchTerm = '';
}
/** Refresh a backend's models, e.g. right after it was added. */
async function showBackendModels(backendId: string): Promise<void> {
await backendsModelsStore.ensureLoaded(backendId);
}
async function handleSelect(modelId: string) {
// favorites live above the tabs and may belong to another backend, so the
// lookup spans every enabled backend
const option = allOptions.find((opt) => opt.id === modelId);
const option = options.find((opt) => opt.id === modelId);
if (!option) return;
let shouldCloseMenu = true;
if (onModelChange) {
const result = await onModelChange(rawModelId(option.id), option.model, option.backendId);
const result = await onModelChange(option.id, option.model);
if (result === false) {
shouldCloseMenu = false;
@@ -275,9 +171,7 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
// only the built-in server loads on request, and only in router mode
const canLoadHere = option.backendId === LOCAL_BACKEND_ID && isRouter;
if (!onModelChange && canLoadHere && !modelsStore.isModelLoaded(option.model)) {
if (!onModelChange && isRouter && !modelsStore.isModelLoaded(option.model)) {
isLoadingModel = true;
modelsStore.status
@@ -289,18 +183,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
function getDisplayOption(): ModelOption | undefined {
if (!isRouter) {
// External backend: the selection is backend-scoped, so it wins over
// the conversation's model, which may belong to another backend.
if (!serverStore.capabilities.props) {
const selected = activeId ? allOptions.find((option) => option.id === activeId) : undefined;
if (selected) return selected;
return currentModel
? allOptions.find((option) => option.model === currentModel)
: undefined;
}
const displayModel = serverModel || currentModel;
if (displayModel) {
@@ -325,11 +207,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
};
}
return allOptions.find((option) => option.model === currentModel);
return options.find((option) => option.model === currentModel);
}
if (activeId) {
return allOptions.find((option) => option.id === activeId);
return options.find((option) => option.id === activeId);
}
return undefined;
@@ -340,8 +222,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return activeId;
},
closeProvider,
get emptyMessage() {
return emptyMessage;
},
@@ -387,10 +267,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return isMultiModel;
},
get isProviderView() {
return isProviderView;
},
get isRouter() {
return isRouter;
},
@@ -403,8 +279,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return loading;
},
openProvider,
get options() {
return options;
},
@@ -421,8 +295,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
searchTerm = value;
},
showBackendModels,
get updating() {
return updating;
}
@@ -20,9 +20,7 @@ export interface UseReasoningMenuReturn {
/**
* Shared reactive state and helpers for the reasoning effort menu.
*
* Used by the models dropdown footer (`ModelsSelectorReasoningPanel`) and the
* mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
* thinking-support derivation and the effort selection logic.
* Shared by the dropdown footer and the mobile sheet.
*/
export function useReasoningMenu(): UseReasoningMenuReturn {
const conversationModel = $derived(
@@ -1,4 +1,4 @@
import { PATH_SEPARATOR } from '$lib/constants';
import { PATH_SEPARATOR, SETTINGS_KEYS } from '$lib/constants';
import {
BYTE,
BYTE_LABEL,
@@ -64,6 +64,7 @@ import {
} from '$lib/constants';
import { MODEL_ID, type ModelSidecar } from '$lib/constants';
import { HfEntryType, HfModelSort, SidecarForm } from '$lib/enums';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type {
HfCatalogEntry,
HfModelDetailInfo,
@@ -97,9 +98,9 @@ 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.
// Model details fetched this session, keyed by repo id: the Hub rate limits
// aggressively, so each repo costs at most one request per load. Failures stay
// uncached so the next mount can retry.
private static detailsCache = new SvelteMap<string, HfModelDetailInfo | null>();
private static detailsPending = new Map<string, Promise<HfModelDetailInfo | null>>();
@@ -143,23 +144,14 @@ 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.
*/
/** Details already fetched, for a caller that cannot await (a table sorting its rows). */
static cachedDetails(modelId: string): HfModelDetailInfo | null | undefined {
// llama.cpp model ids carry the quant tag after a colon
const [hfRepoId] = modelId.split(':');
const [hfRepoId] = modelId.split(MODEL_ID.QUANTIZATION_SEPARATOR);
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
@@ -198,6 +190,8 @@ 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.
@@ -353,8 +347,6 @@ export class HuggingFaceService {
return `${Math.floor(diffDays / DAYS_PER_YEAR)} ${YEARS_AGO_LABEL}`;
}
// Model Details & Files
/**
* Format a min-max size range with one shared unit, e.g. `19.0-28.6 GB`.
*/
@@ -380,6 +372,8 @@ 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.
@@ -396,10 +390,10 @@ export class HuggingFaceService {
* base model. Results are cached per repo.
*/
static getBaseModel(repoId: string): Promise<{ org: string; name: string } | null> {
// 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(':');
if (!HuggingFaceService.isEnabled()) return Promise.resolve(null);
// all quants of one repo share the cached lookup, so key it by the repo id
const [hfRepoId] = repoId.split(MODEL_ID.QUANTIZATION_SEPARATOR);
const cached = this.baseModelCache.get(hfRepoId);
if (cached !== undefined) return Promise.resolve(cached);
@@ -472,6 +466,8 @@ export class HuggingFaceService {
}
static async getCatalog(): Promise<HfCatalogEntry[]> {
if (!HuggingFaceService.isEnabled()) return [];
const response = await fetch(MODELS_DISCOVER_CATALOG_URL);
if (!response.ok) throw new Error(`Failed to fetch catalog: ${response.status}`);
@@ -480,6 +476,8 @@ export class HuggingFaceService {
}
static getDetails(modelId: string): Promise<HfModelDetailInfo | null> {
if (!HuggingFaceService.isEnabled()) return Promise.resolve(null);
const cached = HuggingFaceService.detailsCache.get(modelId);
if (cached !== undefined) return Promise.resolve(cached);
@@ -505,8 +503,8 @@ export class HuggingFaceService {
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
// the hub answers with a list of provider entries for one repo; 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];
@@ -517,8 +515,8 @@ export class HuggingFaceService {
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
// not cached: a rate limited or failed fetch retries on the next mount
// instead of hiding the model for the session
console.error(`Error fetching details for ${modelId}:`, error);
return null;
@@ -536,12 +534,12 @@ export class HuggingFaceService {
return `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}`;
}
// Utility Methods
static async getMostLiked(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.LIKES });
}
// Utility Methods
static async getNew(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.CREATED_AT });
}
@@ -554,6 +552,8 @@ export class HuggingFaceService {
* Fetch the raw README.md for a repo, with the YAML frontmatter stripped.
*/
static async getReadme(modelId: string): Promise<string | null> {
if (!HuggingFaceService.isEnabled()) return null;
// Do not encode the modelId, it contains slashes for author/name
const url = `${HF_BASE_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_RAW_PATH}${PATH_SEPARATOR}${HF_MAIN_BRANCH}${PATH_SEPARATOR}${HF_README_FILENAME}`;
@@ -577,28 +577,21 @@ 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 getTree(modelId: string): Promise<HfModelSibling[]> {
const cached = HuggingFaceService.treeCache.get(modelId);
static async getTree(modelId: string): Promise<HfModelSibling[]> {
if (!HuggingFaceService.isEnabled()) return [];
if (cached) return Promise.resolve(cached);
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}`;
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;
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)
throw new Error(`Failed to fetch tree for ${modelId}: ${response.status}`);
if (!response.ok) return files;
const data = (await response.json()) as HfModelSibling[];
@@ -606,30 +599,25 @@ export class HuggingFaceService {
url = HuggingFaceService.parseNextPageUrl(response.headers.get(HF_LINK_HEADER));
}
} catch {
// Return whatever was fetched before the failure.
}
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;
return files;
}
static async getTrending(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
return this.search({ limit, sort: HfModelSort.TRENDING_SCORE });
}
/**
* True when the UI may read model metadata from the Hugging Face Hub. Off by
* default: with it off, callers only see what the server's /v1/models reports.
*/
static isEnabled(): boolean {
return settingsStore.config[SETTINGS_KEYS.USE_HUGGING_FACE_HUB] ?? false;
}
/**
* Parameter count a repo reports: a GGUF repo spells it out in its metadata,
* a transformers repo reports it in its SafeTensors index.
@@ -717,6 +705,8 @@ export class HuggingFaceService {
* catalog entry; caller-provided `expand` entries are merged in.
*/
static async search(params: HfModelSearchParams = {}): Promise<HfModelInfo[]> {
if (!HuggingFaceService.isEnabled()) return [];
const { expand, limit = HF_DEFAULT_LIMIT, ...restParams } = params;
const url = this.buildUrl({
...restParams,
+12 -15
View File
@@ -143,7 +143,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
/** Load/unload operations and the /models/sse status feed, composed here. */
private _status = new ModelStatusManager(this);
// Dedup concurrent fetch() callers - all awaiters share the same inflight promise.
// every caller awaits the same inflight promise
// Without this, ?model=<name> URL handler races an in-progress fetch and sees an empty list.
private inflightFetch: Promise<void> | null = null;
@@ -278,8 +278,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return;
}
// Try a favorite model, but only one that exists on this backend: favorites
// are shared across backends, so a stored id may belong to another one
// favorites are shared across backends, so a stored id may belong to another one
const favorite = this.favoriteModelIds.values().next()?.value;
const favoriteOption = favorite
? availableModels.find((m) => m.id === favorite || m.model === favorite)
@@ -401,8 +400,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
/**
* Select a model. `recordRecent` marks a pick the user made in the selector, so
* automatic picks (startup default, conversation sync) stay out of the recency list.
* Select a model. `recordRecent` keeps automatic picks out of the recency list.
*/
async selectModelById(modelId: string, options?: { recordRecent?: boolean }): Promise<void> {
if (!modelId || this.updating) return;
@@ -545,23 +543,22 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
/**
* Warm the Hub record for the local repos, so a list can read the context and the
* size the server only reports once a model is loaded. Best effort, and only for
* installs that opted into the Hub.
* Warm the Hub record of the local repos, so a list can read the context and size
* the server reports only once a model is loaded.
*/
warmHubDetails(): void {
if (!settingsStore.config[SETTINGS_KEYS.ENABLE_DISCOVER_MODELS]) return;
if (!settingsStore.config[SETTINGS_KEYS.USE_HUGGING_FACE_HUB]) return;
const repos: string[] = [];
for (const option of this.models) {
const repo = option.model.split(':')[0];
const repo = option.model.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0];
if (repo?.includes('/') && !repos.includes(repo)) repos.push(repo);
}
// a large catalog would fire one request per repo on every load, so this warms
// the ones the lists mount first and lets the rest arrive on demand
// a large catalog would fire one request per repo on every load, so warm the ones
// the lists mount first and let the rest arrive on demand
for (const repo of repos.slice(0, MODEL_ROW_WINDOW)) {
void HuggingFaceService.getDetails(repo).catch(() => {});
}
@@ -582,9 +579,9 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
item,
parsed: ModelsService.parseModelId(item.id)
}));
// sidecar entries mark downloaded sidecar files, not loadable models; the
// router lists them under their own `<repo>:<quant>-<sidecar>` id, so pair
// the drafts with their repo here and drop the entries below
// sidecar entries mark downloaded sidecar files, not loadable models, and the router
// lists them under their own `<repo>:<quant>-<sidecar>` id: pair each draft with its
// repo here and drop the entries below
const draftSidecarsByRepo = new SvelteMap<string, ModelSidecarBadge[]>();
for (const { item, parsed } of entries) {
@@ -89,11 +89,8 @@ export class ModelStatusManager {
private downloadProgress = new SvelteMap<string, ModelDownloadProgress>();
/**
* Draft sidecars the registered models pull with them, keyed by the repo of
* the model that pulls them. Read from the models' `--model-draft` args,
* which is where the router records a sidecar it serves with a model; the
* listing carries no sidecar entries of its own. The badge keeps the
* sidecar's own repo, which may differ from the model's.
* Draft sidecars the registered models pull with them, keyed by the repo of the model
* that pulls them: the router records them in the model's `--model-draft` args.
*/
private draftSidecarsByRepo = $derived.by(() => {
const result = new SvelteMap<string, ModelSidecarBadge[]>();
@@ -476,11 +476,7 @@ class SettingsStore {
}
}
/**
* Move the trigger-only org name key onto the selector wide one. The setting
* grew from the trigger to every model id, so an explicit choice has to
* survive the rename.
*/
/** Move the trigger-only org name key onto the selector wide one. */
private migrateModelOrgNameKey() {
if (!browser) return;
+2 -9
View File
@@ -62,11 +62,7 @@ export interface ParsedModelId {
tags: string[];
}
/**
* Draft sidecar available for a model, with the quantization its file ships
* as and the repo it was pulled from, e.g. `mtp` at `Q8_0` of the model's own
* repo. Shown as a badge pair next to the model id.
*/
/** Draft sidecar of a model, shown as a `+ [KIND] [QUANT]` badge pair. */
export interface ModelSidecarBadge {
kind: ModelSidecar;
/** Quantization of the sidecar file itself; null when the name carries none. */
@@ -82,10 +78,7 @@ export interface ModalityCapabilities {
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.
*/
/** Sidecar file a listing reports as its own model entry, paired back to its model. */
export interface ModelSidecarFile {
id: string;
kind: ModelSidecar;
+16
View File
@@ -60,6 +60,22 @@ export function formatParameters(params: number | unknown): string {
* @param num - Number to format
* @returns Human-readable number
*/
/** Separator between the context values shown for one model, e.g. `8192 / 4096`. */
const CONTEXT_VALUE_SEPARATOR = ' / ';
/** Unit the context values are shown in. */
const CONTEXT_UNIT = 'tokens';
/** Shown where a value is unknown. */
const UNKNOWN_VALUE = '-';
/**
* Context window of a model: `configured / supported tokens`, or whichever is known.
*/
export function formatContextLength(values: number[]): string {
return values.length
? `${values.map((value) => formatParameters(value)).join(CONTEXT_VALUE_SEPARATOR)} ${CONTEXT_UNIT}`
: UNKNOWN_VALUE;
}
export function formatNumber(num: number | unknown): string {
if (typeof num !== 'number') return 'Unknown';
+9 -5
View File
@@ -5,16 +5,20 @@ export interface ModelFamilyGroup<T> {
label: string;
}
/** Leading letters of a repo name, e.g. the `Qwen` of `Qwen3.8-27B`. */
const FAMILY_LETTERS = /^[A-Za-z]+/;
/** Separators between the segments of a repo name. */
const FAMILY_SEPARATORS = /[-_.]/;
/**
* 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.
* Family a repo belongs to: the leading letters of its name, so `Qwen3.5` and
* `Qwen3.8-27B` both read as `Qwen`.
*/
export function modelFamilyKey(repo: string): string {
const name = repo.split('/').pop() ?? repo;
const letters = name.match(/^[A-Za-z]+/);
const letters = name.match(FAMILY_LETTERS);
return letters ? letters[0] : (name.split(/[-_.]/)[0] ?? name);
return letters ? letters[0] : (name.split(FAMILY_SEPARATORS)[0] ?? name);
}
/** Fold entries into families, so `Qwen` collects its sizes and variants. */
+2 -3
View File
@@ -4,9 +4,8 @@ import type { ActionReturn } from 'svelte/action';
const NEAR_VIEWPORT_MARGIN = '200px';
/**
* Calls back once the element comes near the viewport. Long lists mount
* hundreds of rows at once, so work that costs a request per row (avatars,
* Hub details) waits for this instead of firing on mount.
* Calls back once the element comes near the viewport, so per-row work that costs
* a request (avatars, Hub details) does not fire on mount.
*/
export function nearViewport(node: HTMLElement, onNear: () => void): ActionReturn {
if (typeof IntersectionObserver === 'undefined') {
@@ -103,8 +103,7 @@
orgName: 'intel'
}
],
loaded: loadedModels,
providers: []
loaded: loadedModels
};
function handleSelect(modelId: string) {
@@ -138,8 +137,7 @@
currentModel={null}
groups={{
available: [],
loaded: [loadedModels[0]],
providers: []
loaded: [loadedModels[0]]
}}
onSelect={handleSelect}
/>
@@ -154,8 +152,7 @@
favorites={favoriteModels}
groups={{
available: [],
loaded: [],
providers: []
loaded: []
}}
onSelect={handleSelect}
/>
-4
View File
@@ -55,10 +55,6 @@ export default defineConfig(({ mode }) => {
fs: {
allow: [searchForWorkspaceRoot(process.cwd()), resolve(__dirname, 'tests')]
},
headers: {
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin'
},
proxy: {
'/cors-proxy': SERVER_ORIGIN,
'/models': SERVER_ORIGIN,