mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
a04b8fd564
- Rename `llm_providers` → `providers`, `llm_provider_oauth_tokens` → `provider_oauth_tokens` - Remove `tts_providers` and `tts_models` tables; speech models now live in the unified `models` table with `type = 'speech'` - Replace top-level `api_key`/`base_url` columns with a JSONB `config` field on `providers` - Rename `llm_provider_id` → `provider_id` across all references - Add `edge-speech` client type and `conf/providers/edge.yaml` default provider - Create new read-only speech endpoints (`/speech-providers`, `/speech-models`) backed by filtered views of the unified tables - Remove old TTS CRUD handlers; simplify speech page to read-only + test - Update registry loader to skip malformed YAML files instead of failing entirely - Fix YAML quoting for model names containing colons in openrouter.yaml - Regenerate sqlc, swagger, and TypeScript SDK
163 lines
4.6 KiB
Vue
163 lines
4.6 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"
|
|
: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 flex items-center gap-1.5">
|
|
<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"
|
|
>
|
|
{{ 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'
|
|
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.models.filter((m) => m.type === props.modelType),
|
|
)
|
|
|
|
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>
|