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

Assisted-by: pi:zai-org/GLM-5.3-Flash
This commit is contained in:
Aleksander Grygier
2026-10-01 08:28:06 +02:00
parent 6541d7dac9
commit 7e26a5dd52
15 changed files with 78 additions and 425 deletions
@@ -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}
/>
@@ -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.
*/
@@ -1,6 +1,5 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import { isLocalOption } from './ModelsManager/utils';
import { ModelCapability } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
@@ -21,7 +20,7 @@
$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
@@ -1,5 +1,4 @@
<script lang="ts">
import { isLocalOption } from './ModelsManager/utils';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { nearViewport } from '$lib/utils';
@@ -22,7 +21,7 @@
$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] ?? '';
@@ -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,
@@ -128,9 +118,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 +151,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 +159,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 +211,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 +235,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)]'
]}
@@ -387,8 +358,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"
@@ -455,15 +424,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,8 +36,6 @@
favorites = [],
groups,
loaded = [],
onProviderBack,
onProviderOpen,
onSelect,
renderOption,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
@@ -227,39 +221,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);
@@ -102,7 +96,6 @@
aliases={option.aliases}
class="min-w-0 flex-1"
draftSidecars={option.draftSidecars}
drafts={modelDraftsFor(option)}
hideCapabilities
hideModalities
{hideOrgName}
@@ -119,15 +112,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,
@@ -73,13 +64,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 +73,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 +92,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 +177,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 +201,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 +229,3 @@
{/if}
{/if}
</div>
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -63,6 +63,7 @@ export { default as ModelsSection } from './ModelsSection.svelte';
* styling stay consistent across every model-id surface.
*/
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
/**
* **ModelDraftSidecars** - draft sidecar badges
*
@@ -71,18 +72,4 @@ export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
* 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';
export * from './ModelsSelector';
@@ -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 {
@@ -150,48 +134,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,12 @@ export const DIALOG_SUBMENU_CONTENT = 'w-60';
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
/**
* Selects the search input inside an open dropdown-menu's content, to focus it
* after the menu mounts (bits-ui's default auto-focus can yank page scroll).
*/
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
@@ -45,6 +45,9 @@ 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'];
@@ -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,29 @@ 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)
// loaded models lead the list
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,28 +104,9 @@ 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
);
@@ -207,8 +129,8 @@ 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 llama.cpp server has no list to show, so the trigger opens
// the manager, which holds the one model's configuration
if (!isMultiModel) {
if (open) uiStore.openModelsManager();
@@ -216,7 +138,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
}
searchTerm = '';
providerViewId = null;
if (open && isRouter) {
modelsStore.props.fetchModalitiesForLoadedModels();
@@ -225,37 +146,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 +174,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 +186,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 +210,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 +225,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return activeId;
},
closeProvider,
get emptyMessage() {
return emptyMessage;
},
@@ -387,10 +270,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return isMultiModel;
},
get isProviderView() {
return isProviderView;
},
get isRouter() {
return isRouter;
},
@@ -403,8 +282,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
return loading;
},
openProvider,
get options() {
return options;
},
@@ -421,8 +298,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
searchTerm = value;
},
showBackendModels,
get updating() {
return updating;
}
@@ -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}
/>