mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-10-01 02:17:42 -05:00
ui : group the model selector components
The selector surfaces move under models/ModelsSelector with their own barrel: the dropdown and sheet relocate, the list, option and trigger icon split out, and the shared list helpers join the navigation utils. The searchable dropdown gains a sticky footer and per-surface class hooks. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
@@ -59,6 +59,7 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
|
||||
* `class` and `style` props and inherits color via `currentColor`.
|
||||
*/
|
||||
export { default as Logo } from './Logo.svelte';
|
||||
|
||||
/**
|
||||
* **CollapsibleSection** - Section with a trigger that expands in place
|
||||
*
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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';
|
||||
@@ -20,7 +21,7 @@
|
||||
$effect(() => {
|
||||
template = '';
|
||||
|
||||
if (!isNearViewport) return;
|
||||
if (!isNearViewport || !isLocalOption(option)) 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,4 +1,5 @@
|
||||
<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';
|
||||
@@ -21,7 +22,7 @@
|
||||
$effect(() => {
|
||||
fetched = null;
|
||||
|
||||
if (reported || !isNearViewport) return;
|
||||
if (reported || !isNearViewport || !isLocalOption(option)) return;
|
||||
|
||||
// a local GGUF carries its trained context in the model metadata
|
||||
const repo = option.model.split(':')[0] ?? '';
|
||||
|
||||
+191
-162
@@ -1,32 +1,43 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from './utils';
|
||||
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption
|
||||
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 { MODEL_ID, MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
|
||||
import {
|
||||
DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR,
|
||||
MODEL_ID,
|
||||
MODEL_ICON,
|
||||
SETTINGS_KEYS
|
||||
} from '$lib/constants';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
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) => Promise<boolean> | boolean | void;
|
||||
onModelChange?: (
|
||||
modelId: string,
|
||||
modelName: string,
|
||||
backendId?: string
|
||||
) => Promise<boolean> | boolean | void;
|
||||
useGlobalSelection?: boolean;
|
||||
}
|
||||
|
||||
@@ -34,6 +45,7 @@
|
||||
class: className = '',
|
||||
currentModel = null,
|
||||
disabled = false,
|
||||
error = false,
|
||||
forceForegroundText = false,
|
||||
onModelChange,
|
||||
useGlobalSelection = false
|
||||
@@ -41,9 +53,7 @@
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
// The model submenu opens together with the menu so the list and its search
|
||||
// box are immediately available, as before the submenu was introduced
|
||||
let modelSubOpen = $state(false);
|
||||
let showAddBackend = $state(false);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -51,22 +61,15 @@
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedId = null;
|
||||
|
||||
if (open) {
|
||||
// Defer submenu open so the Sub component is mounted first;
|
||||
// setting bind:open synchronously can be lost if the Sub hasn't
|
||||
// rendered yet.
|
||||
queueMicrotask(() => {
|
||||
if (isOpen) modelSubOpen = true;
|
||||
});
|
||||
} else {
|
||||
modelSubOpen = false;
|
||||
}
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
});
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
const selectedOption = $derived(ms.getDisplayOption());
|
||||
const triggerModel = $derived(selectedOption?.model ?? null);
|
||||
|
||||
// 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);
|
||||
|
||||
/** Draft sidecar as it reads in the trigger tooltip, with its own quant. */
|
||||
function draftSidecarLabel(baseModel: string, badge: ModelSidecarBadge): string {
|
||||
@@ -89,39 +92,45 @@
|
||||
return [option.model, ...drafts].join(' + ');
|
||||
}
|
||||
|
||||
const showOrgNameInTrigger = $derived(
|
||||
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the model submenu's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
// 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.
|
||||
$effect(() => {
|
||||
if (!isOpen || !modelSubOpen) return;
|
||||
if (!isOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-sub-content"] input'
|
||||
);
|
||||
let frames = 0;
|
||||
let handle = requestAnimationFrame(function focusSearch() {
|
||||
const input = document.querySelector<HTMLElement>(DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
if (input) {
|
||||
input.focus({ preventScroll: true });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (frames++ < 20) handle = requestAnimationFrame(focusSearch);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(handle);
|
||||
});
|
||||
|
||||
// Keyboard navigation follows the on-screen row order, not the flat option list order.
|
||||
let visualOrder = $derived.by(() => {
|
||||
const order: string[] = [];
|
||||
|
||||
for (const item of ms.favoriteItems) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
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;
|
||||
});
|
||||
@@ -148,6 +157,20 @@
|
||||
highlightedId = visualOrder[index];
|
||||
}
|
||||
|
||||
function handleManageModels() {
|
||||
isOpen = false;
|
||||
|
||||
// let the menu finish closing before the dialog takes focus
|
||||
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) {
|
||||
@@ -156,12 +179,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const model = modelsStore.routerModels.find((m) => m.id === modelId);
|
||||
const rawId = rawModelId(modelId);
|
||||
const model = modelsStore.routerModels.find((m) => m.id === rawId);
|
||||
const status = model?.status?.value as ServerModelStatus | undefined;
|
||||
|
||||
if (status === ServerModelStatus.LOADING) return;
|
||||
|
||||
await modelsStore.status.unload(modelId);
|
||||
await modelsStore.status.unload(rawId);
|
||||
}
|
||||
|
||||
export function open() {
|
||||
@@ -190,29 +214,33 @@
|
||||
</script>
|
||||
|
||||
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isRouter}
|
||||
{:else if ms.options.length === 0 && ms.isMultiModel}
|
||||
{#if currentModel}
|
||||
<span
|
||||
class={[
|
||||
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
|
||||
className
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
||||
style="max-width: min(calc(100cqw - 10rem), 48rem)"
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
<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>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
{@const triggerModel = selectedOption?.model}
|
||||
{@const triggerStatus = triggerModel
|
||||
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
|
||||
: undefined}
|
||||
@@ -224,7 +252,7 @@
|
||||
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
|
||||
: 0}
|
||||
|
||||
{#if ms.isRouter}
|
||||
{#if ms.isMultiModel}
|
||||
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
@@ -234,35 +262,36 @@
|
||||
{...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`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
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
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
isOpen && 'text-foreground',
|
||||
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
<ModelsSelectorTriggerIcon
|
||||
class="size-3.5 shrink-0 mr-0.375"
|
||||
option={selectedOption}
|
||||
/>
|
||||
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class="flex min-w-0 items-center gap-0.5">
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
draftSidecars={selectedOption.draftSidecars}
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideOrgName={!showOrgName}
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
@@ -287,94 +316,98 @@
|
||||
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
|
||||
class="w-full md:min-w-80 md:w-112 max-w-[calc(100vw-2rem)] p-0! max-h-[min(40rem,calc(var(--bits-dropdown-menu-content-available-height)-1rem))]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenu.Sub bind:open={modelSubOpen}>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
<MODEL_SELECTOR_ICON class="h-4 w-4" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
draftSidecars={selectedOption.draftSidecars}
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
|
||||
{/if}
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<div class="models-list">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
|
||||
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage={ms.emptyMessage}
|
||||
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
|
||||
onSearchChange={(v) => ms.setSearchTerm(v)}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
placeholder="Search models..."
|
||||
searchClass="bg-transparent"
|
||||
searchValue={ms.searchTerm}
|
||||
>
|
||||
<!-- Option list; the search header sticks to the top and the actions
|
||||
footer to the bottom of the content scrollport. -->
|
||||
<div class="models-list px-1.5">
|
||||
{#if !ms.isCurrentModelInCache && currentModel}
|
||||
<!-- Show unavailable model as first option (disabled) -->
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-selected="true"
|
||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
||||
disabled
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<ModelId
|
||||
class="flex-1"
|
||||
hideOrgName={!showOrgName}
|
||||
hideQuantization
|
||||
modelId={currentModel}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if ms.isEmpty}
|
||||
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
{isHighlighted}
|
||||
{isSelected}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onSelect={ms.handleSelect}
|
||||
{option}
|
||||
showBaseModelAvatar={!settingsStore.config.groupModelsByFamily}
|
||||
showRepoOrgAvatar={settingsStore.config.groupModelsByFamily}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
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"
|
||||
{showOrgName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<!-- Sticky action footer: it sticks to the bottom of the content
|
||||
scrollport, past the option list. -->
|
||||
<DropdownMenu.Group class="px-2">
|
||||
<DropdownMenu.Item class="gap-2" onSelect={handleManageModels}>
|
||||
<MODEL_ICON class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
Manage models
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
@@ -387,7 +420,7 @@
|
||||
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`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
@@ -399,21 +432,20 @@
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
>
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
<ModelsSelectorTriggerIcon
|
||||
class="size-3.5 shrink-0 mr-0.375"
|
||||
option={selectedOption}
|
||||
/>
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 overflow-hidden"
|
||||
draftSidecars={selectedOption.draftSidecars}
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideOrgName={!showOrgName}
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{/if}
|
||||
@@ -423,18 +455,15 @@
|
||||
|
||||
{#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>
|
||||
|
||||
{#if ms.showModelDialog}
|
||||
<DialogModelInformation
|
||||
modelId={ms.infoModelId}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
open={ms.showModelDialog}
|
||||
/>
|
||||
{/if}
|
||||
<DialogBackendForm
|
||||
bind:open={showAddBackend}
|
||||
onSaved={(backend) => void ms.showBackendModels(backend.id)}
|
||||
/>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
|
||||
import { Heart, Power } from '@lucide/svelte';
|
||||
import { GroupedList, ModelAvatar, ModelsSelectorOption } from '$lib/components/app';
|
||||
import { ModelsSection } from '$lib/components/app';
|
||||
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
||||
import Logo from '$lib/components/app/misc/Logo.svelte';
|
||||
import {
|
||||
type GroupedModelOptions,
|
||||
type ModelItem,
|
||||
windowLocalGroups
|
||||
} from '$lib/components/app/navigation/utils';
|
||||
import { LOCAL_BACKEND_ID, MODEL_ROW_WINDOW } from '$lib/constants';
|
||||
import { ModelDownloadConfirmAction } from '$lib/enums';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
|
||||
|
||||
interface Props {
|
||||
groups: GroupedModelOptions;
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
/** Favorite models of every backend, listed in their own section. */
|
||||
favorites?: ModelItem[];
|
||||
/** Loaded models of every llama-compat backend, leading the list. */
|
||||
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 {
|
||||
activeId,
|
||||
currentModel,
|
||||
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.
|
||||
let visibleCount = $state(MODEL_ROW_WINDOW);
|
||||
let sentinelEl = $state<HTMLElement | null>(null);
|
||||
const localGroups = $derived(windowLocalGroups(groups, visibleCount));
|
||||
const localRowCount = $derived(
|
||||
groups.loaded.length + groups.available.reduce((count, group) => count + group.items.length, 0)
|
||||
);
|
||||
const hasMoreLocal = $derived(localGroups.shown < localRowCount);
|
||||
|
||||
$effect(() => {
|
||||
const sentinel = sentinelEl;
|
||||
|
||||
if (!sentinel || !hasMoreLocal) return;
|
||||
|
||||
// scroll does not bubble, so listen in the capture phase and ask the
|
||||
// sentinel where it is instead of guessing which ancestor scrolls
|
||||
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
|
||||
if (rect.top > window.innerHeight || rect.bottom < 0) return;
|
||||
|
||||
visibleCount += MODEL_ROW_WINDOW;
|
||||
};
|
||||
|
||||
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
|
||||
onScroll();
|
||||
|
||||
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)
|
||||
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.
|
||||
// The target is kept while the dialog closes so its copy stays rendered.
|
||||
let pendingCancel = $state('');
|
||||
let cancelOpen = $state(false);
|
||||
|
||||
function requestCancel(repoWithTag: string) {
|
||||
pendingCancel = repoWithTag;
|
||||
cancelOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet familyHeading({ group: family }: { group: ModelFamilyGroup<ModelItem> })}
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 select-none">
|
||||
<ModelAvatar
|
||||
option={family.entries[0].option}
|
||||
showBaseModelAvatar
|
||||
showQuantBadge={false}
|
||||
size="size-6"
|
||||
/>
|
||||
|
||||
<span class="truncate text-[13px] font-semibold text-muted-foreground/70">
|
||||
{family.label}
|
||||
</span>
|
||||
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{family.entries.length} model{family.entries.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet listItem({ depth, entry }: { depth: number; entry: ModelItem })}
|
||||
<div style="padding-left: {depth * 16}px">{@render render(entry, !showOrgName)}</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet listRows(items: ModelItem[], prefix: string)}
|
||||
{#if settingsStore.config.groupModelsByFamily}
|
||||
<GroupedList
|
||||
group={familyHeading}
|
||||
groupStateKey={prefix}
|
||||
groups={groupModelFamilies(items, (row) => row.option.model).map((family) => ({
|
||||
entries: family.entries,
|
||||
group: family,
|
||||
key: `${prefix}-${family.key}`
|
||||
}))}
|
||||
item={listItem}
|
||||
keyOf={(row) => `${prefix}-${row.option.id}`}
|
||||
stickyStyle="top: calc(var(--dropdown-sticky-height, 0px) + 2.25rem - 1px)"
|
||||
/>
|
||||
{:else}
|
||||
<GroupedList item={listItem} {items} keyOf={(row) => `${prefix}-${row.option.id}`} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
isHighlighted={false}
|
||||
{isSelected}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
showBaseModelAvatar={!settingsStore.config.groupModelsByFamily}
|
||||
showRepoOrgAvatar={settingsStore.config.groupModelsByFamily}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if loaded.length > 0}
|
||||
<ModelsSection
|
||||
count={loaded.length}
|
||||
label="Loaded models"
|
||||
persistKey="loaded"
|
||||
revealChevronOnHover
|
||||
sticky
|
||||
>
|
||||
{#snippet icon()}
|
||||
<Power class="h-3.5 w-3.5 shrink-0" />
|
||||
{/snippet}
|
||||
|
||||
{#each loaded as item (item.option.id)}
|
||||
{@render render(item, !showOrgName)}
|
||||
{/each}
|
||||
</ModelsSection>
|
||||
{/if}
|
||||
|
||||
{#if favorites.length > 0}
|
||||
<!-- Favorites come first; the sections below skip them -->
|
||||
<ModelsSection label="Favorites" persistKey="favorites" revealChevronOnHover sticky>
|
||||
{#snippet icon()}
|
||||
<Heart class="h-3.5 w-3.5 shrink-0" />
|
||||
{/snippet}
|
||||
|
||||
{#each favorites as item (`fav-${item.option.id}`)}
|
||||
{@render render(item, !showOrgName)}
|
||||
{/each}
|
||||
</ModelsSection>
|
||||
{/if}
|
||||
|
||||
{#if getDownloadEntries.length > 0}
|
||||
<p class={headerClass} style={headerStyle}>Download in progress</p>
|
||||
|
||||
{#each getDownloadEntries as entry (entry.repoWithTag)}
|
||||
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} {showOrgName} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#snippet localRows()}
|
||||
<!-- the loaded models first -->
|
||||
{#each localGroups.loaded as item (`loaded-${item.option.id}`)}
|
||||
{@render render(item, !showOrgName)}
|
||||
{/each}
|
||||
|
||||
{@render listRows(
|
||||
localGroups.available.flatMap((group) => group.items),
|
||||
'local'
|
||||
)}
|
||||
|
||||
{#if hasMoreLocal}
|
||||
<div bind:this={sentinelEl} aria-hidden="true" class="h-px"></div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if groups.loaded.length > 0 || groups.available.length > 0}
|
||||
<ModelsSection label="Local models" persistKey={LOCAL_BACKEND_ID} revealChevronOnHover sticky>
|
||||
{#snippet icon()}
|
||||
<Logo class="shrink-0" style="--size: 0.875rem" />
|
||||
{/snippet}
|
||||
|
||||
{@render localRows()}
|
||||
</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)}
|
||||
open={cancelOpen}
|
||||
repoWithTag={pendingCancel}
|
||||
/>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import { modelDraftsFor } from '../ModelsManager/utils';
|
||||
import {
|
||||
ModelAvatar,
|
||||
ModelCapabilities,
|
||||
ModelId,
|
||||
ModelLoadControl,
|
||||
ModelRowActions
|
||||
} from '$lib/components/app';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
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;
|
||||
isSelected: boolean;
|
||||
isHighlighted: boolean;
|
||||
isFav: boolean;
|
||||
hideOrgName?: boolean;
|
||||
onSelect: (modelId: string) => void;
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
/** Show the base model's org as the main avatar and the repo (quant) org as the corner badge; resolves the base org lazily via HF. */
|
||||
showBaseModelAvatar?: boolean;
|
||||
/** Show the repo's own org instead of the base model's. */
|
||||
showRepoOrgAvatar?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
hideOrgName = false,
|
||||
isFav,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
onKeyDown,
|
||||
onMouseEnter,
|
||||
onSelect,
|
||||
option,
|
||||
showBaseModelAvatar = false,
|
||||
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);
|
||||
|
||||
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 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.
|
||||
</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',
|
||||
// skip layout and paint for rows scrolled out of the long lists
|
||||
'[content-visibility:auto] [contain-intrinsic-size:auto_2.25rem]',
|
||||
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}
|
||||
>
|
||||
<ModelAvatar {option} {showBaseModelAvatar} {showRepoOrgAvatar} />
|
||||
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="min-w-0 flex-1"
|
||||
draftSidecars={option.draftSidecars}
|
||||
drafts={modelDraftsFor(option)}
|
||||
hideCapabilities
|
||||
hideModalities
|
||||
{hideOrgName}
|
||||
{modalities}
|
||||
modelId={option.model}
|
||||
tags={option.tags}
|
||||
title={option.model}
|
||||
/>
|
||||
|
||||
{#if showCapabilities}
|
||||
<ModelCapabilities {option} />
|
||||
{/if}
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<ModelRowActions {isFav} {option} />
|
||||
|
||||
<ModelLoadControl
|
||||
{canLoad}
|
||||
{isFailed}
|
||||
{isLoaded}
|
||||
{isLoading}
|
||||
{isSleeping}
|
||||
{option}
|
||||
showBackendMark
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<ModelLoadHighlight percent={loadPercent} />
|
||||
{/if}
|
||||
</div>
|
||||
+89
-35
@@ -1,24 +1,32 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
DialogModelInformation,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
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';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
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) => Promise<boolean> | boolean | void;
|
||||
onModelChange?: (
|
||||
modelId: string,
|
||||
modelName: string,
|
||||
backendId?: 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;
|
||||
@@ -28,12 +36,14 @@
|
||||
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,
|
||||
@@ -44,6 +54,9 @@
|
||||
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() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
@@ -53,16 +66,36 @@
|
||||
ms.handleOpenChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleManageModels() {
|
||||
sheetOpen = false;
|
||||
|
||||
// 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]}>
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
||||
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
Loading models…
|
||||
</div>
|
||||
{:else if ms.options.length === 0 && ms.isRouter}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{: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>
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
{@const triggerModel = selectedOption?.model}
|
||||
@@ -77,17 +110,19 @@
|
||||
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
|
||||
: 0}
|
||||
|
||||
{#if ms.isRouter}
|
||||
{#if ms.isMultiModel}
|
||||
<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`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
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
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground',
|
||||
sheetOpen && 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
@@ -95,7 +130,7 @@
|
||||
style="max-width: min(calc(100cqw - 9rem), 20rem)"
|
||||
type="button"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<ModelsSelectorTriggerIcon class="h-3.5 w-3.5 shrink-0" option={selectedOption} />
|
||||
|
||||
{#if !selectedOption}
|
||||
<span class="min-w-0 font-medium">Select model</span>
|
||||
@@ -156,20 +191,35 @@
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{/if}
|
||||
|
||||
{#if ms.filteredOptions.length === 0}
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
|
||||
{#if ms.isEmpty}
|
||||
<p class="px-3 py-3 text-center text-sm text-muted-foreground">{ms.emptyMessage}</p>
|
||||
{/if}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
favorites={ms.favoriteItems}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
loaded={ms.loadedItems}
|
||||
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
|
||||
onProviderOpen={ms.openProvider}
|
||||
onSelect={ms.handleSelect}
|
||||
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
|
||||
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
|
||||
{showOrgName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-2 pb-1">
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent"
|
||||
onclick={handleManageModels}
|
||||
type="button"
|
||||
>
|
||||
<MODEL_ICON class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
Manage models
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
@@ -177,21 +227,28 @@
|
||||
<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`,
|
||||
!ms.isCurrentModelInCache
|
||||
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
||||
: forceForegroundText
|
||||
? 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
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
|
||||
? 'text-foreground'
|
||||
: 'text-foreground'
|
||||
: ms.isHighlightedCurrentModelActive
|
||||
? 'text-foreground'
|
||||
: 'text-foreground'
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<ModelsSelectorTriggerIcon class="h-3.5 w-3.5 shrink-0" option={selectedOption} />
|
||||
|
||||
<ModelId class="font-medium" hideQuantization modelId={selectedOption?.model || ''} />
|
||||
<ModelId
|
||||
class="font-medium"
|
||||
hideOrgName={!showOrgName}
|
||||
hideQuantization
|
||||
modelId={selectedOption?.model || ''}
|
||||
/>
|
||||
|
||||
{#if ms.updating}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
@@ -201,10 +258,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ms.showModelDialog}
|
||||
<DialogModelInformation
|
||||
modelId={ms.infoModelId}
|
||||
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
||||
open={ms.showModelDialog}
|
||||
/>
|
||||
{/if}
|
||||
<DialogBackendForm
|
||||
bind:open={showAddBackend}
|
||||
onSaved={(backend) => void ms.showBackendModels(backend.id)}
|
||||
/>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import ModelAvatar from '../ModelAvatar.svelte';
|
||||
import { MODEL_ICON } from '$lib/constants';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
/** Selected model; its avatar decides the mark, the generic icon when absent. */
|
||||
option?: ModelOption | null;
|
||||
}
|
||||
|
||||
let { class: className = 'h-3.5 w-3.5', option }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if option}
|
||||
<ModelAvatar class={className} {option} showQuantBadge={false} showRepoOrgAvatar size={className}>
|
||||
{#snippet fallback()}
|
||||
<MODEL_ICON class={className} />
|
||||
{/snippet}
|
||||
</ModelAvatar>
|
||||
{:else}
|
||||
<MODEL_ICON class={className} />
|
||||
{/if}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **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,76 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { GroupedModelOptions, ModelItem } from './utils';
|
||||
import { ModelsSelectorOption } from '$lib/components/app';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
groups: GroupedModelOptions;
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
orgHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onInfoClick: (modelName: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
}
|
||||
|
||||
let {
|
||||
activeId,
|
||||
currentModel,
|
||||
groups,
|
||||
onInfoClick,
|
||||
onSelect,
|
||||
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
|
||||
renderOption,
|
||||
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
</script>
|
||||
|
||||
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
{hideOrgName}
|
||||
{isFav}
|
||||
isHighlighted={false}
|
||||
{isSelected}
|
||||
{onInfoClick}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if groups.loaded.length > 0}
|
||||
<p class={sectionHeaderClass}>Loaded models</p>
|
||||
|
||||
{#each groups.loaded as item (`loaded-${item.option.id}`)}
|
||||
{@render render(item, false)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if groups.favorites.length > 0}
|
||||
<p class={sectionHeaderClass}>Favorite models</p>
|
||||
|
||||
{#each groups.favorites as item (`fav-${item.option.id}`)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if groups.available.length > 0}
|
||||
<p class={sectionHeaderClass}>Available models</p>
|
||||
|
||||
{#each groups.available as group (group.orgName)}
|
||||
{#if group.orgName}
|
||||
<p class={orgHeaderClass}>{group.orgName}</p>
|
||||
{/if}
|
||||
|
||||
{#each group.items as item (item.option.id)}
|
||||
{@render render(item, true)}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -8,72 +8,11 @@
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* **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';
|
||||
|
||||
/**
|
||||
* **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';
|
||||
|
||||
/**
|
||||
* **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';
|
||||
|
||||
/**
|
||||
* **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';
|
||||
|
||||
/** * **ModelBadge** - Model name display badge
|
||||
*
|
||||
* Compact badge showing current model name with package icon.
|
||||
@@ -108,7 +47,13 @@ export { default as ModelBadge } from './ModelBadge.svelte';
|
||||
* 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 ModelId } from './ModelId.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';
|
||||
|
||||
/**
|
||||
* **ModelCapabilityIcons** - Capability and modality icon row
|
||||
@@ -139,3 +84,5 @@ 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';
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { ModelModality } from '$lib/enums';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export interface ModelItem {
|
||||
option: ModelOption;
|
||||
flatIndex: number;
|
||||
}
|
||||
|
||||
export interface OrgGroup {
|
||||
orgName: string | null;
|
||||
items: ModelItem[];
|
||||
}
|
||||
|
||||
export interface GroupedModelOptions {
|
||||
loaded: ModelItem[];
|
||||
favorites: ModelItem[];
|
||||
available: OrgGroup[];
|
||||
}
|
||||
|
||||
function matchesModality(option: ModelOption, term: string): boolean {
|
||||
const modalities = option.modalities;
|
||||
|
||||
if (!modalities) return false;
|
||||
|
||||
switch (term) {
|
||||
case ModelModality.VISION.toLowerCase():
|
||||
return modalities.vision;
|
||||
case ModelModality.AUDIO.toLowerCase():
|
||||
return modalities.audio;
|
||||
case ModelModality.VIDEO.toLowerCase():
|
||||
return modalities.video;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
|
||||
if (!term) return options;
|
||||
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.model.toLowerCase().includes(term) ||
|
||||
option.name?.toLowerCase().includes(term) ||
|
||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
|
||||
matchesModality(option, term)
|
||||
);
|
||||
}
|
||||
|
||||
export function groupModelOptions(
|
||||
filteredOptions: ModelOption[],
|
||||
favoriteIds: Set<string>,
|
||||
isModelLoaded: (model: string) => boolean
|
||||
): GroupedModelOptions {
|
||||
// Loaded models
|
||||
const loaded: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
if (isModelLoaded(filteredOptions[i].model)) {
|
||||
loaded.push({ flatIndex: i, option: filteredOptions[i] });
|
||||
}
|
||||
}
|
||||
|
||||
// Favorites (excluding loaded)
|
||||
const loadedModelIds = new Set(loaded.map((item) => item.option.model));
|
||||
const favorites: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
if (
|
||||
favoriteIds.has(filteredOptions[i].model) &&
|
||||
!loadedModelIds.has(filteredOptions[i].model)
|
||||
) {
|
||||
favorites.push({ flatIndex: i, option: filteredOptions[i] });
|
||||
}
|
||||
}
|
||||
|
||||
// Available models grouped by org (excluding loaded and favorites)
|
||||
const available: OrgGroup[] = [];
|
||||
const orgGroups = new SvelteMap<string, ModelItem[]>();
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
const option = filteredOptions[i];
|
||||
|
||||
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
|
||||
|
||||
const key = option.parsedId?.orgName ?? '';
|
||||
|
||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
||||
|
||||
orgGroups.get(key)!.push({ flatIndex: i, option });
|
||||
}
|
||||
|
||||
for (const [orgName, items] of orgGroups) {
|
||||
available.push({ items, orgName: orgName || null });
|
||||
}
|
||||
|
||||
return { available, favorites, loaded };
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { SearchInput } from '$lib/components/app';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -10,41 +9,67 @@
|
||||
onSearchKeyDown?: (event: KeyboardEvent) => void;
|
||||
emptyMessage?: string;
|
||||
isEmpty?: boolean;
|
||||
/** Extra classes for the wrapper around the option list. */
|
||||
contentClass?: string;
|
||||
/** Extra classes for the search input. */
|
||||
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`.
|
||||
*/
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
contentClass = '',
|
||||
emptyMessage = 'No items found',
|
||||
footer,
|
||||
isEmpty = false,
|
||||
onSearchChange,
|
||||
onSearchKeyDown,
|
||||
placeholder = 'Search...',
|
||||
searchClass = '',
|
||||
searchValue = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
// the search sticks to the top; its height is published so
|
||||
// list section headers can stick right below it
|
||||
let stickyHeaderHeight = $state(0);
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
|
||||
<div bind:clientHeight={stickyHeaderHeight} class="sticky top-0 z-20 bg-popover p-1.5">
|
||||
<SearchInput
|
||||
bind:value={searchValue}
|
||||
class={searchClass}
|
||||
onInput={onSearchChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
{@render children()}
|
||||
<div class={contentClass}>
|
||||
<!-- wrapper carries the sticky offset: bits-ui owns the style of the scrollport -->
|
||||
<div style="--dropdown-sticky-height: {stickyHeaderHeight}px">
|
||||
{@render children()}
|
||||
|
||||
{#if isEmpty}
|
||||
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
{#if isEmpty}
|
||||
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if footer}
|
||||
<DropdownMenu.Separator />
|
||||
<div class="sticky bottom-0 z-20 bg-popover py-1.5">
|
||||
<div
|
||||
aria-orientation="horizontal"
|
||||
class="h-px bg-border/20 mb-1.5 mx-1.5"
|
||||
role="separator"
|
||||
></div>
|
||||
|
||||
{@render footer()}
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ModelModality } from '$lib/enums';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
export interface ModelItem {
|
||||
option: ModelOption;
|
||||
flatIndex: number;
|
||||
}
|
||||
|
||||
export interface OrgGroup {
|
||||
orgName: string | null;
|
||||
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 {
|
||||
const modalities = option.modalities;
|
||||
|
||||
if (!modalities) return false;
|
||||
|
||||
switch (term) {
|
||||
case ModelModality.VISION.toLowerCase():
|
||||
return modalities.vision;
|
||||
case ModelModality.AUDIO.toLowerCase():
|
||||
return modalities.audio;
|
||||
case ModelModality.VIDEO.toLowerCase():
|
||||
return modalities.video;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
|
||||
if (!term) return options;
|
||||
|
||||
return options.filter(
|
||||
(option) =>
|
||||
option.model.toLowerCase().includes(term) ||
|
||||
option.name?.toLowerCase().includes(term) ||
|
||||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
|
||||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
|
||||
matchesModality(option, term)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Favorite models across every backend, in list order. The favorites tab spans
|
||||
* all backends, so they are collected from the full option list.
|
||||
*/
|
||||
export function groupFavoriteOptions(
|
||||
options: ModelOption[],
|
||||
favoriteIds: Set<string>
|
||||
): ModelItem[] {
|
||||
const favorites: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
if (favoriteIds.has(options[i].model)) {
|
||||
favorites.push({ flatIndex: i, option: options[i] });
|
||||
}
|
||||
}
|
||||
|
||||
return favorites;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function windowLocalGroups(
|
||||
groups: GroupedModelOptions,
|
||||
limit: number
|
||||
): { available: GroupedModelOptions['available']; loaded: ModelItem[]; shown: number } {
|
||||
const loaded = groups.loaded.slice(0, Math.max(0, limit));
|
||||
|
||||
let budget = limit - loaded.length;
|
||||
|
||||
const available: GroupedModelOptions['available'] = [];
|
||||
|
||||
let shown = loaded.length;
|
||||
|
||||
for (const group of groups.available) {
|
||||
if (budget <= 0) break;
|
||||
|
||||
const items = group.items.slice(0, budget);
|
||||
|
||||
budget -= items.length;
|
||||
shown += items.length;
|
||||
|
||||
if (items.length > 0) available.push({ ...group, items });
|
||||
}
|
||||
|
||||
return { available, loaded, shown };
|
||||
}
|
||||
|
||||
export function groupModelOptions(
|
||||
filteredOptions: ModelOption[],
|
||||
isModelLoaded: (model: string) => boolean
|
||||
): GroupedModelOptions {
|
||||
// Loaded models
|
||||
const loaded: ModelItem[] = [];
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
const option = filteredOptions[i];
|
||||
|
||||
if (isModelLoaded(option.model)) {
|
||||
loaded.push({ flatIndex: i, option });
|
||||
}
|
||||
}
|
||||
|
||||
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[]>();
|
||||
|
||||
for (let i = 0; i < filteredOptions.length; i++) {
|
||||
const option = filteredOptions[i];
|
||||
|
||||
if (loadedModelIds.has(option.model)) continue;
|
||||
|
||||
const key = option.parsedId?.orgName ?? '';
|
||||
|
||||
if (!orgGroups.has(key)) orgGroups.set(key, []);
|
||||
|
||||
orgGroups.get(key)!.push({ flatIndex: i, option });
|
||||
}
|
||||
|
||||
for (const [orgName, items] of orgGroups) {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
<DropdownMenuPrimitive.Content
|
||||
bind:ref
|
||||
class={cn(
|
||||
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
|
||||
'z-50 max-h-[calc(var(--bits-dropdown-menu-content-available-height)-1rem)] min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
|
||||
className
|
||||
)}
|
||||
data-slot="dropdown-menu-content"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
|
||||
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
|
||||
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
|
||||
import ModelsSelectorList from '$lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte';
|
||||
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte';
|
||||
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
|
||||
@@ -103,8 +103,8 @@
|
||||
orgName: 'intel'
|
||||
}
|
||||
],
|
||||
favorites: favoriteModels,
|
||||
loaded: loadedModels
|
||||
loaded: loadedModels,
|
||||
providers: []
|
||||
};
|
||||
|
||||
function handleSelect(modelId: string) {
|
||||
@@ -124,8 +124,8 @@
|
||||
<ModelsSelectorList
|
||||
{activeId}
|
||||
currentModel={selectedModel}
|
||||
favorites={favoriteModels}
|
||||
groups={groupedOptions}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
@@ -138,10 +138,9 @@
|
||||
currentModel={null}
|
||||
groups={{
|
||||
available: [],
|
||||
favorites: [],
|
||||
loaded: [loadedModels[0]]
|
||||
loaded: [loadedModels[0]],
|
||||
providers: []
|
||||
}}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
@@ -152,12 +151,12 @@
|
||||
<ModelsSelectorList
|
||||
activeId={null}
|
||||
currentModel={null}
|
||||
favorites={favoriteModels}
|
||||
groups={{
|
||||
available: [],
|
||||
favorites: favoriteModels,
|
||||
loaded: []
|
||||
loaded: [],
|
||||
providers: []
|
||||
}}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user