|
|
@@ -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<HTMLInputElement | null>(null);
|
|
|
+ let highlightedIndex = $state<number>(-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<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 () => {
|
|
|
- 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<HTMLTextAreaElement>(
|
|
|
+ '[data-slot="chat-form"] textarea'
|
|
|
+ );
|
|
|
+ textarea?.focus();
|
|
|
+ });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -404,10 +349,7 @@
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
-<svelte:window onresize={handleResize} />
|
|
|
-<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
|
|
|
-
|
|
|
-<div class={cn('relative inline-flex flex-col items-end gap-1', className)} bind:this={container}>
|
|
|
+<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
|
|
|
{#if loading && options.length === 0 && isRouter}
|
|
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
|
|
@@ -418,9 +360,8 @@
|
|
|
{:else}
|
|
|
{@const selectedOption = getDisplayOption()}
|
|
|
|
|
|
- <div class="relative">
|
|
|
- <button
|
|
|
- type="button"
|
|
|
+ <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
|
|
+ <Popover.Trigger
|
|
|
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`,
|
|
|
!isCurrentModelInCache()
|
|
|
@@ -430,15 +371,11 @@
|
|
|
: isHighlightedCurrentModelActive
|
|
|
? 'text-foreground'
|
|
|
: 'text-muted-foreground',
|
|
|
- isOpen ? 'text-foreground' : '',
|
|
|
- className
|
|
|
+ isOpen ? 'text-foreground' : ''
|
|
|
)}
|
|
|
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
|
|
- aria-haspopup={isRouter ? 'listbox' : undefined}
|
|
|
- aria-expanded={isRouter ? isOpen : undefined}
|
|
|
- onclick={toggleOpen}
|
|
|
- bind:this={triggerButton}
|
|
|
- disabled={disabled || updating}
|
|
|
+ onclick={handleTriggerClick}
|
|
|
+ disabled={disabled || updating || !isRouter}
|
|
|
>
|
|
|
<Package class="h-3.5 w-3.5" />
|
|
|
|
|
|
@@ -451,33 +388,35 @@
|
|
|
{:else if isRouter}
|
|
|
<ChevronDown class="h-3 w-3.5" />
|
|
|
{/if}
|
|
|
- </button>
|
|
|
-
|
|
|
- {#if isOpen && isRouter}
|
|
|
- <div
|
|
|
- bind:this={menuRef}
|
|
|
- use:portalToBody
|
|
|
- class={cn(
|
|
|
- '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'}
|
|
|
- >
|
|
|
+ </Popover.Trigger>
|
|
|
+
|
|
|
+ <Popover.Content
|
|
|
+ class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
|
|
|
+ align="end"
|
|
|
+ sideOffset={8}
|
|
|
+ collisionPadding={16}
|
|
|
+ >
|
|
|
+ <div class="flex max-h-[50dvh] flex-col overflow-hidden">
|
|
|
+ <div
|
|
|
+ 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"
|
|
|
+ >
|
|
|
+ <SearchInput
|
|
|
+ id="model-search"
|
|
|
+ placeholder="Search models..."
|
|
|
+ bind:value={searchTerm}
|
|
|
+ bind:ref={searchInputRef}
|
|
|
+ onClose={closeMenu}
|
|
|
+ onKeyDown={handleSearchKeyDown}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
<div
|
|
|
- class="overflow-y-auto py-1"
|
|
|
- style:max-height={menuPosition && menuPosition.maxHeight > 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}
|
|
|
<!-- Show unavailable model as first option (disabled) -->
|
|
|
<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"
|
|
|
aria-selected="true"
|
|
|
aria-disabled="true"
|
|
|
@@ -488,20 +427,25 @@
|
|
|
</button>
|
|
|
<div class="my-1 h-px bg-border"></div>
|
|
|
{/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 isLoaded = status === ServerModelStatus.LOADED}
|
|
|
{@const isLoading = status === ServerModelStatus.LOADING}
|
|
|
{@const isSelected = currentModel === option.model || activeId === option.id}
|
|
|
{@const isCompatible = isModelCompatible(option)}
|
|
|
+ {@const isHighlighted = index === highlightedIndex}
|
|
|
{@const missingModalities = getMissingModalities(option)}
|
|
|
+
|
|
|
<div
|
|
|
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
|
|
|
? 'cursor-pointer hover:bg-muted focus:bg-muted'
|
|
|
: 'cursor-not-allowed opacity-50',
|
|
|
- isSelected
|
|
|
+ isSelected || isHighlighted
|
|
|
? 'bg-accent text-accent-foreground'
|
|
|
: isCompatible
|
|
|
? 'hover:bg-accent hover:text-accent-foreground'
|
|
|
@@ -509,10 +453,11 @@
|
|
|
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
|
|
)}
|
|
|
role="option"
|
|
|
- aria-selected={isSelected}
|
|
|
+ aria-selected={isSelected || isHighlighted}
|
|
|
aria-disabled={!isCompatible}
|
|
|
tabindex={isCompatible ? 0 : -1}
|
|
|
onclick={() => isCompatible && handleSelect(option.id)}
|
|
|
+ onmouseenter={() => (highlightedIndex = index)}
|
|
|
onkeydown={(e) => {
|
|
|
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
|
|
|
e.preventDefault();
|
|
|
@@ -586,8 +531,8 @@
|
|
|
{/each}
|
|
|
</div>
|
|
|
</div>
|
|
|
- {/if}
|
|
|
- </div>
|
|
|
+ </Popover.Content>
|
|
|
+ </Popover.Root>
|
|
|
{/if}
|
|
|
</div>
|
|
|
|