Add a search field on model selector / improve mobile display (#17765)
* webui: add search field to model selector and fixes mobile viewport overflow * webui: simplify model search style and code * refacor: Search Input component & consistent UI for Models Selector search * feat: Use Popover component + improve interactions * fix: Fetching props for only loaded models in ROUTER mode * webui: prevent models selector popover from overflowing viewport Use Floating UI's auto-positioning with 50dvh height limit and proper collision detection instead of forcing top positioning. Fixes overflow on desktop and mobile keyboard issues * webui: keep search field near trigger in models selector Place search at the 'near end' (closest to trigger) by swapping layout with CSS flexbox order based on popover direction. Prevents input from moving during typing as list shrinks * chore: update webui build output --------- Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
This commit is contained in:
parent
53ecd4fdb9
commit
a81a569577
Binary file not shown.
|
|
@ -41,7 +41,7 @@
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@vitest/browser": "^3.2.3",
|
"@vitest/browser": "^3.2.3",
|
||||||
"bits-ui": "^2.8.11",
|
"bits-ui": "^2.14.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|
@ -3343,17 +3343,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bits-ui": {
|
"node_modules/bits-ui": {
|
||||||
"version": "2.8.11",
|
"version": "2.14.4",
|
||||||
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.8.11.tgz",
|
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.14.4.tgz",
|
||||||
"integrity": "sha512-lKN9rAk69my6j7H1D4B87r8LrHuEtfEsf1xCixBj9yViql2BdI3f04HyyyT7T1GOCpgb9+8b0B+nm3LN81Konw==",
|
"integrity": "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.7.1",
|
"@floating-ui/core": "^1.7.1",
|
||||||
"@floating-ui/dom": "^1.7.1",
|
"@floating-ui/dom": "^1.7.1",
|
||||||
"esm-env": "^1.1.2",
|
"esm-env": "^1.1.2",
|
||||||
"runed": "^0.29.1",
|
"runed": "^0.35.1",
|
||||||
"svelte-toolbelt": "^0.9.3",
|
"svelte-toolbelt": "^0.10.6",
|
||||||
"tabbable": "^6.2.0"
|
"tabbable": "^6.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -3368,9 +3368,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bits-ui/node_modules/runed": {
|
"node_modules/bits-ui/node_modules/runed": {
|
||||||
"version": "0.29.2",
|
"version": "0.35.1",
|
||||||
"resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz",
|
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
|
||||||
"integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==",
|
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/huntabyte",
|
"https://github.com/sponsors/huntabyte",
|
||||||
|
|
@ -3378,23 +3378,31 @@
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esm-env": "^1.0.0"
|
"dequal": "^2.0.3",
|
||||||
|
"esm-env": "^1.0.0",
|
||||||
|
"lz-string": "^1.5.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@sveltejs/kit": "^2.21.0",
|
||||||
"svelte": "^5.7.0"
|
"svelte": "^5.7.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@sveltejs/kit": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bits-ui/node_modules/svelte-toolbelt": {
|
"node_modules/bits-ui/node_modules/svelte-toolbelt": {
|
||||||
"version": "0.9.3",
|
"version": "0.10.6",
|
||||||
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
|
||||||
"integrity": "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==",
|
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/huntabyte"
|
"https://github.com/sponsors/huntabyte"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"runed": "^0.29.0",
|
"runed": "^0.35.1",
|
||||||
"style-to-object": "^1.0.8"
|
"style-to-object": "^1.0.8"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@vitest/browser": "^3.2.3",
|
"@vitest/browser": "^3.2.3",
|
||||||
"bits-ui": "^2.8.11",
|
"bits-ui": "^2.14.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,7 @@
|
||||||
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
|
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'
|
? 'cursor-not-allowed opacity-60'
|
||||||
: ''} {className}"
|
: ''} {className}"
|
||||||
|
data-slot="chat-form"
|
||||||
>
|
>
|
||||||
<ChatAttachmentsList
|
<ChatAttachmentsList
|
||||||
bind:uploadedFiles
|
bind:uploadedFiles
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { SearchInput } from '$lib/components/app';
|
||||||
import { Search } from '@lucide/svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string;
|
value?: string;
|
||||||
|
|
@ -15,19 +14,6 @@
|
||||||
onInput,
|
onInput,
|
||||||
class: className
|
class: className
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleInput(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement;
|
|
||||||
|
|
||||||
value = target.value;
|
|
||||||
onInput?.(target.value);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative mb-4 {className}">
|
<SearchInput bind:value {placeholder} {onInput} class="mb-4 {className}" />
|
||||||
<Search
|
|
||||||
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input bind:value class="pl-10" oninput={handleInput} {placeholder} type="search" />
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelt
|
||||||
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
|
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
|
||||||
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
|
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
|
||||||
export { default as RemoveButton } from './misc/RemoveButton.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 SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
|
||||||
export { default as ModelsSelector } from './models/ModelsSelector.svelte';
|
export { default as ModelsSelector } from './models/ModelsSelector.svelte';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Search, X } from '@lucide/svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
onInput?: (value: string) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onKeyDown?: (event: KeyboardEvent) => void;
|
||||||
|
class?: string;
|
||||||
|
id?: string;
|
||||||
|
ref?: HTMLInputElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(''),
|
||||||
|
placeholder = 'Search...',
|
||||||
|
onInput,
|
||||||
|
onClose,
|
||||||
|
onKeyDown,
|
||||||
|
class: className,
|
||||||
|
id,
|
||||||
|
ref = $bindable(null)
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let showClearButton = $derived(!!value || !!onClose);
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
|
||||||
|
value = target.value;
|
||||||
|
onInput?.(target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear() {
|
||||||
|
if (value) {
|
||||||
|
value = '';
|
||||||
|
onInput?.('');
|
||||||
|
ref?.focus();
|
||||||
|
} else {
|
||||||
|
onClose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative {className}">
|
||||||
|
<Search
|
||||||
|
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{id}
|
||||||
|
bind:value
|
||||||
|
bind:ref
|
||||||
|
class="pl-9 {showClearButton ? 'pr-9' : ''}"
|
||||||
|
oninput={handleInput}
|
||||||
|
onkeydown={onKeyDown}
|
||||||
|
{placeholder}
|
||||||
|
type="search"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showClearButton}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
onclick={handleClear}
|
||||||
|
aria-label={value ? 'Clear search' : 'Close'}
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
|
import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { cn } from '$lib/components/ui/utils';
|
import { cn } from '$lib/components/ui/utils';
|
||||||
import { portalToBody } from '$lib/utils';
|
|
||||||
import {
|
import {
|
||||||
modelsStore,
|
modelsStore,
|
||||||
modelOptions,
|
modelOptions,
|
||||||
|
|
@ -17,12 +17,8 @@
|
||||||
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
|
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { ServerModelStatus } from '$lib/enums';
|
import { ServerModelStatus } from '$lib/enums';
|
||||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||||
import { DialogModelInformation } from '$lib/components/app';
|
import { DialogModelInformation, SearchInput } from '$lib/components/app';
|
||||||
import {
|
import type { ModelOption } from '$lib/types/models';
|
||||||
MENU_MAX_WIDTH,
|
|
||||||
MENU_OFFSET,
|
|
||||||
VIEWPORT_GUTTER
|
|
||||||
} from '$lib/constants/floating-ui-constraints';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|
@ -145,185 +141,126 @@
|
||||||
return options.some((option) => option.model === currentModel);
|
return options.some((option) => option.model === currentModel);
|
||||||
});
|
});
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let searchTerm = $state('');
|
||||||
let showModelDialog = $state(false);
|
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||||
let container: HTMLDivElement | null = null;
|
let highlightedIndex = $state<number>(-1);
|
||||||
let menuRef = $state<HTMLDivElement | null>(null);
|
|
||||||
let triggerButton = $state<HTMLButtonElement | null>(null);
|
|
||||||
let menuPosition = $state<{
|
|
||||||
top: number;
|
|
||||||
left: number;
|
|
||||||
width: number;
|
|
||||||
placement: 'top' | 'bottom';
|
|
||||||
maxHeight: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
let filteredOptions: ModelOption[] = $derived(
|
||||||
try {
|
(() => {
|
||||||
await modelsStore.fetch();
|
const term = searchTerm.trim().toLowerCase();
|
||||||
} catch (error) {
|
if (!term) return options;
|
||||||
console.error('Unable to load models:', error);
|
|
||||||
}
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggleOpen() {
|
let isOpen = $state(false);
|
||||||
if (loading || updating) return;
|
let showModelDialog = $state(false);
|
||||||
|
|
||||||
if (isRouter) {
|
onMount(() => {
|
||||||
// Router mode: show dropdown
|
modelsStore.fetch().catch((error) => {
|
||||||
if (isOpen) {
|
console.error('Unable to load models:', error);
|
||||||
closeMenu();
|
});
|
||||||
} else {
|
});
|
||||||
openMenu();
|
|
||||||
}
|
function handleOpenChange(open: boolean) {
|
||||||
} else {
|
|
||||||
// Single model mode: show dialog
|
|
||||||
showModelDialog = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openMenu() {
|
|
||||||
if (loading || updating) return;
|
if (loading || updating) return;
|
||||||
|
|
||||||
|
if (open) {
|
||||||
isOpen = true;
|
isOpen = true;
|
||||||
await tick();
|
searchTerm = '';
|
||||||
updateMenuPosition();
|
highlightedIndex = -1;
|
||||||
requestAnimationFrame(() => updateMenuPosition());
|
|
||||||
|
// Focus search input after popover opens
|
||||||
|
tick().then(() => {
|
||||||
|
requestAnimationFrame(() => searchInputRef?.focus());
|
||||||
|
});
|
||||||
|
|
||||||
if (isRouter) {
|
if (isRouter) {
|
||||||
modelsStore.fetchRouterModels().then(() => {
|
modelsStore.fetchRouterModels().then(() => {
|
||||||
modelsStore.fetchModalitiesForLoadedModels();
|
modelsStore.fetchModalitiesForLoadedModels();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
isOpen = false;
|
||||||
|
searchTerm = '';
|
||||||
|
highlightedIndex = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTriggerClick() {
|
||||||
|
if (loading || updating) return;
|
||||||
|
|
||||||
|
if (!isRouter) {
|
||||||
|
// Single model mode: show dialog instead of popover
|
||||||
|
showModelDialog = true;
|
||||||
|
}
|
||||||
|
// For router mode, the Popover handles open/close
|
||||||
}
|
}
|
||||||
|
|
||||||
export function open() {
|
export function open() {
|
||||||
if (isRouter) {
|
if (isRouter) {
|
||||||
openMenu();
|
handleOpenChange(true);
|
||||||
} else {
|
} else {
|
||||||
showModelDialog = true;
|
showModelDialog = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMenu() {
|
function closeMenu() {
|
||||||
if (!isOpen) return;
|
handleOpenChange(false);
|
||||||
|
|
||||||
isOpen = false;
|
|
||||||
menuPosition = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePointerDown(event: PointerEvent) {
|
function handleSearchKeyDown(event: KeyboardEvent) {
|
||||||
if (!container) return;
|
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))) {
|
const currentPos = compatibleIndices.indexOf(highlightedIndex);
|
||||||
closeMenu();
|
if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
|
||||||
}
|
highlightedIndex = compatibleIndices[0];
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
} else {
|
||||||
top = Math.min(Math.max(rawTop, minTop), maxTop);
|
highlightedIndex = compatibleIndices[currentPos + 1];
|
||||||
}
|
}
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (compatibleIndices.length === 0) return;
|
||||||
|
|
||||||
|
const currentPos = compatibleIndices.indexOf(highlightedIndex);
|
||||||
|
if (currentPos === -1 || currentPos === 0) {
|
||||||
|
highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
|
||||||
} else {
|
} else {
|
||||||
const rawTop = triggerRect.top - MENU_OFFSET - height;
|
highlightedIndex = compatibleIndices[currentPos - 1];
|
||||||
const minTop = VIEWPORT_GUTTER;
|
}
|
||||||
const maxTop = viewportHeight - VIEWPORT_GUTTER - height;
|
} else if (event.key === 'Enter') {
|
||||||
if (maxTop < minTop) {
|
event.preventDefault();
|
||||||
top = minTop;
|
if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
|
||||||
} else {
|
const option = filteredOptions[highlightedIndex];
|
||||||
top = Math.max(Math.min(rawTop, maxTop), minTop);
|
if (isModelCompatible(option)) {
|
||||||
|
handleSelect(option.id);
|
||||||
|
}
|
||||||
|
} else if (compatibleIndices.length > 0) {
|
||||||
|
// No selection - highlight first compatible option
|
||||||
|
highlightedIndex = compatibleIndices[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (left < VIEWPORT_GUTTER) {
|
|
||||||
left = VIEWPORT_GUTTER;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
async function handleSelect(modelId: string) {
|
||||||
|
|
@ -356,6 +293,14 @@
|
||||||
|
|
||||||
if (shouldCloseMenu) {
|
if (shouldCloseMenu) {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
|
|
||||||
|
// Focus the chat textarea after model selection
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||||||
|
'[data-slot="chat-form"] textarea'
|
||||||
|
);
|
||||||
|
textarea?.focus();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -404,10 +349,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onresize={handleResize} />
|
<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
|
||||||
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
|
||||||
|
|
||||||
<div class={cn('relative inline-flex flex-col items-end gap-1', className)} bind:this={container}>
|
|
||||||
{#if loading && options.length === 0 && isRouter}
|
{#if loading && options.length === 0 && isRouter}
|
||||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||||
|
|
@ -418,9 +360,8 @@
|
||||||
{:else}
|
{:else}
|
||||||
{@const selectedOption = getDisplayOption()}
|
{@const selectedOption = getDisplayOption()}
|
||||||
|
|
||||||
<div class="relative">
|
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<button
|
<Popover.Trigger
|
||||||
type="button"
|
|
||||||
class={cn(
|
class={cn(
|
||||||
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
|
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
|
||||||
!isCurrentModelInCache()
|
!isCurrentModelInCache()
|
||||||
|
|
@ -430,15 +371,11 @@
|
||||||
: isHighlightedCurrentModelActive
|
: isHighlightedCurrentModelActive
|
||||||
? 'text-foreground'
|
? 'text-foreground'
|
||||||
: 'text-muted-foreground',
|
: 'text-muted-foreground',
|
||||||
isOpen ? 'text-foreground' : '',
|
isOpen ? 'text-foreground' : ''
|
||||||
className
|
|
||||||
)}
|
)}
|
||||||
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
||||||
aria-haspopup={isRouter ? 'listbox' : undefined}
|
onclick={handleTriggerClick}
|
||||||
aria-expanded={isRouter ? isOpen : undefined}
|
disabled={disabled || updating || !isRouter}
|
||||||
onclick={toggleOpen}
|
|
||||||
bind:this={triggerButton}
|
|
||||||
disabled={disabled || updating}
|
|
||||||
>
|
>
|
||||||
<Package class="h-3.5 w-3.5" />
|
<Package class="h-3.5 w-3.5" />
|
||||||
|
|
||||||
|
|
@ -451,33 +388,35 @@
|
||||||
{:else if isRouter}
|
{:else if isRouter}
|
||||||
<ChevronDown class="h-3 w-3.5" />
|
<ChevronDown class="h-3 w-3.5" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</Popover.Trigger>
|
||||||
|
|
||||||
{#if isOpen && isRouter}
|
<Popover.Content
|
||||||
<div
|
class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
|
||||||
bind:this={menuRef}
|
align="end"
|
||||||
use:portalToBody
|
sideOffset={8}
|
||||||
class={cn(
|
collisionPadding={16}
|
||||||
'fixed z-[1000] overflow-hidden rounded-md border bg-popover shadow-lg transition-opacity',
|
|
||||||
menuPosition ? 'opacity-100' : 'pointer-events-none opacity-0'
|
|
||||||
)}
|
|
||||||
role="listbox"
|
|
||||||
style:top={menuPosition ? `${menuPosition.top}px` : undefined}
|
|
||||||
style:left={menuPosition ? `${menuPosition.left}px` : undefined}
|
|
||||||
style:width={menuPosition ? `${menuPosition.width}px` : undefined}
|
|
||||||
data-placement={menuPosition?.placement ?? 'bottom'}
|
|
||||||
>
|
>
|
||||||
|
<div class="flex max-h-[50dvh] flex-col overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="overflow-y-auto py-1"
|
class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
|
||||||
style:max-height={menuPosition && menuPosition.maxHeight > 0
|
>
|
||||||
? `${menuPosition.maxHeight}px`
|
<SearchInput
|
||||||
: undefined}
|
id="model-search"
|
||||||
|
placeholder="Search models..."
|
||||||
|
bind:value={searchTerm}
|
||||||
|
bind:ref={searchInputRef}
|
||||||
|
onClose={closeMenu}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
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 !isCurrentModelInCache() && currentModel}
|
||||||
<!-- Show unavailable model as first option (disabled) -->
|
<!-- Show unavailable model as first option (disabled) -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-3 py-2 text-left text-sm text-red-400"
|
class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected="true"
|
aria-selected="true"
|
||||||
aria-disabled="true"
|
aria-disabled="true"
|
||||||
|
|
@ -488,20 +427,25 @@
|
||||||
</button>
|
</button>
|
||||||
<div class="my-1 h-px bg-border"></div>
|
<div class="my-1 h-px bg-border"></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each options as option (option.id)}
|
{#if filteredOptions.length === 0}
|
||||||
|
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
||||||
|
{/if}
|
||||||
|
{#each filteredOptions as option, index (option.id)}
|
||||||
{@const status = getModelStatus(option.model)}
|
{@const status = getModelStatus(option.model)}
|
||||||
{@const isLoaded = status === ServerModelStatus.LOADED}
|
{@const isLoaded = status === ServerModelStatus.LOADED}
|
||||||
{@const isLoading = status === ServerModelStatus.LOADING}
|
{@const isLoading = status === ServerModelStatus.LOADING}
|
||||||
{@const isSelected = currentModel === option.model || activeId === option.id}
|
{@const isSelected = currentModel === option.model || activeId === option.id}
|
||||||
{@const isCompatible = isModelCompatible(option)}
|
{@const isCompatible = isModelCompatible(option)}
|
||||||
|
{@const isHighlighted = index === highlightedIndex}
|
||||||
{@const missingModalities = getMissingModalities(option)}
|
{@const missingModalities = getMissingModalities(option)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'group flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition focus:outline-none',
|
'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
|
||||||
isCompatible
|
isCompatible
|
||||||
? 'cursor-pointer hover:bg-muted focus:bg-muted'
|
? 'cursor-pointer hover:bg-muted focus:bg-muted'
|
||||||
: 'cursor-not-allowed opacity-50',
|
: 'cursor-not-allowed opacity-50',
|
||||||
isSelected
|
isSelected || isHighlighted
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: isCompatible
|
: isCompatible
|
||||||
? 'hover:bg-accent hover:text-accent-foreground'
|
? 'hover:bg-accent hover:text-accent-foreground'
|
||||||
|
|
@ -509,10 +453,11 @@
|
||||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||||
)}
|
)}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected || isHighlighted}
|
||||||
aria-disabled={!isCompatible}
|
aria-disabled={!isCompatible}
|
||||||
tabindex={isCompatible ? 0 : -1}
|
tabindex={isCompatible ? 0 : -1}
|
||||||
onclick={() => isCompatible && handleSelect(option.id)}
|
onclick={() => isCompatible && handleSelect(option.id)}
|
||||||
|
onmouseenter={() => (highlightedIndex = index)}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
|
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -586,8 +531,8 @@
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</Popover.Content>
|
||||||
</div>
|
</Popover.Root>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
import PopoverPortal from './popover-portal.svelte';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
|
||||||
|
import type { ComponentProps } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
side,
|
||||||
|
align = 'center',
|
||||||
|
collisionPadding = 8,
|
||||||
|
avoidCollisions = true,
|
||||||
|
portalProps,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.ContentProps & {
|
||||||
|
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPortal {...portalProps}>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-content"
|
||||||
|
{sideOffset}
|
||||||
|
{side}
|
||||||
|
{align}
|
||||||
|
{collisionPadding}
|
||||||
|
{avoidCollisions}
|
||||||
|
class={cn(
|
||||||
|
'z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
</PopoverPortal>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Portal {...restProps} />
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/components/ui/utils.js';
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: PopoverPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Trigger
|
||||||
|
bind:ref
|
||||||
|
data-slot="popover-trigger"
|
||||||
|
class={cn('', className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||||
|
|
||||||
|
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PopoverPrimitive.Root bind:open {...restProps} />
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
export const VIEWPORT_GUTTER = 8;
|
export const VIEWPORT_GUTTER = 8;
|
||||||
export const MENU_OFFSET = 6;
|
export const MENU_OFFSET = 6;
|
||||||
export const MENU_MAX_WIDTH = 320;
|
|
||||||
|
|
|
||||||
|
|
@ -295,14 +295,21 @@ class ModelsStore {
|
||||||
* Fetch props for a specific model from /props endpoint
|
* Fetch props for a specific model from /props endpoint
|
||||||
* Uses caching to avoid redundant requests
|
* 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
|
* @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<ApiLlamaCppServerProps | null> {
|
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
|
||||||
// Return cached props if available
|
// Return cached props if available
|
||||||
const cached = this.modelPropsCache.get(modelId);
|
const cached = this.modelPropsCache.get(modelId);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
|
if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Avoid duplicate fetches
|
// Avoid duplicate fetches
|
||||||
if (this.modelPropsFetching.has(modelId)) return null;
|
if (this.modelPropsFetching.has(modelId)) return null;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue