Files
Memoh/apps/web/src/pages/bots/components/model-options.vue
T
Acbox ddda00f980 feat(models): add image model type support
Add a dedicated image model type so bots can use image API models without overloading chat model capabilities, while keeping existing chat-based image generation selectable.
2026-04-16 16:00:22 +08:00

164 lines
4.7 KiB
Vue

<template>
<div class="flex items-center border-b px-3">
<Search
class="mr-2 size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="searchTerm"
:placeholder="$t('bots.settings.searchModel')"
aria-label="Search models"
class="flex h-10 w-full bg-transparent py-3 text-xs outline-none placeholder:text-muted-foreground"
>
</div>
<div
class="max-h-64 overflow-y-auto"
role="listbox"
>
<div
v-if="filteredGroups.length === 0"
class="py-6 text-center text-xs text-muted-foreground"
>
{{ $t('bots.settings.noModel') }}
</div>
<div
v-for="group in filteredGroups"
:key="group.key"
class="p-1"
>
<div
v-if="group.label"
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>
{{ group.label }}
</div>
<button
v-for="option in group.items"
:key="option.value"
type="button"
role="option"
:aria-selected="modelValue === option.value"
class="relative flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none hover:bg-accent hover:text-accent-foreground [&_+button]:mt-1"
:class="{ 'bg-accent': modelValue === option.value }"
@click="$emit('update:modelValue', option.value)"
>
<Check
v-if="modelValue === option.value"
class="size-3.5 shrink-0"
/>
<span
v-else
class="size-3.5 shrink-0"
/>
<span class="truncate ">{{ option.label }}</span>
<span class="ml-auto min-w-[50%] flex items-center gap-1.5 text-right">
<ModelCapabilities
v-if="option.compatibilities?.length"
:compatibilities="option.compatibilities"
/>
<ContextWindowBadge :context-window="option.contextWindow" />
<span
v-if="option.description"
class="text-xs text-muted-foreground truncate "
>
{{ option.description }}
</span>
</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Search, Check } from 'lucide-vue-next'
import type { ModelsGetResponse, ProvidersGetResponse } from '@memohai/sdk'
import ModelCapabilities from '@/components/model-capabilities/index.vue'
import ContextWindowBadge from '@/components/context-window-badge/index.vue'
export interface ModelOption {
value: string
label: string
description?: string
groupKey: string
groupLabel: string
keywords: string[]
compatibilities?: string[]
contextWindow?: number
}
const props = defineProps<{
models: ModelsGetResponse[]
providers: ProvidersGetResponse[]
modelType?: 'chat' | 'embedding' | 'image'
open?: boolean
}>()
defineEmits<{
'update:modelValue': [value: string]
}>()
const modelValue = defineModel<string>({ default: '' })
const searchTerm = ref('')
watch(() => props.open, (v) => {
if (v) searchTerm.value = ''
})
const providerMap = computed(() => {
const map = new Map<string, string>()
for (const p of props.providers) {
if (p.id) map.set(p.id, p.name ?? p.id)
}
return map
})
const typeFilteredModels = computed(() =>
props.modelType ? props.models.filter((m) => m.type === props.modelType) : props.models,
)
const options = computed<ModelOption[]>(() =>
typeFilteredModels.value.map((model) => {
const providerId = model.provider_id ?? ''
const config = model.config as { compatibilities?: string[]; context_window?: number } | undefined
return {
value: model.id || model.model_id || '',
label: model.name || model.model_id || '',
description: model.name ? model.model_id : undefined,
groupKey: providerId,
groupLabel: providerMap.value.get(providerId) ?? providerId,
keywords: [model.model_id ?? '', model.name ?? ''],
compatibilities: config?.compatibilities,
contextWindow: config?.context_window,
}
}),
)
const filteredOptions = computed(() => {
const keyword = searchTerm.value.trim().toLowerCase()
if (!keyword) return options.value
return options.value.filter((opt) => {
const terms = [opt.label, opt.description, ...opt.keywords]
.filter((t): t is string => Boolean(t))
.join(' ')
.toLowerCase()
return terms.includes(keyword)
})
})
const filteredGroups = computed(() => {
const groups = new Map<string, { key: string; label: string; items: ModelOption[] }>()
for (const opt of filteredOptions.value) {
if (!groups.has(opt.groupKey)) {
groups.set(opt.groupKey, { key: opt.groupKey, label: opt.groupLabel, items: [] })
}
groups.get(opt.groupKey)!.items.push(opt)
}
return Array.from(groups.values())
})
</script>