Files
veridian/app/components/ModelSelector.vue
T
zoeissleeping 7a944afdb5 style: UI polish and miscellaneous fixes
Fixes link color from hardcoded white to currentColor for proper theme
support. Adds video and audio modality icons to ModelInfo. Registers
anthropic, nvidia, and xiaomi in the provider icon map. Removes unused
pagination validation from agents and topics endpoints. Adds convenience
start script. Updates AGENTS.md.
2026-04-27 12:07:01 -05:00

309 lines
13 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
import type { DialogType } from '~/composables/useDialog';
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
import { sortByReleaseDate } from '~/utils/sort';
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
const { openDialog } = useDialog();
const { allModels } = await useModels();
const { addShortcut } = useKeyboardShortcuts();
const props = defineProps<{
providers: ProviderWithModels[];
addHotkey?: boolean;
}>();
const selectedModel = defineModel<ModelWithProvider | null>();
const isOpen = ref(false);
const dropdownButton = ref<HTMLElement | null>(null);
const dropdownContent = ref<HTMLElement | null>(null);
const searchInputRef = ref<HTMLInputElement | null>(null);
const scrollContainerRef = ref<HTMLDivElement | null>(null);
const virtualizerRef = ref<InstanceType<typeof RowVirtualizerFixed> | null>(null);
const navigatingWithKeyboard = ref(false);
const focusedOptionId = ref<string | null>(null);
const { floatingStyles, placement, middlewareData } = useFloating(dropdownButton, dropdownContent, {
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset(6),
flip(),
shift({ padding: 10 }),
size({
apply({ availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxHeight: `${Math.min(availableHeight - 20, 460)}px`,
});
},
padding: 10,
}),
hide()
],
transform: false,
});
const transformOrigin = computed(() =>
placement.value.startsWith('top')
? 'transform-origin-bottom-center'
: 'transform-origin-top-center'
);
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const searchQuery = ref('');
const filteredProviders = computed(() => {
return filterProvidersWithModel(props.providers, searchQuery.value).filter(p => p.enabled);
});
const flatOptions = computed(() => {
type FlatItem =
| { type: 'header'; id: string; provider: Provider }
| { type: 'model'; id: string; model: Model; provider: Provider };
const options: FlatItem[] = [];
for (const provider of filteredProviders.value) {
const enabledModels = provider.models.filter(m => m.enabled).sort(sortByReleaseDate);
if (enabledModels.length > 0) {
options.push({ type: 'header', id: `header-${provider.id}`, provider });
for (const model of enabledModels) {
options.push({ type: 'model', id: model.id, model, provider });
}
}
}
return options;
});
const modelOnlyOptions = computed(() =>
flatOptions.value.filter(o => o.type === 'model')
);
const setFocusToModel = (index: number) => {
if (modelOnlyOptions.value.length === 0) return;
navigatingWithKeyboard.value = true;
const clampedIndex = Math.max(0, Math.min(index, modelOnlyOptions.value.length - 1));
focusedOptionId.value = modelOnlyOptions.value[clampedIndex]!.id;
scrollFocusedIntoView();
};
const scrollFocusedIntoView = () => {
if (!focusedOptionId.value) return;
const index = flatOptions.value.findIndex(o => o.id === focusedOptionId.value);
if (index !== -1) {
virtualizerRef.value?.scrollToIndex(index, { align: 'start' });
}
};
const selectModel = (model: Model, provider: Provider) => {
selectedModel.value = { ...model, provider };
closeDropdown();
};
const handleSelectFromFocused = () => {
const option = flatOptions.value.find(o => o.id === focusedOptionId.value);
if (option && option.type === 'model') {
selectModel(option.model, option.provider);
} else if (modelOnlyOptions.value.length > 0) {
selectModel(modelOnlyOptions.value[0]!.model, modelOnlyOptions.value[0]!.provider);
}
};
watch(() => props.providers, () => {
if (selectedModel.value) {
const modelStillExists = allModels.value.some(model => model.id === selectedModel.value?.id);
if (!modelStillExists) {
let firstEnabled: ModelWithProvider | null = null;
for (const provider of props.providers) {
const enabledModel = provider.models.find(m => m.enabled);
if (enabledModel) {
firstEnabled = { ...enabledModel, provider };
break;
}
}
selectedModel.value = firstEnabled;
}
}
});
watch(isOpen, (open) => {
if (open) {
nextTick(() => {
searchInputRef.value?.focus();
});
} else {
focusedOptionId.value = null;
searchQuery.value = '';
}
});
const closeDropdown = () => {
isOpen.value = false;
navigatingWithKeyboard.value = false;
};
const handleSearchKeyDown = (event: KeyboardEvent) => {
if (modelOnlyOptions.value.length === 0) return;
const currentModelIndex = focusedOptionId.value
? modelOnlyOptions.value.findIndex(o => o.id === focusedOptionId.value)
: -1;
switch (event.key) {
case 'Escape':
closeDropdown();
event.preventDefault();
break;
case 'ArrowDown':
event.preventDefault();
if (currentModelIndex === -1 || currentModelIndex === modelOnlyOptions.value.length - 1) {
setFocusToModel(0);
} else {
setFocusToModel(currentModelIndex + 1);
}
break;
case 'ArrowUp':
event.preventDefault();
if (currentModelIndex === -1 || currentModelIndex === 0) {
setFocusToModel(modelOnlyOptions.value.length - 1);
} else {
setFocusToModel(currentModelIndex - 1);
}
break;
case 'Home':
event.preventDefault();
setFocusToModel(0);
break;
case 'End':
event.preventDefault();
setFocusToModel(modelOnlyOptions.value.length - 1);
break;
case 'Enter':
event.preventDefault();
handleSelectFromFocused();
break;
}
};
let unbindShortcut: () => void = () => { };
if (props.addHotkey) {
unbindShortcut = addShortcut(['ctrl', 'shift', 'm'], (event) => {
event.preventDefault();
event.stopPropagation();
toggleDropdown();
});
}
onUnmounted(() => {
unbindShortcut();
});
</script>
<template>
<div role="button" @click="toggleDropdown()" ref="dropdownButton"
class="cursor-pointer flex items-center w-fit min-w-0 select-none gap-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-200"
:class="[
isOpen
? 'bg-[var(--color-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)]',
]">
<ModelIcon v-if="selectedModel" class="text-white" :avatar="true" variant="color"
:model-id="selectedModel.externalId" size="22" />
<span class="max-w-full truncate">
{{ selectedModel ? selectedModel.name : 'Select a model' }}
</span>
<span class="i-mynaui-chevron-down text-3.5 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"></span>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
enter-from-class="opacity-0 scale-95 translate-y-1" enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-[opacity,transform] duration-100 ease-[cubic-bezier(0.5,_1,_0.89,_1)]"
leave-from-class="opacity-100 scale-100 translate-y-0" leave-to-class="opacity-0 scale-95 translate-y-1">
<KeepAlive>
<div v-if="isOpen" v-click-outside="closeDropdown" ref="dropdownContent" role="listbox"
aria-label="Select model"
:aria-activedescendant="focusedOptionId ? `model-option-${focusedOptionId}` : undefined" :style="{
...floatingStyles,
visibility: middlewareData.hide?.referenceHidden
? 'hidden'
: 'visible',
}"
class="fixed z-100 w-full max-w-[420px] min-w-[280px] flex flex-col rounded-xl border border-[var(--color-border)] bg-[var(--bg-surface)] shadow-lg overflow-hidden"
:class="transformOrigin">
<div class="relative">
<span
class="i-mynaui-search absolute left-3 top-1/2 -translate-y-1/2 text-4 text-[var(--text-secondary)]"></span>
<input ref="searchInputRef" v-model="searchQuery" autocomplete="off" name="search"
@keydown="handleSearchKeyDown" type="text" placeholder="Search models..."
class="placeholder:text-[var(--text-tertiary)] w-full pl-9 pr-3 py-2 text-sm text-[var(--text-primary)] bg-transparent placeholder-[var(--text-secondary)] outline-none" />
</div>
<div ref="scrollContainerRef"
class="flex-1 overflow-y-auto [scrollbar-width:thin] py-2 select-none">
<div v-if="filteredProviders.length === 0"
class="px-4 py-8 text-center text-sm text-[var(--text-secondary)]">
No models found
</div>
<template v-else>
<RowVirtualizerFixed ref="virtualizerRef" :items="flatOptions" key-field="id"
:scroll-element="scrollContainerRef" :item-size="42" :overscan="10">
<template v-slot="{ item }">
<template v-if="item.type === 'header'">
<div
class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
{{ item.provider.name }}
<button
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })"
class="flex h-5 w-5 items-center justify-center @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
<span class="i-mynaui-cog-four text-4"></span>
</button>
</div>
</template>
<template v-else>
<button :id="`model-option-${item.model.id}`" role="option"
:aria-selected="focusedOptionId === item.model.id"
@click="selectModel(item.model, item.provider)"
class="text-white w-full h-10.5 px-4 py-2 flex items-center justify-between @hover:bg-[var(--color-hover)] transition-colors duration-150"
:class="{
'bg-[var(--color-hover)]': selectedModel?.id === item.model.id,
'ring-2 ring-inset ring-[var(--color-accent)]': focusedOptionId === item.model.id && navigatingWithKeyboard
}">
<ModelInfo :model="item.model" size="medium" />
</button>
</template>
</template>
</RowVirtualizerFixed>
</template>
</div>
<div class="p-1 border-t border-[var(--color-border)]">
<button
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers' });">
<span class="i-mynaui-cog-four text-4.5"></span>
<span>Manage Providers</span>
<span class="i-mynaui-arrow-right text-4 ml-auto"></span>
</button>
</div>
</div>
</KeepAlive>
</Transition>
</Teleport>
</template>