mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-02 02:47:26 -05:00
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -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'
|
||||
)}
|
||||
|
||||
+6
-29
@@ -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}
|
||||
/>
|
||||
|
||||
+1
-2
@@ -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 })}
|
||||
|
||||
+2
-10
@@ -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
|
||||
|
||||
+5
-2
@@ -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}
|
||||
>
|
||||
|
||||
+73
@@ -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>
|
||||
+119
@@ -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>
|
||||
+195
@@ -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>
|
||||
|
||||
|
||||
+25
-15
@@ -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
-1
@@ -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?.();
|
||||
}}
|
||||
|
||||
+19
-25
@@ -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}
|
||||
|
||||
+17
-54
@@ -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';
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Vendored
+2
-9
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user