|
|
@@ -179,51 +179,37 @@
|
|
|
});
|
|
|
});
|
|
|
|
|
|
+ // Handle changes to the model selector pop-down or the model dialog, depending on if the server is in
|
|
|
+ // router mode or not.
|
|
|
function handleOpenChange(open: boolean) {
|
|
|
if (loading || updating) return;
|
|
|
|
|
|
- if (open) {
|
|
|
- isOpen = true;
|
|
|
- searchTerm = '';
|
|
|
- highlightedIndex = -1;
|
|
|
-
|
|
|
- // Focus search input after popover opens
|
|
|
- tick().then(() => {
|
|
|
- requestAnimationFrame(() => searchInputRef?.focus());
|
|
|
- });
|
|
|
+ if (isRouter) {
|
|
|
+ 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 {
|
|
|
+ isOpen = false;
|
|
|
+ searchTerm = '';
|
|
|
+ highlightedIndex = -1;
|
|
|
}
|
|
|
} else {
|
|
|
- isOpen = false;
|
|
|
- searchTerm = '';
|
|
|
- highlightedIndex = -1;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- function handleTriggerClick() {
|
|
|
- if (loading || updating) return;
|
|
|
-
|
|
|
- if (!isRouter) {
|
|
|
- // Single model mode: show dialog instead of popover
|
|
|
- showModelDialog = true;
|
|
|
+ showModelDialog = open;
|
|
|
}
|
|
|
- // For router mode, the Popover handles open/close
|
|
|
}
|
|
|
|
|
|
export function open() {
|
|
|
- if (isRouter) {
|
|
|
- handleOpenChange(true);
|
|
|
- } else {
|
|
|
- showModelDialog = true;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- function closeMenu() {
|
|
|
- handleOpenChange(false);
|
|
|
+ handleOpenChange(true);
|
|
|
}
|
|
|
|
|
|
function handleSearchKeyDown(event: KeyboardEvent) {
|
|
|
@@ -292,7 +278,7 @@
|
|
|
}
|
|
|
|
|
|
if (shouldCloseMenu) {
|
|
|
- closeMenu();
|
|
|
+ handleOpenChange(false);
|
|
|
|
|
|
// Focus the chat textarea after model selection
|
|
|
requestAnimationFrame(() => {
|
|
|
@@ -360,8 +346,181 @@
|
|
|
{:else}
|
|
|
{@const selectedOption = getDisplayOption()}
|
|
|
|
|
|
- <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
|
|
- <Popover.Trigger
|
|
|
+ {#if isRouter}
|
|
|
+ <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()
|
|
|
+ ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
|
|
+ : forceForegroundText
|
|
|
+ ? 'text-foreground'
|
|
|
+ : isHighlightedCurrentModelActive
|
|
|
+ ? 'text-foreground'
|
|
|
+ : 'text-muted-foreground',
|
|
|
+ isOpen ? 'text-foreground' : ''
|
|
|
+ )}
|
|
|
+ style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
|
|
+ disabled={disabled || updating}
|
|
|
+ >
|
|
|
+ <Package class="h-3.5 w-3.5" />
|
|
|
+
|
|
|
+ <span class="truncate font-medium">
|
|
|
+ {selectedOption?.model || 'Select model'}
|
|
|
+ </span>
|
|
|
+
|
|
|
+ {#if updating}
|
|
|
+ <Loader2 class="h-3 w-3.5 animate-spin" />
|
|
|
+ {:else}
|
|
|
+ <ChevronDown class="h-3 w-3.5" />
|
|
|
+ {/if}
|
|
|
+ </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={() => handleOpenChange(false)}
|
|
|
+ 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}
|
|
|
+ <!-- Show unavailable model as first option (disabled) -->
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ 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"
|
|
|
+ disabled
|
|
|
+ >
|
|
|
+ <span class="truncate">{selectedOption?.name || currentModel}</span>
|
|
|
+ <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
|
|
+ </button>
|
|
|
+ <div class="my-1 h-px bg-border"></div>
|
|
|
+ {/if}
|
|
|
+ {#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-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 || isHighlighted
|
|
|
+ ? 'bg-accent text-accent-foreground'
|
|
|
+ : isCompatible
|
|
|
+ ? 'hover:bg-accent hover:text-accent-foreground'
|
|
|
+ : '',
|
|
|
+ isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
|
|
+ )}
|
|
|
+ role="option"
|
|
|
+ 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();
|
|
|
+ handleSelect(option.id);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <span class="min-w-0 flex-1 truncate">{option.model}</span>
|
|
|
+
|
|
|
+ {#if missingModalities}
|
|
|
+ <span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
|
|
|
+ {#if missingModalities.vision}
|
|
|
+ <Tooltip.Root>
|
|
|
+ <Tooltip.Trigger>
|
|
|
+ <EyeOff class="h-3.5 w-3.5" />
|
|
|
+ </Tooltip.Trigger>
|
|
|
+ <Tooltip.Content class="z-[9999]">
|
|
|
+ <p>No vision support</p>
|
|
|
+ </Tooltip.Content>
|
|
|
+ </Tooltip.Root>
|
|
|
+ {/if}
|
|
|
+ {#if missingModalities.audio}
|
|
|
+ <Tooltip.Root>
|
|
|
+ <Tooltip.Trigger>
|
|
|
+ <MicOff class="h-3.5 w-3.5" />
|
|
|
+ </Tooltip.Trigger>
|
|
|
+ <Tooltip.Content class="z-[9999]">
|
|
|
+ <p>No audio support</p>
|
|
|
+ </Tooltip.Content>
|
|
|
+ </Tooltip.Root>
|
|
|
+ {/if}
|
|
|
+ </span>
|
|
|
+ {/if}
|
|
|
+
|
|
|
+ {#if isLoading}
|
|
|
+ <Tooltip.Root>
|
|
|
+ <Tooltip.Trigger>
|
|
|
+ <Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
|
|
+ </Tooltip.Trigger>
|
|
|
+ <Tooltip.Content class="z-[9999]">
|
|
|
+ <p>Loading model...</p>
|
|
|
+ </Tooltip.Content>
|
|
|
+ </Tooltip.Root>
|
|
|
+ {:else if isLoaded}
|
|
|
+ <Tooltip.Root>
|
|
|
+ <Tooltip.Trigger>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
|
|
|
+ onclick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ modelsStore.unloadModel(option.model);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <span
|
|
|
+ class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
|
|
|
+ ></span>
|
|
|
+ <Power
|
|
|
+ class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ </Tooltip.Trigger>
|
|
|
+ <Tooltip.Content class="z-[9999]">
|
|
|
+ <p>Unload model</p>
|
|
|
+ </Tooltip.Content>
|
|
|
+ </Tooltip.Root>
|
|
|
+ {:else}
|
|
|
+ <span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
|
|
|
+ {/if}
|
|
|
+ </div>
|
|
|
+ {/each}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Popover.Content>
|
|
|
+ </Popover.Root>
|
|
|
+ {:else}
|
|
|
+ <button
|
|
|
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()
|
|
|
@@ -374,165 +533,20 @@
|
|
|
isOpen ? 'text-foreground' : ''
|
|
|
)}
|
|
|
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
|
|
- onclick={handleTriggerClick}
|
|
|
- disabled={disabled || updating || !isRouter}
|
|
|
+ onclick={() => handleOpenChange(true)}
|
|
|
+ disabled={disabled || updating}
|
|
|
>
|
|
|
<Package class="h-3.5 w-3.5" />
|
|
|
|
|
|
<span class="truncate font-medium">
|
|
|
- {selectedOption?.model || 'Select model'}
|
|
|
+ {selectedOption?.model}
|
|
|
</span>
|
|
|
|
|
|
{#if updating}
|
|
|
<Loader2 class="h-3 w-3.5 animate-spin" />
|
|
|
- {:else if isRouter}
|
|
|
- <ChevronDown class="h-3 w-3.5" />
|
|
|
{/if}
|
|
|
- </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="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-4 py-2 text-left text-sm text-red-400"
|
|
|
- role="option"
|
|
|
- aria-selected="true"
|
|
|
- aria-disabled="true"
|
|
|
- disabled
|
|
|
- >
|
|
|
- <span class="truncate">{selectedOption?.name || currentModel}</span>
|
|
|
- <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
|
|
- </button>
|
|
|
- <div class="my-1 h-px bg-border"></div>
|
|
|
- {/if}
|
|
|
- {#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-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 || isHighlighted
|
|
|
- ? 'bg-accent text-accent-foreground'
|
|
|
- : isCompatible
|
|
|
- ? 'hover:bg-accent hover:text-accent-foreground'
|
|
|
- : '',
|
|
|
- isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
|
|
- )}
|
|
|
- role="option"
|
|
|
- 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();
|
|
|
- handleSelect(option.id);
|
|
|
- }
|
|
|
- }}
|
|
|
- >
|
|
|
- <span class="min-w-0 flex-1 truncate">{option.model}</span>
|
|
|
-
|
|
|
- {#if missingModalities}
|
|
|
- <span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
|
|
|
- {#if missingModalities.vision}
|
|
|
- <Tooltip.Root>
|
|
|
- <Tooltip.Trigger>
|
|
|
- <EyeOff class="h-3.5 w-3.5" />
|
|
|
- </Tooltip.Trigger>
|
|
|
- <Tooltip.Content class="z-[9999]">
|
|
|
- <p>No vision support</p>
|
|
|
- </Tooltip.Content>
|
|
|
- </Tooltip.Root>
|
|
|
- {/if}
|
|
|
- {#if missingModalities.audio}
|
|
|
- <Tooltip.Root>
|
|
|
- <Tooltip.Trigger>
|
|
|
- <MicOff class="h-3.5 w-3.5" />
|
|
|
- </Tooltip.Trigger>
|
|
|
- <Tooltip.Content class="z-[9999]">
|
|
|
- <p>No audio support</p>
|
|
|
- </Tooltip.Content>
|
|
|
- </Tooltip.Root>
|
|
|
- {/if}
|
|
|
- </span>
|
|
|
- {/if}
|
|
|
-
|
|
|
- {#if isLoading}
|
|
|
- <Tooltip.Root>
|
|
|
- <Tooltip.Trigger>
|
|
|
- <Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
|
|
- </Tooltip.Trigger>
|
|
|
- <Tooltip.Content class="z-[9999]">
|
|
|
- <p>Loading model...</p>
|
|
|
- </Tooltip.Content>
|
|
|
- </Tooltip.Root>
|
|
|
- {:else if isLoaded}
|
|
|
- <Tooltip.Root>
|
|
|
- <Tooltip.Trigger>
|
|
|
- <button
|
|
|
- type="button"
|
|
|
- class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
|
|
|
- onclick={(e) => {
|
|
|
- e.stopPropagation();
|
|
|
- modelsStore.unloadModel(option.model);
|
|
|
- }}
|
|
|
- >
|
|
|
- <span
|
|
|
- class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
|
|
|
- ></span>
|
|
|
- <Power
|
|
|
- class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
|
|
|
- />
|
|
|
- </button>
|
|
|
- </Tooltip.Trigger>
|
|
|
- <Tooltip.Content class="z-[9999]">
|
|
|
- <p>Unload model</p>
|
|
|
- </Tooltip.Content>
|
|
|
- </Tooltip.Root>
|
|
|
- {:else}
|
|
|
- <span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
|
|
|
- {/if}
|
|
|
- </div>
|
|
|
- {/each}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Popover.Content>
|
|
|
- </Popover.Root>
|
|
|
+ </button>
|
|
|
+ {/if}
|
|
|
{/if}
|
|
|
</div>
|
|
|
|