webui: Architecture and UI improvements (#19596)

This commit is contained in:
Aleksander Grygier
2026-02-14 09:06:41 +01:00
committed by GitHub
parent 2d8015e8a4
commit baa12f3831
76 changed files with 1061 additions and 2818 deletions
@@ -1,6 +1,6 @@
<script lang="ts">
import { Package } from '@lucide/svelte';
import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app';
import { BadgeInfo, ActionIconCopyToClipboard } from '$lib/components/app';
import { modelsStore } from '$lib/stores/models.svelte';
import { serverStore } from '$lib/stores/server.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -34,7 +34,7 @@
{model}
{#if showCopyIcon}
<CopyToClipboardIcon text={model || ''} ariaLabel="Copy model name" />
<ActionIconCopyToClipboard text={model || ''} ariaLabel="Copy model name" />
{/if}
</BadgeInfo>
{/snippet}
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
import { onMount } from 'svelte';
import { ChevronDown, Loader2, Package, Power } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/components/ui/utils';
import {
modelsStore,
@@ -11,13 +11,15 @@
modelsUpdating,
selectedModelId,
routerModels,
propsCacheVersion,
singleModelName
} from '$lib/stores/models.svelte';
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
import { ServerModelStatus } from '$lib/enums';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte';
import { DialogModelInformation, SearchInput } from '$lib/components/app';
import {
DialogModelInformation,
DropdownMenuSearchable,
TruncatedText
} from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
interface Props {
@@ -29,11 +31,7 @@
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
/**
* When provided, only consider modalities from messages BEFORE this message.
* Used for regeneration - allows selecting models that don't support modalities
* used in later messages.
*/
/** Optional compatibility prop for context-aware selectors. */
upToMessageId?: string;
}
@@ -44,7 +42,8 @@
disabled = false,
forceForegroundText = false,
useGlobalSelection = false,
upToMessageId
// eslint-disable-next-line @typescript-eslint/no-unused-vars
upToMessageId: _upToMessageId = undefined
}: Props = $props();
let options = $derived(modelOptions());
@@ -57,74 +56,11 @@
// Reactive router models state - needed for proper reactivity of status checks
let currentRouterModels = $derived(routerModels());
let requiredModalities = $derived(
upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities()
);
function getModelStatus(modelId: string): ServerModelStatus | null {
const model = currentRouterModels.find((m) => m.id === modelId);
return (model?.status?.value as ServerModelStatus) ?? null;
}
/**
* Checks if a model supports all modalities used in the conversation.
* Returns true if the model can be selected, false if it should be disabled.
*/
function isModelCompatible(option: ModelOption): boolean {
void propsCacheVersion();
const modelModalities = modelsStore.getModelModalities(option.model);
if (!modelModalities) {
const status = getModelStatus(option.model);
if (status === ServerModelStatus.LOADED) {
if (requiredModalities.vision || requiredModalities.audio) return false;
}
return true;
}
if (requiredModalities.vision && !modelModalities.vision) return false;
if (requiredModalities.audio && !modelModalities.audio) return false;
return true;
}
/**
* Gets missing modalities for a model.
* Returns object with vision/audio booleans indicating what's missing.
*/
function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null {
void propsCacheVersion();
const modelModalities = modelsStore.getModelModalities(option.model);
if (!modelModalities) {
const status = getModelStatus(option.model);
if (status === ServerModelStatus.LOADED) {
const missing = {
vision: requiredModalities.vision,
audio: requiredModalities.audio
};
if (missing.vision || missing.audio) return missing;
}
return null;
}
const missing = {
vision: requiredModalities.vision && !modelModalities.vision,
audio: requiredModalities.audio && !modelModalities.audio
};
if (!missing.vision && !missing.audio) return null;
return missing;
}
let isHighlightedCurrentModelActive = $derived(
!isRouter || !currentModel
? false
@@ -142,7 +78,6 @@
});
let searchTerm = $state('');
let searchInputRef = $state<HTMLInputElement | null>(null);
let highlightedIndex = $state<number>(-1);
let filteredOptions: ModelOption[] = $derived(
@@ -157,13 +92,6 @@
})()
);
// 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;
@@ -179,7 +107,7 @@
});
});
// Handle changes to the model selector pop-down or the model dialog, depending on if the server is in
// Handle changes to the model selector dropdown or the model dialog, depending on if the server is in
// router mode or not.
function handleOpenChange(open: boolean) {
if (loading || updating) return;
@@ -190,11 +118,6 @@
searchTerm = '';
highlightedIndex = -1;
// Focus search input after popover opens
tick().then(() => {
requestAnimationFrame(() => searchInputRef?.focus());
});
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
});
@@ -215,36 +138,32 @@
function handleSearchKeyDown(event: KeyboardEvent) {
if (event.isComposing) return;
if (event.key === 'ArrowDown') {
if (event.key === KeyboardKey.ARROW_DOWN) {
event.preventDefault();
if (compatibleIndices.length === 0) return;
if (filteredOptions.length === 0) return;
const currentPos = compatibleIndices.indexOf(highlightedIndex);
if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
highlightedIndex = compatibleIndices[0];
if (highlightedIndex === -1 || highlightedIndex === filteredOptions.length - 1) {
highlightedIndex = 0;
} else {
highlightedIndex = compatibleIndices[currentPos + 1];
highlightedIndex += 1;
}
} else if (event.key === 'ArrowUp') {
} else if (event.key === KeyboardKey.ARROW_UP) {
event.preventDefault();
if (compatibleIndices.length === 0) return;
if (filteredOptions.length === 0) return;
const currentPos = compatibleIndices.indexOf(highlightedIndex);
if (currentPos === -1 || currentPos === 0) {
highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
if (highlightedIndex === -1 || highlightedIndex === 0) {
highlightedIndex = filteredOptions.length - 1;
} else {
highlightedIndex = compatibleIndices[currentPos - 1];
highlightedIndex -= 1;
}
} else if (event.key === 'Enter') {
} else if (event.key === KeyboardKey.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];
handleSelect(option.id);
} else if (filteredOptions.length > 0) {
// No selection - highlight first option
highlightedIndex = 0;
}
}
}
@@ -347,68 +266,72 @@
{@const selectedOption = getDisplayOption()}
{#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)"
<DropdownMenu.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
<DropdownMenu.Trigger
disabled={disabled || updating}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Package class="h-3.5 w-3.5" />
<button
type="button"
class={cn(
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] 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 - 9rem), 20rem)"
disabled={disabled || updating}
>
<Package class="h-3.5 w-3.5" />
<span class="truncate font-medium">
{selectedOption?.model || 'Select model'}
</span>
<TruncatedText
text={selectedOption?.model || 'Select model'}
class="min-w-0 font-medium"
/>
{#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
{/if}
</Popover.Trigger>
{#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
{/if}
</button>
</DropdownMenu.Trigger>
<Popover.Content
class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
<DropdownMenu.Content
align="end"
sideOffset={8}
collisionPadding={16}
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
>
<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"
>
<DropdownMenuSearchable
bind:searchValue={searchTerm}
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
>
<div class="models-list">
{#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"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-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="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:text-clip sm:whitespace-nowrap"
>
{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>
@@ -421,104 +344,78 @@
{@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',
'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer hover:bg-muted focus:bg-muted',
isSelected || isHighlighted
? 'bg-accent text-accent-foreground'
: isCompatible
? 'hover:bg-accent hover:text-accent-foreground'
: '',
: '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)}
tabindex="0"
onclick={() => handleSelect(option.id)}
onmouseenter={() => (highlightedIndex = index)}
onkeydown={(e) => {
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect(option.id);
}
}}
>
<span class="min-w-0 flex-1 truncate">{option.model}</span>
<span
class="min-w-0 flex-1 truncate text-left sm:overflow-visible sm:pr-2 sm:text-clip sm:whitespace-nowrap"
>
{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 class="flex w-6 shrink-0 justify-center">
{#if isLoading}
<Tooltip.Root>
<Tooltip.Trigger>
<Loader2 class="h-4 w-4 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 flex h-4 w-4 items-center justify-center"
onclick={(e) => {
e.stopPropagation();
modelsStore.unloadModel(option.model);
}}
>
<span
class="h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
></span>
<Power
class="absolute 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="h-2 w-2 rounded-full bg-muted-foreground/50"></span>
{/if}
</div>
</div>
{/each}
</div>
</div>
</Popover.Content>
</Popover.Root>
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<button
class={cn(
@@ -538,9 +435,7 @@
>
<Package class="h-3.5 w-3.5" />
<span class="truncate font-medium">
{selectedOption?.model}
</span>
<TruncatedText text={selectedOption?.model || ''} class="min-w-0 font-medium" />
{#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
@@ -0,0 +1,73 @@
/**
*
* MODELS
*
* Components for model selection and display. 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.
*
*/
/**
* **ModelsSelector** - Model selection dropdown
*
* 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
* <ModelsSelector
* currentModel={conversation.modelId}
* onModelChange={(id, name) => updateModel(id)}
* useGlobalSelection
* />
* ```
*/
export { default as ModelsSelector } from './ModelsSelector.svelte';
/**
* **ModelBadge** - Model name display badge
*
* Compact badge showing current model name with package icon.
* Only visible in single model mode. Supports tooltip and copy functionality.
*
* **Architecture:**
* - Reads model name from modelsStore or prop
* - Checks server mode from serverStore
* - Uses BadgeInfo for consistent styling
*
* **Features:**
* - Optional copy to clipboard button
* - Optional tooltip with model details
* - Click handler for model info dialog
* - Only renders in model mode (not router)
*
* @example
* ```svelte
* <ModelBadge
* onclick={() => showModelInfo = true}
* showTooltip
* showCopyIcon
* />
* ```
*/
export { default as ModelBadge } from './ModelBadge.svelte';