7a944afdb5
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.
309 lines
13 KiB
Vue
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>
|