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}