aboutsummaryrefslogtreecommitdiff
path: root/llama.cpp/tools/server/webui/src/lib/components/app/models
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-12 20:57:17 +0100
commitb333b06772c89d96aacb5490d6a219fba7c09cc6 (patch)
tree211df60083a5946baa2ed61d33d8121b7e251b06 /llama.cpp/tools/server/webui/src/lib/components/app/models
downloadllmnpc-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.svelte56
-rw-r--r--llama.cpp/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte555
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}