2025-12-01 19:41:04 +01:00
|
|
|
<script lang="ts">
|
2026-03-05 08:52:22 +01:00
|
|
|
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
2026-02-14 09:06:41 +01:00
|
|
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
2025-12-01 19:41:04 +01:00
|
|
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
2026-06-24 10:50:44 +02:00
|
|
|
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
2026-04-28 14:35:49 +03:00
|
|
|
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
2026-06-24 10:50:44 +02:00
|
|
|
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
|
|
|
|
|
import { modelLoadFraction } from '$lib/utils';
|
2026-02-14 09:06:41 +01:00
|
|
|
import {
|
|
|
|
|
DialogModelInformation,
|
|
|
|
|
DropdownMenuSearchable,
|
2026-03-05 08:52:22 +01:00
|
|
|
ModelId,
|
2026-03-16 15:38:11 +01:00
|
|
|
ModelsSelectorList,
|
2026-03-05 08:52:22 +01:00
|
|
|
ModelsSelectorOption
|
2026-02-14 09:06:41 +01:00
|
|
|
} from '$lib/components/app';
|
2026-06-24 10:50:44 +02:00
|
|
|
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
|
2026-04-28 14:35:49 +03:00
|
|
|
import type { ModelItem } from './utils';
|
2025-12-01 19:41:04 +01:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
class?: string;
|
|
|
|
|
currentModel?: string | null;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
forceForegroundText?: boolean;
|
2026-03-05 08:52:22 +01:00
|
|
|
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
|
2025-12-01 19:41:04 +01:00
|
|
|
useGlobalSelection?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let {
|
|
|
|
|
class: className = '',
|
|
|
|
|
currentModel = null,
|
|
|
|
|
disabled = false,
|
|
|
|
|
forceForegroundText = false,
|
2026-03-05 08:52:22 +01:00
|
|
|
onModelChange,
|
2026-02-18 12:02:02 +01:00
|
|
|
useGlobalSelection = false
|
2025-12-01 19:41:04 +01:00
|
|
|
}: Props = $props();
|
|
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
let isOpen = $state(false);
|
2025-12-11 18:21:21 +01:00
|
|
|
let highlightedIndex = $state<number>(-1);
|
2025-12-01 19:41:04 +01:00
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
const ms = useModelsSelector({
|
|
|
|
|
currentModel: () => currentModel,
|
|
|
|
|
useGlobalSelection: () => useGlobalSelection,
|
|
|
|
|
onModelChange: () => onModelChange,
|
|
|
|
|
onOpenChange: (open) => {
|
|
|
|
|
isOpen = open;
|
|
|
|
|
highlightedIndex = -1;
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-11 18:21:21 +01:00
|
|
|
|
|
|
|
|
$effect(() => {
|
2026-04-28 14:35:49 +03:00
|
|
|
void ms.searchTerm;
|
2025-12-11 18:21:21 +01:00
|
|
|
highlightedIndex = -1;
|
2025-12-01 19:41:04 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export function open() {
|
2026-04-28 14:35:49 +03:00
|
|
|
ms.handleOpenChange(true);
|
2025-12-01 19:41:04 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-11 18:21:21 +01:00
|
|
|
function handleSearchKeyDown(event: KeyboardEvent) {
|
|
|
|
|
if (event.isComposing) return;
|
2025-12-01 19:41:04 +01:00
|
|
|
|
2026-02-14 09:06:41 +01:00
|
|
|
if (event.key === KeyboardKey.ARROW_DOWN) {
|
2025-12-11 18:21:21 +01:00
|
|
|
event.preventDefault();
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
if (ms.filteredOptions.length === 0) return;
|
2025-12-01 19:41:04 +01:00
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
|
2026-02-14 09:06:41 +01:00
|
|
|
highlightedIndex = 0;
|
2025-12-01 19:41:04 +01:00
|
|
|
} else {
|
2026-02-14 09:06:41 +01:00
|
|
|
highlightedIndex += 1;
|
2025-12-11 18:21:21 +01:00
|
|
|
}
|
2026-02-14 09:06:41 +01:00
|
|
|
} else if (event.key === KeyboardKey.ARROW_UP) {
|
2025-12-11 18:21:21 +01:00
|
|
|
event.preventDefault();
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
if (ms.filteredOptions.length === 0) return;
|
2025-12-11 18:21:21 +01:00
|
|
|
|
2026-02-14 09:06:41 +01:00
|
|
|
if (highlightedIndex === -1 || highlightedIndex === 0) {
|
2026-04-28 14:35:49 +03:00
|
|
|
highlightedIndex = ms.filteredOptions.length - 1;
|
2025-12-11 18:21:21 +01:00
|
|
|
} else {
|
2026-02-14 09:06:41 +01:00
|
|
|
highlightedIndex -= 1;
|
2025-12-11 18:21:21 +01:00
|
|
|
}
|
2026-02-14 09:06:41 +01:00
|
|
|
} else if (event.key === KeyboardKey.ENTER) {
|
2025-12-11 18:21:21 +01:00
|
|
|
event.preventDefault();
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
|
|
|
|
|
const option = ms.filteredOptions[highlightedIndex];
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
ms.handleSelect(option.id);
|
|
|
|
|
} else if (ms.filteredOptions.length > 0) {
|
2026-02-14 09:06:41 +01:00
|
|
|
highlightedIndex = 0;
|
2025-12-01 19:41:04 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
2026-05-01 18:36:29 +02:00
|
|
|
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
|
2026-04-28 14:35:49 +03:00
|
|
|
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
|
2025-12-01 19:41:04 +01:00
|
|
|
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
|
|
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2025-12-01 19:41:04 +01:00
|
|
|
Loading models…
|
|
|
|
|
</div>
|
2026-04-28 14:35:49 +03:00
|
|
|
{:else if ms.options.length === 0 && ms.isRouter}
|
2026-03-05 08:52:22 +01:00
|
|
|
{#if currentModel}
|
|
|
|
|
<span
|
2026-05-01 18:36:29 +02:00
|
|
|
class={[
|
2026-03-05 08:52:22 +01:00
|
|
|
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
|
|
|
|
|
className
|
2026-05-01 18:36:29 +02:00
|
|
|
]}
|
2026-04-28 14:35:49 +03:00
|
|
|
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
2026-03-05 08:52:22 +01:00
|
|
|
>
|
2026-06-02 10:23:19 +02:00
|
|
|
<Package class="h-3.5 w-3.5 shrink-0" />
|
2026-03-05 08:52:22 +01:00
|
|
|
</span>
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="text-xs text-muted-foreground">No models available.</p>
|
|
|
|
|
{/if}
|
2025-12-01 19:41:04 +01:00
|
|
|
{:else}
|
2026-04-28 14:35:49 +03:00
|
|
|
{@const selectedOption = ms.getDisplayOption()}
|
2026-06-24 10:50:44 +02:00
|
|
|
{@const triggerModel = selectedOption?.model}
|
|
|
|
|
{@const triggerStatus = triggerModel
|
|
|
|
|
? routerModels().find((m) => m.id === triggerModel)?.status?.value
|
|
|
|
|
: undefined}
|
|
|
|
|
{@const triggerLoading =
|
|
|
|
|
!!triggerModel &&
|
|
|
|
|
(triggerStatus === ServerModelStatus.LOADING ||
|
|
|
|
|
modelsStore.isModelOperationInProgress(triggerModel))}
|
|
|
|
|
{@const triggerLoadPercent = triggerLoading
|
|
|
|
|
? Math.round(modelLoadFraction(modelsStore.getLoadProgress(triggerModel)) * 100)
|
|
|
|
|
: 0}
|
2025-12-01 19:41:04 +01:00
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
{#if ms.isRouter}
|
|
|
|
|
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
|
2026-06-04 08:59:00 -07:00
|
|
|
<Tooltip.Root>
|
|
|
|
|
<Tooltip.Trigger>
|
|
|
|
|
<!-- prevent another nested button element -->
|
|
|
|
|
{#snippet child({ props })}
|
|
|
|
|
<DropdownMenu.Trigger
|
|
|
|
|
{...props}
|
|
|
|
|
class={[
|
2026-06-24 10:50:44 +02:00
|
|
|
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
2026-06-04 08:59:00 -07:00
|
|
|
!ms.isCurrentModelInCache
|
|
|
|
|
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
|
|
|
|
: forceForegroundText
|
|
|
|
|
? 'text-foreground'
|
|
|
|
|
: ms.isHighlightedCurrentModelActive
|
|
|
|
|
? 'text-foreground'
|
|
|
|
|
: 'text-foreground',
|
|
|
|
|
isOpen && 'text-foreground',
|
|
|
|
|
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
|
|
|
|
|
]}
|
|
|
|
|
disabled={disabled || ms.updating}
|
|
|
|
|
>
|
|
|
|
|
<Package class="h-3.5 w-3.5 shrink-0" />
|
2025-12-16 01:15:49 -05:00
|
|
|
|
2026-06-04 08:59:00 -07:00
|
|
|
{#if selectedOption}
|
2026-03-28 17:57:59 +01:00
|
|
|
<ModelId
|
|
|
|
|
modelId={selectedOption.model}
|
|
|
|
|
class="min-w-0 overflow-hidden"
|
2026-04-28 14:35:49 +03:00
|
|
|
hideOrgName={false}
|
2026-06-02 10:23:19 +02:00
|
|
|
hideQuantization
|
2026-03-28 17:57:59 +01:00
|
|
|
/>
|
2026-06-04 08:59:00 -07:00
|
|
|
{:else}
|
|
|
|
|
<span class="min-w-0 font-medium">Select model</span>
|
|
|
|
|
{/if}
|
2025-12-16 01:15:49 -05:00
|
|
|
|
2026-06-04 08:59:00 -07:00
|
|
|
{#if ms.updating || ms.isLoadingModel}
|
|
|
|
|
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
|
|
|
|
{:else}
|
|
|
|
|
<ChevronDown class="h-3 w-3.5 shrink-0" />
|
|
|
|
|
{/if}
|
2026-06-24 10:50:44 +02:00
|
|
|
|
|
|
|
|
{#if triggerLoading}
|
|
|
|
|
<ModelLoadHighlight percent={triggerLoadPercent} />
|
|
|
|
|
{/if}
|
2026-06-04 08:59:00 -07:00
|
|
|
</DropdownMenu.Trigger>
|
|
|
|
|
{/snippet}
|
|
|
|
|
</Tooltip.Trigger>
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2026-06-04 08:59:00 -07:00
|
|
|
{#if selectedOption}
|
|
|
|
|
<Tooltip.Content>
|
|
|
|
|
<p class="font-mono">{selectedOption.model}</p>
|
|
|
|
|
</Tooltip.Content>
|
2026-03-28 17:57:59 +01:00
|
|
|
{/if}
|
2026-06-04 08:59:00 -07:00
|
|
|
</Tooltip.Root>
|
2025-12-16 01:15:49 -05:00
|
|
|
|
2026-02-14 09:06:41 +01:00
|
|
|
<DropdownMenu.Content
|
2025-12-16 01:15:49 -05:00
|
|
|
align="end"
|
2026-02-14 09:06:41 +01:00
|
|
|
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
2025-12-16 01:15:49 -05:00
|
|
|
>
|
2026-02-14 09:06:41 +01:00
|
|
|
<DropdownMenuSearchable
|
2026-04-28 14:35:49 +03:00
|
|
|
searchValue={ms.searchTerm}
|
|
|
|
|
onSearchChange={(v) => ms.setSearchTerm(v)}
|
2026-02-14 09:06:41 +01:00
|
|
|
placeholder="Search models..."
|
|
|
|
|
onSearchKeyDown={handleSearchKeyDown}
|
|
|
|
|
emptyMessage="No models found."
|
2026-04-28 14:35:49 +03:00
|
|
|
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
|
2026-02-14 09:06:41 +01:00
|
|
|
>
|
|
|
|
|
<div class="models-list">
|
2026-04-28 14:35:49 +03:00
|
|
|
{#if !ms.isCurrentModelInCache && currentModel}
|
2025-12-16 01:15:49 -05:00
|
|
|
<!-- Show unavailable model as first option (disabled) -->
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-02-14 09:06:41 +01:00
|
|
|
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
|
2025-12-16 01:15:49 -05:00
|
|
|
role="option"
|
|
|
|
|
aria-selected="true"
|
|
|
|
|
aria-disabled="true"
|
|
|
|
|
disabled
|
|
|
|
|
>
|
2026-04-28 14:35:49 +03:00
|
|
|
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2025-12-16 01:15:49 -05:00
|
|
|
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
{#if ms.filteredOptions.length === 0}
|
2025-12-16 01:15:49 -05:00
|
|
|
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
|
|
|
|
|
{/if}
|
|
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
2026-03-16 15:38:11 +01:00
|
|
|
{@const { option, flatIndex } = item}
|
2026-04-28 14:35:49 +03:00
|
|
|
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
2026-03-16 15:38:11 +01:00
|
|
|
{@const isHighlighted = flatIndex === highlightedIndex}
|
2026-04-28 14:35:49 +03:00
|
|
|
{@const isFav = ms.isFavorite(option.model)}
|
2025-12-16 01:15:49 -05:00
|
|
|
|
2026-03-16 15:38:11 +01:00
|
|
|
<ModelsSelectorOption
|
|
|
|
|
{option}
|
|
|
|
|
{isSelected}
|
|
|
|
|
{isHighlighted}
|
|
|
|
|
{isFav}
|
2026-04-28 14:35:49 +03:00
|
|
|
{hideOrgName}
|
|
|
|
|
onSelect={ms.handleSelect}
|
|
|
|
|
onInfoClick={ms.handleInfoClick}
|
2026-03-16 15:38:11 +01:00
|
|
|
onMouseEnter={() => (highlightedIndex = flatIndex)}
|
2026-05-01 18:36:29 +02:00
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
|
|
|
|
event.preventDefault();
|
2026-04-28 14:35:49 +03:00
|
|
|
ms.handleSelect(option.id);
|
2026-03-16 15:38:11 +01:00
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
{/snippet}
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2026-03-16 15:38:11 +01:00
|
|
|
<ModelsSelectorList
|
2026-04-28 14:35:49 +03:00
|
|
|
groups={ms.groupedFilteredOptions}
|
2026-03-16 15:38:11 +01:00
|
|
|
{currentModel}
|
2026-04-28 14:35:49 +03:00
|
|
|
activeId={ms.activeId}
|
2026-03-16 15:38:11 +01:00
|
|
|
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
|
2026-04-28 14:35:49 +03:00
|
|
|
onSelect={ms.handleSelect}
|
|
|
|
|
onInfoClick={ms.handleInfoClick}
|
2026-03-16 15:38:11 +01:00
|
|
|
renderOption={modelOption}
|
|
|
|
|
/>
|
2025-12-16 01:15:49 -05:00
|
|
|
</div>
|
2026-02-14 09:06:41 +01:00
|
|
|
</DropdownMenuSearchable>
|
|
|
|
|
</DropdownMenu.Content>
|
|
|
|
|
</DropdownMenu.Root>
|
2025-12-16 01:15:49 -05:00
|
|
|
{:else}
|
2026-06-04 08:59:00 -07:00
|
|
|
<Tooltip.Root>
|
|
|
|
|
<Tooltip.Trigger>
|
|
|
|
|
<!-- prevent another nested button element -->
|
|
|
|
|
{#snippet child({ props })}
|
|
|
|
|
<button
|
|
|
|
|
{...props}
|
|
|
|
|
class={[
|
|
|
|
|
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
|
|
|
|
|
!ms.isCurrentModelInCache
|
|
|
|
|
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
|
|
|
|
|
: forceForegroundText
|
|
|
|
|
? 'text-foreground'
|
|
|
|
|
: ms.isHighlightedCurrentModelActive
|
|
|
|
|
? 'text-foreground'
|
|
|
|
|
: 'text-foreground',
|
|
|
|
|
isOpen && 'text-foreground'
|
|
|
|
|
]}
|
|
|
|
|
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
|
|
|
|
|
onclick={() => ms.handleOpenChange(true)}
|
|
|
|
|
disabled={disabled || ms.updating}
|
|
|
|
|
>
|
|
|
|
|
<Package class="h-3.5 w-3.5 shrink-0" />
|
2025-12-01 19:41:04 +01:00
|
|
|
|
2026-06-04 08:59:00 -07:00
|
|
|
{#if selectedOption}
|
2026-03-28 17:57:59 +01:00
|
|
|
<ModelId
|
|
|
|
|
modelId={selectedOption.model}
|
|
|
|
|
class="min-w-0 overflow-hidden"
|
2026-04-28 14:35:49 +03:00
|
|
|
hideOrgName={false}
|
2026-06-02 10:23:19 +02:00
|
|
|
hideQuantization
|
2026-03-28 17:57:59 +01:00
|
|
|
/>
|
2026-06-04 08:59:00 -07:00
|
|
|
{/if}
|
2026-03-05 08:52:22 +01:00
|
|
|
|
2026-06-04 08:59:00 -07:00
|
|
|
{#if ms.updating}
|
|
|
|
|
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
{/snippet}
|
|
|
|
|
</Tooltip.Trigger>
|
2025-12-01 19:41:04 +01:00
|
|
|
|
2026-06-04 08:59:00 -07:00
|
|
|
{#if selectedOption}
|
|
|
|
|
<Tooltip.Content>
|
|
|
|
|
<p class="font-mono">{selectedOption.model}</p>
|
|
|
|
|
</Tooltip.Content>
|
2025-12-01 19:41:04 +01:00
|
|
|
{/if}
|
2026-06-04 08:59:00 -07:00
|
|
|
</Tooltip.Root>
|
2025-12-16 01:15:49 -05:00
|
|
|
{/if}
|
2025-12-01 19:41:04 +01:00
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-04-28 14:35:49 +03:00
|
|
|
{#if ms.showModelDialog}
|
|
|
|
|
<DialogModelInformation
|
|
|
|
|
open={ms.showModelDialog}
|
|
|
|
|
onOpenChange={(v) => ms.setShowModelDialog(v)}
|
|
|
|
|
modelId={ms.infoModelId}
|
|
|
|
|
/>
|
2025-12-01 19:41:04 +01:00
|
|
|
{/if}
|