mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-11 04:56:56 +02:00
ui : group the model selector components
Move the four selector surfaces into models/ModelsSelector with their own barrel, extract the shared reasoning panel and download row, group the option list helpers under navigation/utils, and add the models selector hook wiring (in-flight downloads and reasoning menu included). Assisted-by: pi:GLM-5.3-Flash
This commit is contained in:
-75
@@ -1,75 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
|
||||
? 'text-muted-foreground'
|
||||
: ''}"
|
||||
>
|
||||
Reasoning
|
||||
|
||||
<span class="capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenu.SubTrigger>
|
||||
|
||||
<DropdownMenu.SubContent
|
||||
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
|
||||
>
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
|
||||
level
|
||||
)
|
||||
? 'bg-accent'
|
||||
: ''}"
|
||||
onclick={() => reasoning.select(level)}
|
||||
>
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<span class="flex-1">{level.label}</span>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
@@ -220,14 +220,6 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
|
||||
*/
|
||||
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
|
||||
|
||||
/** Dropdown submenu for selecting reasoning effort level.
|
||||
*
|
||||
* Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
|
||||
* thinking status, and a nested list of effort levels.
|
||||
* Only visible when the current model supports thinking.
|
||||
*/
|
||||
export { default as ChatFormActionAddReasoningSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte';
|
||||
|
||||
/**
|
||||
* Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip.
|
||||
*/
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import ModelsDiscoverAvatar from '../discover/ModelsDiscoverAvatar.svelte';
|
||||
import ModelsDiscoverDownloadProgressBar from '../discover/ModelsDiscoverDownloadProgressBar.svelte';
|
||||
import { Loader2, Pause, Play, X } from '@lucide/svelte';
|
||||
import { ModelId } from '$lib/components/app';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelDownloadProgress } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** One entry from the status feed: an in-flight or paused download. */
|
||||
entry: { isPaused: boolean; progress: ModelDownloadProgress | null; repoWithTag: string };
|
||||
/**
|
||||
* Ask the list to confirm cancelling this download. The row owns no dialog;
|
||||
* the list renders a single shared confirmation.
|
||||
*/
|
||||
onRequestCancel?: (repoWithTag: string) => void;
|
||||
}
|
||||
|
||||
let { entry, onRequestCancel }: Props = $props();
|
||||
|
||||
let percent = $derived(
|
||||
entry.progress && entry.progress.totalBytes > 0
|
||||
? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
|
||||
: null
|
||||
);
|
||||
|
||||
let actionText = $derived(entry.isPaused ? 'Resume downloading' : 'Pause downloading');
|
||||
|
||||
// Avatar: the repo org with the quantizer org corner badge, as in the model
|
||||
// option rows; the base model org resolves lazily via HF when unknown
|
||||
let orgName = $derived(ModelsService.parseModelId(entry.repoWithTag).orgName);
|
||||
let fetchedBaseModelOrg = $state<string | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const repoWithTag = entry.repoWithTag;
|
||||
|
||||
fetchedBaseModelOrg = null;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getBaseModel(repoWithTag)
|
||||
.then((base) => {
|
||||
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
|
||||
})
|
||||
// best-effort lookup: offline or unknown repos keep the repo org
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- One in-flight download; same actions as the discover quant chips: the row
|
||||
itself pauses / resumes, the trailing X cancels (stops and discards the
|
||||
partial files). Both affordances fade in on hover, the slots are reserved
|
||||
so the list never reflows. -->
|
||||
<div
|
||||
class="group relative flex items-center gap-2 rounded-sm p-2 text-left text-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<button
|
||||
aria-label={actionText}
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
|
||||
onclick={() => {
|
||||
if (entry.isPaused) void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
|
||||
else void modelsStore.status.pauseDownload(entry.repoWithTag);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{#if orgName}
|
||||
<ModelsDiscoverAvatar
|
||||
class="mt-0"
|
||||
org={fetchedBaseModelOrg ?? orgName}
|
||||
quantOrg={orgName}
|
||||
quantPositionClass="-bottom-1 -right-1"
|
||||
quantSize="h-3 w-3"
|
||||
size="h-6 w-6"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ModelId class="flex-1" hideOrgName modelId={entry.repoWithTag} showRawTooltip />
|
||||
|
||||
{#if percent !== null}
|
||||
<span class="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
|
||||
{:else if entry.isPaused}
|
||||
<span class="shrink-0 text-xs text-muted-foreground">Paused</span>
|
||||
{/if}
|
||||
|
||||
<!-- status action: spinner -> pause on hover while in flight, play on hover
|
||||
when paused; opacity only for the spinner, the spin owns the transform -->
|
||||
<span class="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{#if entry.isPaused}
|
||||
<Play
|
||||
class="absolute h-4 w-4 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
{:else}
|
||||
<Loader2
|
||||
class="absolute h-4 w-4 animate-spin text-muted-foreground transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
|
||||
/>
|
||||
|
||||
<Pause
|
||||
class="absolute h-4 w-4 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label="Cancel downloading"
|
||||
class="inline-flex h-4 w-4 shrink-0 scale-75 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
|
||||
onclick={() => onRequestCancel?.(entry.repoWithTag)}
|
||||
type="button"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{#if entry.progress && entry.progress.totalBytes > 0}
|
||||
<ModelsDiscoverDownloadProgressBar
|
||||
downloadedBytes={entry.progress.downloadedBytes}
|
||||
overlay
|
||||
totalBytes={entry.progress.totalBytes}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
+109
-117
@@ -1,18 +1,22 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import type { ModelItem } from './utils';
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
|
||||
import {
|
||||
ChatFormActionAddReasoningSubmenu,
|
||||
DialogModelInformation,
|
||||
DropdownMenuSearchable,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorOption
|
||||
ModelsSelectorOption,
|
||||
ModelsSelectorReasoningPanel
|
||||
} from '$lib/components/app';
|
||||
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_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
|
||||
import {
|
||||
DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR,
|
||||
MODEL_SELECTOR_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';
|
||||
@@ -39,9 +43,6 @@
|
||||
|
||||
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);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -51,14 +52,16 @@
|
||||
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;
|
||||
// Defer focus so the content is mounted; bits-ui auto-focuses the
|
||||
// opened content by default which can yank the page scroll, so we
|
||||
// prevent that on the Content and refocus the search here.
|
||||
requestAnimationFrame(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
document
|
||||
.querySelector<HTMLElement>(DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR)
|
||||
?.focus({ preventScroll: true });
|
||||
});
|
||||
} else {
|
||||
modelSubOpen = false;
|
||||
}
|
||||
},
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
@@ -66,6 +69,9 @@
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
const selectedOption = $derived(ms.getDisplayOption());
|
||||
const triggerModel = $derived(selectedOption?.model ?? null);
|
||||
|
||||
const showOrgNameInTrigger = $derived(
|
||||
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
|
||||
);
|
||||
@@ -75,27 +81,12 @@
|
||||
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.
|
||||
$effect(() => {
|
||||
if (!isOpen || !modelSubOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-sub-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
|
||||
// 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.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
@@ -125,6 +116,13 @@
|
||||
highlightedId = visualOrder[index];
|
||||
}
|
||||
|
||||
// Pointer/focus interaction with the sticky actions footer (reasoning submenu,
|
||||
// discover models) leaves the option list, so drop the row highlight the same
|
||||
// way we do when the dropdown first opens. Keyboard arrows set it again.
|
||||
function clearHighlight() {
|
||||
highlightedId = null;
|
||||
}
|
||||
|
||||
// Alt+Enter only unloads and keeps the dropdown open.
|
||||
async function handleModelKeyAction(modelId: string, unload: boolean) {
|
||||
if (!unload) {
|
||||
@@ -180,7 +178,7 @@
|
||||
'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" />
|
||||
</span>
|
||||
@@ -188,8 +186,6 @@
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{@const selectedOption = ms.getDisplayOption()}
|
||||
{@const triggerModel = selectedOption?.model}
|
||||
{@const triggerStatus = triggerModel
|
||||
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
|
||||
: undefined}
|
||||
@@ -264,94 +260,90 @@
|
||||
|
||||
<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:max-w-[26rem] max-w-[calc(100vw-2rem)] p-0!"
|
||||
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" />
|
||||
<DropdownMenuSearchable
|
||||
emptyMessage="No models found."
|
||||
isEmpty={ms.filteredOptions.length === 0 && 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" hideQuantization modelId={currentModel} />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
hideOrgName={!showOrgNameInTrigger}
|
||||
hideQuantization
|
||||
modelId={selectedOption.model}
|
||||
/>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
|
||||
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
||||
</button>
|
||||
{/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}
|
||||
{#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}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<ModelsSelectorList
|
||||
activeId={ms.activeId}
|
||||
{currentModel}
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onSelect={ms.handleSelect}
|
||||
renderOption={modelOption}
|
||||
sectionHeaderClass="[&:not(:first-child)]:mt-3 mb-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<!-- Sticky actions footer: reasoning effort panel.
|
||||
Sticks to the bottom of the content scrollport. -->
|
||||
<div
|
||||
class="px-1.5"
|
||||
onfocusin={clearHighlight}
|
||||
onmouseenter={clearHighlight}
|
||||
role="none"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
|
||||
<ChatFormActionAddReasoningSubmenu />
|
||||
<ModelsSelectorReasoningPanel inMenu />
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownMenuSearchable>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
@@ -364,7 +356,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
|
||||
+46
-19
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { GroupedModelOptions, ModelItem } from './utils';
|
||||
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
|
||||
import { ModelsSelectorOption } from '$lib/components/app';
|
||||
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
|
||||
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
|
||||
import { ModelDownloadConfirmAction } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
|
||||
interface Props {
|
||||
@@ -8,7 +11,6 @@
|
||||
currentModel: string | null;
|
||||
activeId: string | null;
|
||||
sectionHeaderClass?: string;
|
||||
orgHeaderClass?: string;
|
||||
onSelect: (modelId: string) => void;
|
||||
onInfoClick: (modelName: string) => void;
|
||||
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
|
||||
@@ -20,20 +22,33 @@
|
||||
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'
|
||||
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
|
||||
}: Props = $props();
|
||||
let render = $derived(renderOption ?? defaultOption);
|
||||
|
||||
/** 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 defaultOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{#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}
|
||||
hideOrgName
|
||||
{isFav}
|
||||
isHighlighted={false}
|
||||
{isSelected}
|
||||
@@ -42,17 +57,10 @@
|
||||
onMouseEnter={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
showBaseModelAvatar
|
||||
/>
|
||||
{/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>
|
||||
|
||||
@@ -61,16 +69,35 @@
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if getDownloadEntries.length > 0}
|
||||
<p class={sectionHeaderClass}>Download in progress</p>
|
||||
|
||||
{#each getDownloadEntries as entry (entry.repoWithTag)}
|
||||
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} />
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#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.available.length > 0}
|
||||
<p class={sectionHeaderClass}>Available models</p>
|
||||
<h2 class={sectionHeaderClass}>Downloaded models</h2>
|
||||
|
||||
{#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}
|
||||
|
||||
<DialogConfirmDownload
|
||||
action={ModelDownloadConfirmAction.CANCEL}
|
||||
onClose={() => (cancelOpen = false)}
|
||||
open={cancelOpen}
|
||||
repoWithTag={pendingCancel}
|
||||
/>
|
||||
+53
-4
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import ModelsDiscoverAvatar from '../discover/ModelsDiscoverAvatar.svelte';
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import {
|
||||
CircleAlert,
|
||||
Heart,
|
||||
@@ -11,11 +12,12 @@
|
||||
RotateCw
|
||||
} from '@lucide/svelte';
|
||||
import { ActionIcon, ModelId } from '$lib/components/app';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { HF_BASE_MODEL_TAG_REGEX, ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { ModelCapability, ServerModelStatus } from '$lib/enums';
|
||||
import { HuggingFaceService, ModelsService } from '$lib/services';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
|
||||
import { modelLoadFraction, modelLoadProgressText, orgOf } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
option: ModelOption;
|
||||
@@ -27,6 +29,8 @@
|
||||
onMouseEnter: () => void;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onInfoClick?: (modelName: string) => 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;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -38,7 +42,8 @@
|
||||
onKeyDown,
|
||||
onMouseEnter,
|
||||
onSelect,
|
||||
option
|
||||
option,
|
||||
showBaseModelAvatar = false
|
||||
}: Props = $props();
|
||||
|
||||
let currentRouterModels = $derived(modelsStore.routerModels);
|
||||
@@ -59,6 +64,39 @@
|
||||
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
|
||||
let loadTitle = $derived(modelLoadProgressText(loadProgress));
|
||||
let modalities = $derived(option.modalities);
|
||||
// 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.
|
||||
let parsedId = $derived(ModelsService.parseModelId(option.model));
|
||||
let orgName = $derived(parsedId.orgName);
|
||||
let tagBaseModel = $derived(
|
||||
(option.tags ?? [])
|
||||
.find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
|
||||
?.match(HF_BASE_MODEL_TAG_REGEX)?.[1] ?? null
|
||||
);
|
||||
let fetchedBaseModelOrg = $state<string | null>(null);
|
||||
let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
|
||||
|
||||
$effect(() => {
|
||||
fetchedBaseModelOrg = null;
|
||||
|
||||
if (!showBaseModelAvatar || !orgName || tagBaseModel) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void HuggingFaceService.getBaseModel(option.model)
|
||||
.then((base) => {
|
||||
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
|
||||
})
|
||||
// best-effort lookup: offline or unknown repos keep the repo org
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
let capabilities = $derived.by(() => ({
|
||||
reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
|
||||
tools: option.capabilities.includes(ModelCapability.TOOL_USE)
|
||||
@@ -84,6 +122,17 @@
|
||||
tabindex="0"
|
||||
title={loadTitle}
|
||||
>
|
||||
{#if orgName}
|
||||
<ModelsDiscoverAvatar
|
||||
class="mt-0"
|
||||
org={baseModelOrg ?? orgName}
|
||||
quantOrg={showBaseModelAvatar ? orgName : undefined}
|
||||
quantPositionClass="-bottom-1 -right-1"
|
||||
quantSize="h-3 w-3"
|
||||
size="size-5"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<ModelId
|
||||
aliases={option.aliases}
|
||||
class="flex-1"
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronUp, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ICON_CLASS_DEFAULT } from '$lib/constants';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import type { ReasoningEffortLevel } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** Render rows as DropdownMenu items (desktop dropdown); plain buttons in menus without one (sheet). */
|
||||
inMenu?: boolean;
|
||||
}
|
||||
|
||||
let { inMenu = false }: Props = $props();
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
// rows stay mounted through the collapse transition so it can play; after
|
||||
// it, menus drop them - mounted rows are menu items and would pollute the
|
||||
// arrow-key navigation
|
||||
// measured px transition: two plain lengths interpolate in every browser,
|
||||
// no calc-size() support needed. The rows mount at height 0, get measured,
|
||||
// then the region grows to the measured height.
|
||||
const EXPAND_TRANSITION_MS = 200;
|
||||
let rowsMounted = $state(false);
|
||||
let regionEl = $state<HTMLDivElement | null>(null);
|
||||
let heightPx = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (!inMenu) {
|
||||
rowsMounted = true;
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
// in menus the rows mount with the flip; measure once they are laid out
|
||||
if (inMenu) rowsMounted = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (regionEl) heightPx = regionEl.scrollHeight;
|
||||
});
|
||||
} else {
|
||||
heightPx = 0;
|
||||
|
||||
// menus drop the rows after the collapse: mounted rows are menu
|
||||
// items and would pollute the arrow-key navigation
|
||||
if (inMenu) {
|
||||
const timer = setTimeout(() => (rowsMounted = false), EXPAND_TRANSITION_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const pickLevel = (level: ReasoningEffortLevel) => {
|
||||
reasoning.select(level);
|
||||
|
||||
// collapse so the footer returns to its resting single-row look
|
||||
expanded = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Reasoning effort picker for the models selector footer; expands in place
|
||||
(a flyout submenu would cover the list it belongs to). Rows are rendered
|
||||
as dropdown menu items inside the dropdown, plain buttons in the sheet,
|
||||
which has no menu context. -->
|
||||
{#snippet triggerContent()}
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
|
||||
{:else if reasoning.isOff}
|
||||
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="truncate">Reasoning</span>
|
||||
|
||||
<span class="shrink-0 capitalize text-muted-foreground">
|
||||
{reasoning.currentEffort}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if expanded}
|
||||
<ChevronUp class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if inMenu}
|
||||
<!-- A menu item (for keyboard nav) wrapping the trigger via `child`;
|
||||
closeOnSelect keeps the menu open while picking. -->
|
||||
<DropdownMenu.Item
|
||||
class="w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left text-sm"
|
||||
closeOnSelect={false}
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<!-- No `class` here: a static attribute would override the spread props.class. -->
|
||||
<button
|
||||
{...props}
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{@render triggerContent()}
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
class="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent focus-visible:bg-accent"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{@render triggerContent()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Custom expand region instead of bits-ui Collapsible (whose conditional
|
||||
rendering kills the transition): the height animates through
|
||||
calc-size(auto, size) and visibility keeps collapsed rows out of the tab
|
||||
order. In menus the rows unmount after the collapse transition, so the
|
||||
arrow-key navigation never sees hidden items. -->
|
||||
<div
|
||||
bind:this={regionEl}
|
||||
data-expanded={expanded}
|
||||
class="overflow-hidden"
|
||||
style={`height: ${heightPx}px; visibility: ${
|
||||
expanded ? 'visible' : 'hidden'
|
||||
}; transition: height ${EXPAND_TRANSITION_MS}ms cubic-bezier(0.23, 1, 0.32, 1), visibility ${EXPAND_TRANSITION_MS}ms;`}
|
||||
>
|
||||
{#if rowsMounted}
|
||||
<!-- Plain items (not a RadioGroup): selection lives in the store. -->
|
||||
<div class="mt-0.5 flex flex-col gap-0.5 pl-4">
|
||||
{#each reasoning.levels as level (level.value)}
|
||||
{@const tokenLabel = reasoning.tokenLabel(level)}
|
||||
{#if inMenu}
|
||||
<DropdownMenu.Item
|
||||
class="flex w-full cursor-pointer gap-3 px-2 py-1.5"
|
||||
closeOnSelect={false}
|
||||
onSelect={() => pickLevel(level)}
|
||||
>
|
||||
{@render levelContent(level, tokenLabel)}
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent focus-visible:bg-accent"
|
||||
onclick={() => pickLevel(level)}
|
||||
type="button"
|
||||
>
|
||||
{@render levelContent(level, tokenLabel)}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet levelContent(level: ReasoningEffortLevel, tokenLabel: string | null)}
|
||||
{#if reasoning.isSelected(level)}
|
||||
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
|
||||
{:else}
|
||||
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
|
||||
{/if}
|
||||
|
||||
<span class="min-w-0 flex-1 truncate">{level.label}</span>
|
||||
|
||||
{#if tokenLabel}
|
||||
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">
|
||||
{tokenLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if level.hasInfo}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
|
||||
<Tooltip.Content side="left">
|
||||
<p>Maximum reasoning effort with extended context usage</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{/snippet}
|
||||
+14
-3
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
|
||||
import { ChevronDown, Lightbulb, Loader2, Package } from '@lucide/svelte';
|
||||
import {
|
||||
DialogModelInformation,
|
||||
ModelId,
|
||||
ModelsSelectorList,
|
||||
ModelsSelectorReasoningPanel,
|
||||
SearchInput
|
||||
} from '$lib/components/app';
|
||||
import * as Sheet from '$lib/components/ui/sheet';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
|
||||
@@ -44,6 +46,8 @@
|
||||
useGlobalSelection: () => useGlobalSelection
|
||||
});
|
||||
|
||||
const reasoning = useReasoningMenu();
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
@@ -109,6 +113,10 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if reasoning.isReasoningActive}
|
||||
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
|
||||
{/if}
|
||||
|
||||
{#if ms.updating || ms.isLoadingModel}
|
||||
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
||||
{:else}
|
||||
@@ -166,10 +174,13 @@
|
||||
groups={ms.groupedFilteredOptions}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="px-2 pb-1">
|
||||
<ModelsSelectorReasoningPanel />
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
*
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* **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';
|
||||
|
||||
/**
|
||||
* **ModelsSelectorReasoningPanel** - Inline reasoning effort picker
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export { default as ModelsSelectorReasoningPanel } from './ModelsSelectorReasoningPanel.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';
|
||||
@@ -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.
|
||||
@@ -118,3 +57,5 @@ export { default as ModelId } from './ModelId.svelte';
|
||||
* styling stay consistent across every model-id surface.
|
||||
*/
|
||||
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
|
||||
|
||||
export * from './ModelsSelector';
|
||||
|
||||
@@ -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,32 +9,45 @@
|
||||
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();
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
|
||||
<div class="sticky top-0 z-20 p-1.5">
|
||||
<SearchInput
|
||||
bind:value={searchValue}
|
||||
class={searchClass}
|
||||
onInput={onSearchChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto">
|
||||
<div class={contentClass}>
|
||||
{@render children()}
|
||||
|
||||
{#if isEmpty}
|
||||
@@ -44,7 +56,13 @@
|
||||
</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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
/** Default Tailwind size class for inline icon components (lucide, etc.). */
|
||||
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
|
||||
import { filterModelOptions, groupModelOptions } from '$lib/components/app/navigation/utils';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
|
||||
import { modelsStore, serverStore } from '$lib/stores';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
@@ -20,8 +20,8 @@ export interface UseReasoningMenuReturn {
|
||||
/**
|
||||
* Shared reactive state and helpers for the reasoning effort menu.
|
||||
*
|
||||
* Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`)
|
||||
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
|
||||
* Used by the models dropdown footer (`ModelsSelectorReasoningPanel`) and the
|
||||
* mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
|
||||
* thinking-support derivation and the effort selection logic.
|
||||
*/
|
||||
export function useReasoningMenu(): UseReasoningMenuReturn {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user