diff options
| author | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
|---|---|---|
| committer | Mitja Felicijan <mitja.felicijan@gmail.com> | 2026-02-12 20:57:17 +0100 |
| commit | b333b06772c89d96aacb5490d6a219fba7c09cc6 (patch) | |
| tree | 211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/models | |
| download | llmnpc-b333b06772c89d96aacb5490d6a219fba7c09cc6.tar.gz | |
Engage!
Diffstat (limited to 'llama.cpp/tools/server/webui/src/lib/components/app/models')
| -rw-r--r-- | llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte | 56 | ||||
| -rw-r--r-- | llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte | 555 |
2 files changed, 611 insertions, 0 deletions
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte new file mode 100644 index 0000000..bea1bf6 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelBadge.svelte | |||
| @@ -0,0 +1,56 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { Package } from '@lucide/svelte'; | ||
| 3 | import { BadgeInfo, CopyToClipboardIcon } from '$lib/components/app'; | ||
| 4 | import { modelsStore } from '$lib/stores/models.svelte'; | ||
| 5 | import { serverStore } from '$lib/stores/server.svelte'; | ||
| 6 | import * as Tooltip from '$lib/components/ui/tooltip'; | ||
| 7 | |||
| 8 | interface Props { | ||
| 9 | class?: string; | ||
| 10 | model?: string; | ||
| 11 | onclick?: () => void; | ||
| 12 | showCopyIcon?: boolean; | ||
| 13 | showTooltip?: boolean; | ||
| 14 | } | ||
| 15 | |||
| 16 | let { | ||
| 17 | class: className = '', | ||
| 18 | model: modelProp, | ||
| 19 | onclick, | ||
| 20 | showCopyIcon = false, | ||
| 21 | showTooltip = false | ||
| 22 | }: Props = $props(); | ||
| 23 | |||
| 24 | let model = $derived(modelProp || modelsStore.singleModelName); | ||
| 25 | let isModelMode = $derived(serverStore.isModelMode); | ||
| 26 | </script> | ||
| 27 | |||
| 28 | {#snippet badgeContent()} | ||
| 29 | <BadgeInfo class={className} {onclick}> | ||
| 30 | {#snippet icon()} | ||
| 31 | <Package class="h-3 w-3" /> | ||
| 32 | {/snippet} | ||
| 33 | |||
| 34 | {model} | ||
| 35 | |||
| 36 | {#if showCopyIcon} | ||
| 37 | <CopyToClipboardIcon text={model || ''} ariaLabel="Copy model name" /> | ||
| 38 | {/if} | ||
| 39 | </BadgeInfo> | ||
| 40 | {/snippet} | ||
| 41 | |||
| 42 | {#if model && isModelMode} | ||
| 43 | {#if showTooltip} | ||
| 44 | <Tooltip.Root> | ||
| 45 | <Tooltip.Trigger> | ||
| 46 | {@render badgeContent()} | ||
| 47 | </Tooltip.Trigger> | ||
| 48 | |||
| 49 | <Tooltip.Content> | ||
| 50 | {onclick ? 'Click for model details' : model} | ||
| 51 | </Tooltip.Content> | ||
| 52 | </Tooltip.Root> | ||
| 53 | {:else} | ||
| 54 | {@render badgeContent()} | ||
| 55 | {/if} | ||
| 56 | {/if} | ||
diff --git a/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte new file mode 100644 index 0000000..efc9cd4 --- /dev/null +++ b/llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte | |||
| @@ -0,0 +1,555 @@ | |||
| 1 | <script lang="ts"> | ||
| 2 | import { onMount, tick } from 'svelte'; | ||
| 3 | import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte'; | ||
| 4 | import * as Tooltip from '$lib/components/ui/tooltip'; | ||
| 5 | import * as Popover from '$lib/components/ui/popover'; | ||
| 6 | import { cn } from '$lib/components/ui/utils'; | ||
| 7 | import { | ||
| 8 | modelsStore, | ||
| 9 | modelOptions, | ||
| 10 | modelsLoading, | ||
| 11 | modelsUpdating, | ||
| 12 | selectedModelId, | ||
| 13 | routerModels, | ||
| 14 | propsCacheVersion, | ||
| 15 | singleModelName | ||
| 16 | } from '$lib/stores/models.svelte'; | ||
| 17 | import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte'; | ||
| 18 | import { ServerModelStatus } from '$lib/enums'; | ||
| 19 | import { isRouterMode } from '$lib/stores/server.svelte'; | ||
| 20 | import { DialogModelInformation, SearchInput } from '$lib/components/app'; | ||
| 21 | import type { ModelOption } from '$lib/types/models'; | ||
| 22 | |||
| 23 | interface Props { | ||
| 24 | class?: string; | ||
| 25 | currentModel?: string | null; | ||
| 26 | /** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */ | ||
| 27 | onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void; | ||
| 28 | disabled?: boolean; | ||
| 29 | forceForegroundText?: boolean; | ||
| 30 | /** When true, user's global selection takes priority over currentModel (for form selector) */ | ||
| 31 | useGlobalSelection?: boolean; | ||
| 32 | /** | ||
| 33 | * When provided, only consider modalities from messages BEFORE this message. | ||
| 34 | * Used for regeneration - allows selecting models that don't support modalities | ||
| 35 | * used in later messages. | ||
| 36 | */ | ||
| 37 | upToMessageId?: string; | ||
| 38 | } | ||
| 39 | |||
| 40 | let { | ||
| 41 | class: className = '', | ||
| 42 | currentModel = null, | ||
| 43 | onModelChange, | ||
| 44 | disabled = false, | ||
| 45 | forceForegroundText = false, | ||
| 46 | useGlobalSelection = false, | ||
| 47 | upToMessageId | ||
| 48 | }: Props = $props(); | ||
| 49 | |||
| 50 | let options = $derived(modelOptions()); | ||
| 51 | let loading = $derived(modelsLoading()); | ||
| 52 | let updating = $derived(modelsUpdating()); | ||
| 53 | let activeId = $derived(selectedModelId()); | ||
| 54 | let isRouter = $derived(isRouterMode()); | ||
| 55 | let serverModel = $derived(singleModelName()); | ||
| 56 | |||
| 57 | // Reactive router models state - needed for proper reactivity of status checks | ||
| 58 | let currentRouterModels = $derived(routerModels()); | ||
| 59 | |||
| 60 | let requiredModalities = $derived( | ||
| 61 | upToMessageId ? conversationsStore.getModalitiesUpToMessage(upToMessageId) : usedModalities() | ||
| 62 | ); | ||
| 63 | |||
| 64 | function getModelStatus(modelId: string): ServerModelStatus | null { | ||
| 65 | const model = currentRouterModels.find((m) => m.id === modelId); | ||
| 66 | return (model?.status?.value as ServerModelStatus) ?? null; | ||
| 67 | } | ||
| 68 | |||
| 69 | /** | ||
| 70 | * Checks if a model supports all modalities used in the conversation. | ||
| 71 | * Returns true if the model can be selected, false if it should be disabled. | ||
| 72 | */ | ||
| 73 | function isModelCompatible(option: ModelOption): boolean { | ||
| 74 | void propsCacheVersion(); | ||
| 75 | |||
| 76 | const modelModalities = modelsStore.getModelModalities(option.model); | ||
| 77 | |||
| 78 | if (!modelModalities) { | ||
| 79 | const status = getModelStatus(option.model); | ||
| 80 | |||
| 81 | if (status === ServerModelStatus.LOADED) { | ||
| 82 | if (requiredModalities.vision || requiredModalities.audio) return false; | ||
| 83 | } | ||
| 84 | |||
| 85 | return true; | ||
| 86 | } | ||
| 87 | |||
| 88 | if (requiredModalities.vision && !modelModalities.vision) return false; | ||
| 89 | if (requiredModalities.audio && !modelModalities.audio) return false; | ||
| 90 | |||
| 91 | return true; | ||
| 92 | } | ||
| 93 | |||
| 94 | /** | ||
| 95 | * Gets missing modalities for a model. | ||
| 96 | * Returns object with vision/audio booleans indicating what's missing. | ||
| 97 | */ | ||
| 98 | function getMissingModalities(option: ModelOption): { vision: boolean; audio: boolean } | null { | ||
| 99 | void propsCacheVersion(); | ||
| 100 | |||
| 101 | const modelModalities = modelsStore.getModelModalities(option.model); | ||
| 102 | |||
| 103 | if (!modelModalities) { | ||
| 104 | const status = getModelStatus(option.model); | ||
| 105 | |||
| 106 | if (status === ServerModelStatus.LOADED) { | ||
| 107 | const missing = { | ||
| 108 | vision: requiredModalities.vision, | ||
| 109 | audio: requiredModalities.audio | ||
| 110 | }; | ||
| 111 | |||
| 112 | if (missing.vision || missing.audio) return missing; | ||
| 113 | } | ||
| 114 | |||
| 115 | return null; | ||
| 116 | } | ||
| 117 | |||
| 118 | const missing = { | ||
| 119 | vision: requiredModalities.vision && !modelModalities.vision, | ||
| 120 | audio: requiredModalities.audio && !modelModalities.audio | ||
| 121 | }; | ||
| 122 | |||
| 123 | if (!missing.vision && !missing.audio) return null; | ||
| 124 | |||
| 125 | return missing; | ||
| 126 | } | ||
| 127 | |||
| 128 | let isHighlightedCurrentModelActive = $derived( | ||
| 129 | !isRouter || !currentModel | ||
| 130 | ? false | ||
| 131 | : (() => { | ||
| 132 | const currentOption = options.find((option) => option.model === currentModel); | ||
| 133 | |||
| 134 | return currentOption ? currentOption.id === activeId : false; | ||
| 135 | })() | ||
| 136 | ); | ||
| 137 | |||
| 138 | let isCurrentModelInCache = $derived(() => { | ||
| 139 | if (!isRouter || !currentModel) return true; | ||
| 140 | |||
| 141 | return options.some((option) => option.model === currentModel); | ||
| 142 | }); | ||
| 143 | |||
| 144 | let searchTerm = $state(''); | ||
| 145 | let searchInputRef = $state<HTMLInputElement | null>(null); | ||
| 146 | let highlightedIndex = $state<number>(-1); | ||
| 147 | |||
| 148 | let filteredOptions: ModelOption[] = $derived( | ||
| 149 | (() => { | ||
| 150 | const term = searchTerm.trim().toLowerCase(); | ||
| 151 | if (!term) return options; | ||
| 152 | |||
| 153 | return options.filter( | ||
| 154 | (option) => | ||
| 155 | option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term) | ||
| 156 | ); | ||
| 157 | })() | ||
| 158 | ); | ||
| 159 | |||
| 160 | // Get indices of compatible options for keyboard navigation | ||
| 161 | let compatibleIndices = $derived( | ||
| 162 | filteredOptions | ||
| 163 | .map((option, index) => (isModelCompatible(option) ? index : -1)) | ||
| 164 | .filter((i) => i !== -1) | ||
| 165 | ); | ||
| 166 | |||
| 167 | // Reset highlighted index when search term changes | ||
| 168 | $effect(() => { | ||
| 169 | void searchTerm; | ||
| 170 | highlightedIndex = -1; | ||
| 171 | }); | ||
| 172 | |||
| 173 | let isOpen = $state(false); | ||
| 174 | let showModelDialog = $state(false); | ||
| 175 | |||
| 176 | onMount(() => { | ||
| 177 | modelsStore.fetch().catch((error) => { | ||
| 178 | console.error('Unable to load models:', error); | ||
| 179 | }); | ||
| 180 | }); | ||
| 181 | |||
| 182 | // Handle changes to the model selector pop-down or the model dialog, depending on if the server is in | ||
| 183 | // router mode or not. | ||
| 184 | function handleOpenChange(open: boolean) { | ||
| 185 | if (loading || updating) return; | ||
| 186 | |||
| 187 | if (isRouter) { | ||
| 188 | if (open) { | ||
| 189 | isOpen = true; | ||
| 190 | searchTerm = ''; | ||
| 191 | highlightedIndex = -1; | ||
| 192 | |||
| 193 | // Focus search input after popover opens | ||
| 194 | tick().then(() => { | ||
| 195 | requestAnimationFrame(() => searchInputRef?.focus()); | ||
| 196 | }); | ||
| 197 | |||
| 198 | modelsStore.fetchRouterModels().then(() => { | ||
| 199 | modelsStore.fetchModalitiesForLoadedModels(); | ||
| 200 | }); | ||
| 201 | } else { | ||
| 202 | isOpen = false; | ||
| 203 | searchTerm = ''; | ||
| 204 | highlightedIndex = -1; | ||
| 205 | } | ||
| 206 | } else { | ||
| 207 | showModelDialog = open; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 211 | export function open() { | ||
| 212 | handleOpenChange(true); | ||
| 213 | } | ||
| 214 | |||
| 215 | function handleSearchKeyDown(event: KeyboardEvent) { | ||
| 216 | if (event.isComposing) return; | ||
| 217 | |||
| 218 | if (event.key === 'ArrowDown') { | ||
| 219 | event.preventDefault(); | ||
| 220 | if (compatibleIndices.length === 0) return; | ||
| 221 | |||
| 222 | const currentPos = compatibleIndices.indexOf(highlightedIndex); | ||
| 223 | if (currentPos === -1 || currentPos === compatibleIndices.length - 1) { | ||
| 224 | highlightedIndex = compatibleIndices[0]; | ||
| 225 | } else { | ||
| 226 | highlightedIndex = compatibleIndices[currentPos + 1]; | ||
| 227 | } | ||
| 228 | } else if (event.key === 'ArrowUp') { | ||
| 229 | event.preventDefault(); | ||
| 230 | if (compatibleIndices.length === 0) return; | ||
| 231 | |||
| 232 | const currentPos = compatibleIndices.indexOf(highlightedIndex); | ||
| 233 | if (currentPos === -1 || currentPos === 0) { | ||
| 234 | highlightedIndex = compatibleIndices[compatibleIndices.length - 1]; | ||
| 235 | } else { | ||
| 236 | highlightedIndex = compatibleIndices[currentPos - 1]; | ||
| 237 | } | ||
| 238 | } else if (event.key === 'Enter') { | ||
| 239 | event.preventDefault(); | ||
| 240 | if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) { | ||
| 241 | const option = filteredOptions[highlightedIndex]; | ||
| 242 | if (isModelCompatible(option)) { | ||
| 243 | handleSelect(option.id); | ||
| 244 | } | ||
| 245 | } else if (compatibleIndices.length > 0) { | ||
| 246 | // No selection - highlight first compatible option | ||
| 247 | highlightedIndex = compatibleIndices[0]; | ||
| 248 | } | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | async function handleSelect(modelId: string) { | ||
| 253 | const option = options.find((opt) => opt.id === modelId); | ||
| 254 | if (!option) return; | ||
| 255 | |||
| 256 | let shouldCloseMenu = true; | ||
| 257 | |||
| 258 | if (onModelChange) { | ||
| 259 | // If callback provided, use it (for regenerate functionality) | ||
| 260 | const result = await onModelChange(option.id, option.model); | ||
| 261 | |||
| 262 | // If callback returns false, keep menu open (validation failed) | ||
| 263 | if (result === false) { | ||
| 264 | shouldCloseMenu = false; | ||
| 265 | } | ||
| 266 | } else { | ||
| 267 | // Update global selection | ||
| 268 | await modelsStore.selectModelById(option.id); | ||
| 269 | |||
| 270 | // Load the model if not already loaded (router mode) | ||
| 271 | if (isRouter && getModelStatus(option.model) !== ServerModelStatus.LOADED) { | ||
| 272 | try { | ||
| 273 | await modelsStore.loadModel(option.model); | ||
| 274 | } catch (error) { | ||
| 275 | console.error('Failed to load model:', error); | ||
| 276 | } | ||
| 277 | } | ||
| 278 | } | ||
| 279 | |||
| 280 | if (shouldCloseMenu) { | ||
| 281 | handleOpenChange(false); | ||
| 282 | |||
| 283 | // Focus the chat textarea after model selection | ||
| 284 | requestAnimationFrame(() => { | ||
| 285 | const textarea = document.querySelector<HTMLTextAreaElement>( | ||
| 286 | '[data-slot="chat-form"] textarea' | ||
| 287 | ); | ||
| 288 | textarea?.focus(); | ||
| 289 | }); | ||
| 290 | } | ||
| 291 | } | ||
| 292 | |||
| 293 | function getDisplayOption(): ModelOption | undefined { | ||
| 294 | if (!isRouter) { | ||
| 295 | if (serverModel) { | ||
| 296 | return { | ||
| 297 | id: 'current', | ||
| 298 | model: serverModel, | ||
| 299 | name: serverModel.split('/').pop() || serverModel, | ||
| 300 | capabilities: [] // Empty array for single model mode | ||
| 301 | }; | ||
| 302 | } | ||
| 303 | |||
| 304 | return undefined; | ||
| 305 | } | ||
| 306 | |||
| 307 | // When useGlobalSelection is true (form selector), prioritize user selection | ||
| 308 | // Otherwise (message display), prioritize currentModel | ||
| 309 | if (useGlobalSelection && activeId) { | ||
| 310 | const selected = options.find((option) => option.id === activeId); | ||
| 311 | if (selected) return selected; | ||
| 312 | } | ||
| 313 | |||
| 314 | // Show currentModel (from message payload or conversation) | ||
| 315 | if (currentModel) { | ||
| 316 | if (!isCurrentModelInCache()) { | ||
| 317 | return { | ||
| 318 | id: 'not-in-cache', | ||
| 319 | model: currentModel, | ||
| 320 | name: currentModel.split('/').pop() || currentModel, | ||
| 321 | capabilities: [] | ||
| 322 | }; | ||
| 323 | } | ||
| 324 | |||
| 325 | return options.find((option) => option.model === currentModel); | ||
| 326 | } | ||
| 327 | |||
| 328 | // Fallback to user selection (for new chats before first message) | ||
| 329 | if (activeId) { | ||
| 330 | return options.find((option) => option.id === activeId); | ||
| 331 | } | ||
| 332 | |||
| 333 | // No selection - return undefined to show "Select model" | ||
| 334 | return undefined; | ||
| 335 | } | ||
| 336 | </script> | ||
| 337 | |||
| 338 | <div class={cn('relative inline-flex flex-col items-end gap-1', className)}> | ||
| 339 | {#if loading && options.length === 0 && isRouter} | ||
| 340 | <div class="flex items-center gap-2 text-xs text-muted-foreground"> | ||
| 341 | <Loader2 class="h-3.5 w-3.5 animate-spin" /> | ||
| 342 | Loading models… | ||
| 343 | </div> | ||
| 344 | {:else if options.length === 0 && isRouter} | ||
| 345 | <p class="text-xs text-muted-foreground">No models available.</p> | ||
| 346 | {:else} | ||
| 347 | {@const selectedOption = getDisplayOption()} | ||
| 348 | |||
| 349 | {#if isRouter} | ||
| 350 | <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}> | ||
| 351 | <Popover.Trigger | ||
| 352 | class={cn( | ||
| 353 | `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`, | ||
| 354 | !isCurrentModelInCache() | ||
| 355 | ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' | ||
| 356 | : forceForegroundText | ||
| 357 | ? 'text-foreground' | ||
| 358 | : isHighlightedCurrentModelActive | ||
| 359 | ? 'text-foreground' | ||
| 360 | : 'text-muted-foreground', | ||
| 361 | isOpen ? 'text-foreground' : '' | ||
| 362 | )} | ||
| 363 | style="max-width: min(calc(100cqw - 6.5rem), 32rem)" | ||
| 364 | disabled={disabled || updating} | ||
| 365 | > | ||
| 366 | <Package class="h-3.5 w-3.5" /> | ||
| 367 | |||
| 368 | <span class="truncate font-medium"> | ||
| 369 | {selectedOption?.model || 'Select model'} | ||
| 370 | </span> | ||
| 371 | |||
| 372 | {#if updating} | ||
| 373 | <Loader2 class="h-3 w-3.5 animate-spin" /> | ||
| 374 | {:else} | ||
| 375 | <ChevronDown class="h-3 w-3.5" /> | ||
| 376 | {/if} | ||
| 377 | </Popover.Trigger> | ||
| 378 | |||
| 379 | <Popover.Content | ||
| 380 | class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0" | ||
| 381 | align="end" | ||
| 382 | sideOffset={8} | ||
| 383 | collisionPadding={16} | ||
| 384 | > | ||
| 385 | <div class="flex max-h-[50dvh] flex-col overflow-hidden"> | ||
| 386 | <div | ||
| 387 | 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" | ||
| 388 | > | ||
| 389 | <SearchInput | ||
| 390 | id="model-search" | ||
| 391 | placeholder="Search models..." | ||
| 392 | bind:value={searchTerm} | ||
| 393 | bind:ref={searchInputRef} | ||
| 394 | onClose={() => handleOpenChange(false)} | ||
| 395 | onKeyDown={handleSearchKeyDown} | ||
| 396 | /> | ||
| 397 | </div> | ||
| 398 | <div | ||
| 399 | class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1" | ||
| 400 | > | ||
| 401 | {#if !isCurrentModelInCache() && currentModel} | ||
| 402 | <!-- Show unavailable model as first option (disabled) --> | ||
| 403 | <button | ||
| 404 | type="button" | ||
| 405 | class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400" | ||
| 406 | role="option" | ||
| 407 | aria-selected="true" | ||
| 408 | aria-disabled="true" | ||
| 409 | disabled | ||
| 410 | > | ||
| 411 | <span class="truncate">{selectedOption?.name || currentModel}</span> | ||
| 412 | <span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span> | ||
| 413 | </button> | ||
| 414 | <div class="my-1 h-px bg-border"></div> | ||
| 415 | {/if} | ||
| 416 | {#if filteredOptions.length === 0} | ||
| 417 | <p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p> | ||
| 418 | {/if} | ||
| 419 | {#each filteredOptions as option, index (option.id)} | ||
| 420 | {@const status = getModelStatus(option.model)} | ||
| 421 | {@const isLoaded = status === ServerModelStatus.LOADED} | ||
| 422 | {@const isLoading = status === ServerModelStatus.LOADING} | ||
| 423 | {@const isSelected = currentModel === option.model || activeId === option.id} | ||
| 424 | {@const isCompatible = isModelCompatible(option)} | ||
| 425 | {@const isHighlighted = index === highlightedIndex} | ||
| 426 | {@const missingModalities = getMissingModalities(option)} | ||
| 427 | |||
| 428 | <div | ||
| 429 | class={cn( | ||
| 430 | 'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none', | ||
| 431 | isCompatible | ||
| 432 | ? 'cursor-pointer hover:bg-muted focus:bg-muted' | ||
| 433 | : 'cursor-not-allowed opacity-50', | ||
| 434 | isSelected || isHighlighted | ||
| 435 | ? 'bg-accent text-accent-foreground' | ||
| 436 | : isCompatible | ||
| 437 | ? 'hover:bg-accent hover:text-accent-foreground' | ||
| 438 | : '', | ||
| 439 | isLoaded ? 'text-popover-foreground' : 'text-muted-foreground' | ||
| 440 | )} | ||
| 441 | role="option" | ||
| 442 | aria-selected={isSelected || isHighlighted} | ||
| 443 | aria-disabled={!isCompatible} | ||
| 444 | tabindex={isCompatible ? 0 : -1} | ||
| 445 | onclick={() => isCompatible && handleSelect(option.id)} | ||
| 446 | onmouseenter={() => (highlightedIndex = index)} | ||
| 447 | onkeydown={(e) => { | ||
| 448 | if (isCompatible && (e.key === 'Enter' || e.key === ' ')) { | ||
| 449 | e.preventDefault(); | ||
| 450 | handleSelect(option.id); | ||
| 451 | } | ||
| 452 | }} | ||
| 453 | > | ||
| 454 | <span class="min-w-0 flex-1 truncate">{option.model}</span> | ||
| 455 | |||
| 456 | {#if missingModalities} | ||
| 457 | <span class="flex shrink-0 items-center gap-1 text-muted-foreground/70"> | ||
| 458 | {#if missingModalities.vision} | ||
| 459 | <Tooltip.Root> | ||
| 460 | <Tooltip.Trigger> | ||
| 461 | <EyeOff class="h-3.5 w-3.5" /> | ||
| 462 | </Tooltip.Trigger> | ||
| 463 | <Tooltip.Content class="z-[9999]"> | ||
| 464 | <p>No vision support</p> | ||
| 465 | </Tooltip.Content> | ||
| 466 | </Tooltip.Root> | ||
| 467 | {/if} | ||
| 468 | {#if missingModalities.audio} | ||
| 469 | <Tooltip.Root> | ||
| 470 | <Tooltip.Trigger> | ||
| 471 | <MicOff class="h-3.5 w-3.5" /> | ||
| 472 | </Tooltip.Trigger> | ||
| 473 | <Tooltip.Content class="z-[9999]"> | ||
| 474 | <p>No audio support</p> | ||
| 475 | </Tooltip.Content> | ||
| 476 | </Tooltip.Root> | ||
| 477 | {/if} | ||
| 478 | </span> | ||
| 479 | {/if} | ||
| 480 | |||
| 481 | {#if isLoading} | ||
| 482 | <Tooltip.Root> | ||
| 483 | <Tooltip.Trigger> | ||
| 484 | <Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" /> | ||
| 485 | </Tooltip.Trigger> | ||
| 486 | <Tooltip.Content class="z-[9999]"> | ||
| 487 | <p>Loading model...</p> | ||
| 488 | </Tooltip.Content> | ||
| 489 | </Tooltip.Root> | ||
| 490 | {:else if isLoaded} | ||
| 491 | <Tooltip.Root> | ||
| 492 | <Tooltip.Trigger> | ||
| 493 | <button | ||
| 494 | type="button" | ||
| 495 | class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center" | ||
| 496 | onclick={(e) => { | ||
| 497 | e.stopPropagation(); | ||
| 498 | modelsStore.unloadModel(option.model); | ||
| 499 | }} | ||
| 500 | > | ||
| 501 | <span | ||
| 502 | class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0" | ||
| 503 | ></span> | ||
| 504 | <Power | ||
| 505 | class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600" | ||
| 506 | /> | ||
| 507 | </button> | ||
| 508 | </Tooltip.Trigger> | ||
| 509 | <Tooltip.Content class="z-[9999]"> | ||
| 510 | <p>Unload model</p> | ||
| 511 | </Tooltip.Content> | ||
| 512 | </Tooltip.Root> | ||
| 513 | {:else} | ||
| 514 | <span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span> | ||
| 515 | {/if} | ||
| 516 | </div> | ||
| 517 | {/each} | ||
| 518 | </div> | ||
| 519 | </div> | ||
| 520 | </Popover.Content> | ||
| 521 | </Popover.Root> | ||
| 522 | {:else} | ||
| 523 | <button | ||
| 524 | class={cn( | ||
| 525 | `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`, | ||
| 526 | !isCurrentModelInCache() | ||
| 527 | ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' | ||
| 528 | : forceForegroundText | ||
| 529 | ? 'text-foreground' | ||
| 530 | : isHighlightedCurrentModelActive | ||
| 531 | ? 'text-foreground' | ||
| 532 | : 'text-muted-foreground', | ||
| 533 | isOpen ? 'text-foreground' : '' | ||
| 534 | )} | ||
| 535 | style="max-width: min(calc(100cqw - 6.5rem), 32rem)" | ||
| 536 | onclick={() => handleOpenChange(true)} | ||
| 537 | disabled={disabled || updating} | ||
| 538 | > | ||
| 539 | <Package class="h-3.5 w-3.5" /> | ||
| 540 | |||
| 541 | <span class="truncate font-medium"> | ||
| 542 | {selectedOption?.model} | ||
| 543 | </span> | ||
| 544 | |||
| 545 | {#if updating} | ||
| 546 | <Loader2 class="h-3 w-3.5 animate-spin" /> | ||
| 547 | {/if} | ||
| 548 | </button> | ||
| 549 | {/if} | ||
| 550 | {/if} | ||
| 551 | </div> | ||
| 552 | |||
| 553 | {#if showModelDialog && !isRouter} | ||
| 554 | <DialogModelInformation bind:open={showModelDialog} /> | ||
| 555 | {/if} | ||
