diff --git a/tools/server/public/index.html.gz b/tools/server/public/index.html.gz index 4527cb33567..0db03f05b5f 100644 Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json index 9c1c2499cfd..4f37b308b13 100644 --- a/tools/server/webui/package-lock.json +++ b/tools/server/webui/package-lock.json @@ -41,7 +41,7 @@ "@tailwindcss/vite": "^4.0.0", "@types/node": "^22", "@vitest/browser": "^3.2.3", - "bits-ui": "^2.8.11", + "bits-ui": "^2.14.4", "clsx": "^2.1.1", "dexie": "^4.0.11", "eslint": "^9.18.0", @@ -3343,17 +3343,17 @@ } }, "node_modules/bits-ui": { - "version": "2.8.11", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.8.11.tgz", - "integrity": "sha512-lKN9rAk69my6j7H1D4B87r8LrHuEtfEsf1xCixBj9yViql2BdI3f04HyyyT7T1GOCpgb9+8b0B+nm3LN81Konw==", + "version": "2.14.4", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.14.4.tgz", + "integrity": "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==", "dev": true, "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", - "runed": "^0.29.1", - "svelte-toolbelt": "^0.9.3", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "engines": { @@ -3368,9 +3368,9 @@ } }, "node_modules/bits-ui/node_modules/runed": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz", - "integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==", + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", "dev": true, "funding": [ "https://github.com/sponsors/huntabyte", @@ -3378,23 +3378,31 @@ ], "license": "MIT", "dependencies": { - "esm-env": "^1.0.0" + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" }, "peerDependencies": { + "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } } }, "node_modules/bits-ui/node_modules/svelte-toolbelt": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.3.tgz", - "integrity": "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", "dev": true, "funding": [ "https://github.com/sponsors/huntabyte" ], "dependencies": { "clsx": "^2.1.1", - "runed": "^0.29.0", + "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "engines": { diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json index 987a7239ed4..c20ab3cfde0 100644 --- a/tools/server/webui/package.json +++ b/tools/server/webui/package.json @@ -43,7 +43,7 @@ "@tailwindcss/vite": "^4.0.0", "@types/node": "^22", "@vitest/browser": "^3.2.3", - "bits-ui": "^2.8.11", + "bits-ui": "^2.14.4", "clsx": "^2.1.1", "dexie": "^4.0.11", "eslint": "^9.18.0", diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 7f8e38286d2..78cc1c47daa 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -331,6 +331,7 @@ class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled ? 'cursor-not-allowed opacity-60' : ''} {className}" + data-slot="chat-form" > - import { Input } from '$lib/components/ui/input'; - import { Search } from '@lucide/svelte'; + import { SearchInput } from '$lib/components/app'; interface Props { value?: string; @@ -15,19 +14,6 @@ onInput, class: className }: Props = $props(); - - function handleInput(event: Event) { - const target = event.target as HTMLInputElement; - - value = target.value; - onInput?.(target.value); - } -
- - - -
+ diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts index cf4d7495e2f..c656c7a67fe 100644 --- a/tools/server/webui/src/lib/components/app/index.ts +++ b/tools/server/webui/src/lib/components/app/index.ts @@ -62,6 +62,7 @@ export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelt export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte'; export { default as MarkdownContent } from './misc/MarkdownContent.svelte'; export { default as RemoveButton } from './misc/RemoveButton.svelte'; +export { default as SearchInput } from './misc/SearchInput.svelte'; export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte'; export { default as ModelsSelector } from './models/ModelsSelector.svelte'; diff --git a/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte b/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte new file mode 100644 index 00000000000..15cd6abaa9a --- /dev/null +++ b/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte @@ -0,0 +1,73 @@ + + +
+ + + + + {#if showClearButton} + + {/if} +
diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte index c4331e92f13..ac0937696d4 100644 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte +++ b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte @@ -2,8 +2,8 @@ import { onMount, tick } from 'svelte'; import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; + import * as Popover from '$lib/components/ui/popover'; import { cn } from '$lib/components/ui/utils'; - import { portalToBody } from '$lib/utils'; import { modelsStore, modelOptions, @@ -17,12 +17,8 @@ import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte'; import { ServerModelStatus } from '$lib/enums'; import { isRouterMode } from '$lib/stores/server.svelte'; - import { DialogModelInformation } from '$lib/components/app'; - import { - MENU_MAX_WIDTH, - MENU_OFFSET, - VIEWPORT_GUTTER - } from '$lib/constants/floating-ui-constraints'; + import { DialogModelInformation, SearchInput } from '$lib/components/app'; + import type { ModelOption } from '$lib/types/models'; interface Props { class?: string; @@ -145,185 +141,126 @@ return options.some((option) => option.model === currentModel); }); + let searchTerm = $state(''); + let searchInputRef = $state(null); + let highlightedIndex = $state(-1); + + let filteredOptions: ModelOption[] = $derived( + (() => { + const term = searchTerm.trim().toLowerCase(); + if (!term) return options; + + return options.filter( + (option) => + option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term) + ); + })() + ); + + // Get indices of compatible options for keyboard navigation + let compatibleIndices = $derived( + filteredOptions + .map((option, index) => (isModelCompatible(option) ? index : -1)) + .filter((i) => i !== -1) + ); + + // Reset highlighted index when search term changes + $effect(() => { + void searchTerm; + highlightedIndex = -1; + }); + let isOpen = $state(false); let showModelDialog = $state(false); - let container: HTMLDivElement | null = null; - let menuRef = $state(null); - let triggerButton = $state(null); - let menuPosition = $state<{ - top: number; - left: number; - width: number; - placement: 'top' | 'bottom'; - maxHeight: number; - } | null>(null); - - onMount(async () => { - try { - await modelsStore.fetch(); - } catch (error) { + + onMount(() => { + modelsStore.fetch().catch((error) => { console.error('Unable to load models:', error); - } + }); }); - function toggleOpen() { + function handleOpenChange(open: boolean) { if (loading || updating) return; - if (isRouter) { - // Router mode: show dropdown - if (isOpen) { - closeMenu(); - } else { - openMenu(); + if (open) { + isOpen = true; + searchTerm = ''; + highlightedIndex = -1; + + // Focus search input after popover opens + tick().then(() => { + requestAnimationFrame(() => searchInputRef?.focus()); + }); + + if (isRouter) { + modelsStore.fetchRouterModels().then(() => { + modelsStore.fetchModalitiesForLoadedModels(); + }); } } else { - // Single model mode: show dialog - showModelDialog = true; + isOpen = false; + searchTerm = ''; + highlightedIndex = -1; } } - async function openMenu() { + function handleTriggerClick() { if (loading || updating) return; - isOpen = true; - await tick(); - updateMenuPosition(); - requestAnimationFrame(() => updateMenuPosition()); - - if (isRouter) { - modelsStore.fetchRouterModels().then(() => { - modelsStore.fetchModalitiesForLoadedModels(); - }); + if (!isRouter) { + // Single model mode: show dialog instead of popover + showModelDialog = true; } + // For router mode, the Popover handles open/close } export function open() { if (isRouter) { - openMenu(); + handleOpenChange(true); } else { showModelDialog = true; } } function closeMenu() { - if (!isOpen) return; - - isOpen = false; - menuPosition = null; + handleOpenChange(false); } - function handlePointerDown(event: PointerEvent) { - if (!container) return; + function handleSearchKeyDown(event: KeyboardEvent) { + if (event.isComposing) return; - const target = event.target as Node | null; + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (compatibleIndices.length === 0) return; - if (target && !container.contains(target) && !(menuRef && menuRef.contains(target))) { - closeMenu(); - } - } - - function handleKeydown(event: KeyboardEvent) { - if (event.key === 'Escape') { - closeMenu(); - } - } - - function handleResize() { - if (isOpen) { - updateMenuPosition(); - } - } - - function updateMenuPosition() { - if (!isOpen || !triggerButton || !menuRef) return; - - const triggerRect = triggerButton.getBoundingClientRect(); - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - if (viewportWidth === 0 || viewportHeight === 0) return; - - const scrollWidth = menuRef.scrollWidth; - const scrollHeight = menuRef.scrollHeight; - - const availableWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2); - const constrainedMaxWidth = Math.min(MENU_MAX_WIDTH, availableWidth || MENU_MAX_WIDTH); - const safeMaxWidth = - constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth); - const desiredMinWidth = Math.min(160, safeMaxWidth || 160); - - let width = Math.min( - Math.max(triggerRect.width, scrollWidth, desiredMinWidth), - safeMaxWidth || 320 - ); - - const availableBelow = Math.max( - 0, - viewportHeight - VIEWPORT_GUTTER - triggerRect.bottom - MENU_OFFSET - ); - const availableAbove = Math.max(0, triggerRect.top - VIEWPORT_GUTTER - MENU_OFFSET); - const viewportAllowance = Math.max(0, viewportHeight - VIEWPORT_GUTTER * 2); - const fallbackAllowance = Math.max(1, viewportAllowance > 0 ? viewportAllowance : scrollHeight); - - function computePlacement(placement: 'top' | 'bottom') { - const available = placement === 'bottom' ? availableBelow : availableAbove; - const allowedHeight = - available > 0 ? Math.min(available, fallbackAllowance) : fallbackAllowance; - const maxHeight = Math.min(scrollHeight, allowedHeight); - const height = Math.max(0, maxHeight); - - let top: number; - if (placement === 'bottom') { - const rawTop = triggerRect.bottom + MENU_OFFSET; - const minTop = VIEWPORT_GUTTER; - const maxTop = viewportHeight - VIEWPORT_GUTTER - height; - if (maxTop < minTop) { - top = minTop; - } else { - top = Math.min(Math.max(rawTop, minTop), maxTop); - } + const currentPos = compatibleIndices.indexOf(highlightedIndex); + if (currentPos === -1 || currentPos === compatibleIndices.length - 1) { + highlightedIndex = compatibleIndices[0]; } else { - const rawTop = triggerRect.top - MENU_OFFSET - height; - const minTop = VIEWPORT_GUTTER; - const maxTop = viewportHeight - VIEWPORT_GUTTER - height; - if (maxTop < minTop) { - top = minTop; - } else { - top = Math.max(Math.min(rawTop, maxTop), minTop); - } + highlightedIndex = compatibleIndices[currentPos + 1]; } + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (compatibleIndices.length === 0) return; - return { placement, top, height, maxHeight }; - } - - const belowMetrics = computePlacement('bottom'); - const aboveMetrics = computePlacement('top'); - - let metrics = belowMetrics; - if (scrollHeight > belowMetrics.maxHeight && aboveMetrics.maxHeight > belowMetrics.maxHeight) { - metrics = aboveMetrics; - } - - let left = triggerRect.right - width; - const maxLeft = viewportWidth - VIEWPORT_GUTTER - width; - if (maxLeft < VIEWPORT_GUTTER) { - left = VIEWPORT_GUTTER; - } else { - if (left > maxLeft) { - left = maxLeft; + const currentPos = compatibleIndices.indexOf(highlightedIndex); + if (currentPos === -1 || currentPos === 0) { + highlightedIndex = compatibleIndices[compatibleIndices.length - 1]; + } else { + highlightedIndex = compatibleIndices[currentPos - 1]; } - if (left < VIEWPORT_GUTTER) { - left = VIEWPORT_GUTTER; + } else if (event.key === 'Enter') { + event.preventDefault(); + if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { + const option = filteredOptions[highlightedIndex]; + if (isModelCompatible(option)) { + handleSelect(option.id); + } + } else if (compatibleIndices.length > 0) { + // No selection - highlight first compatible option + highlightedIndex = compatibleIndices[0]; } } - - menuPosition = { - top: Math.round(metrics.top), - left: Math.round(left), - width: Math.round(width), - placement: metrics.placement, - maxHeight: Math.round(metrics.maxHeight) - }; } async function handleSelect(modelId: string) { @@ -356,6 +293,14 @@ if (shouldCloseMenu) { closeMenu(); + + // Focus the chat textarea after model selection + requestAnimationFrame(() => { + const textarea = document.querySelector( + '[data-slot="chat-form"] textarea' + ); + textarea?.focus(); + }); } } @@ -404,10 +349,7 @@ } - - - -
+
{#if loading && options.length === 0 && isRouter}
@@ -418,9 +360,8 @@ {:else} {@const selectedOption = getDisplayOption()} -
- - - {#if isOpen && isRouter} -
+ + + +
+
+ +
0 - ? `${menuPosition.maxHeight}px` - : undefined} + class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1" > {#if !isCurrentModelInCache() && currentModel}
- {/if} -
+
+ {/if}
diff --git a/tools/server/webui/src/lib/components/ui/popover/index.ts b/tools/server/webui/src/lib/components/ui/popover/index.ts new file mode 100644 index 00000000000..c5937fb3a04 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/index.ts @@ -0,0 +1,19 @@ +import Root from './popover.svelte'; +import Close from './popover-close.svelte'; +import Content from './popover-content.svelte'; +import Trigger from './popover-trigger.svelte'; +import Portal from './popover-portal.svelte'; + +export { + Root, + Content, + Trigger, + Close, + Portal, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, + Portal as PopoverPortal +}; diff --git a/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte b/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte new file mode 100644 index 00000000000..dc4dec4b339 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte b/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte new file mode 100644 index 00000000000..2d3513d347f --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,37 @@ + + + + + diff --git a/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte b/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte new file mode 100644 index 00000000000..25efb877b73 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte b/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte new file mode 100644 index 00000000000..5ef3d0e9324 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + diff --git a/tools/server/webui/src/lib/components/ui/popover/popover.svelte b/tools/server/webui/src/lib/components/ui/popover/popover.svelte new file mode 100644 index 00000000000..f39b867a694 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover.svelte @@ -0,0 +1,7 @@ + + + diff --git a/tools/server/webui/src/lib/constants/floating-ui-constraints.ts b/tools/server/webui/src/lib/constants/floating-ui-constraints.ts index c95d3f18417..003fc77acb0 100644 --- a/tools/server/webui/src/lib/constants/floating-ui-constraints.ts +++ b/tools/server/webui/src/lib/constants/floating-ui-constraints.ts @@ -1,3 +1,2 @@ export const VIEWPORT_GUTTER = 8; export const MENU_OFFSET = 6; -export const MENU_MAX_WIDTH = 320; diff --git a/tools/server/webui/src/lib/stores/models.svelte.ts b/tools/server/webui/src/lib/stores/models.svelte.ts index 2e834af5a00..1bc299abc0d 100644 --- a/tools/server/webui/src/lib/stores/models.svelte.ts +++ b/tools/server/webui/src/lib/stores/models.svelte.ts @@ -279,14 +279,21 @@ class ModelsStore { * Fetch props for a specific model from /props endpoint * Uses caching to avoid redundant requests * + * In ROUTER mode, this will only fetch props if the model is loaded, + * since unloaded models return 400 from /props endpoint. + * * @param modelId - Model identifier to fetch props for - * @returns Props data or null if fetch failed + * @returns Props data or null if fetch failed or model not loaded */ async fetchModelProps(modelId: string): Promise { // Return cached props if available const cached = this.modelPropsCache.get(modelId); if (cached) return cached; + if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) { + return null; + } + // Avoid duplicate fetches if (this.modelPropsFetching.has(modelId)) return null;