mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
ddda00f980
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.
164 lines
4.7 KiB
Vue
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>
|