Files
Memoh/apps/web/src/pages/speech/components/provider-setting.vue
T
Acbox a04b8fd564 refactor: unify providers and models tables
- 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
2026-04-07 00:26:06 +08:00

187 lines
5.7 KiB
Vue

<template>
<div class="p-4">
<section class="flex items-center gap-3">
<Volume2
class="size-5"
/>
<div class="min-w-0">
<h2 class="text-sm font-semibold truncate">
{{ curProvider?.name }}
</h2>
<p class="text-xs text-muted-foreground">
{{ currentMeta?.display_name ?? curProvider?.client_type }}
</p>
</div>
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-muted-foreground">
{{ $t('common.enable') }}
</span>
<Switch
:model-value="curProvider?.enable ?? false"
:disabled="!curProvider?.id || enableLoading"
@update:model-value="handleToggleEnable"
/>
</div>
</section>
<Separator class="mt-4 mb-6" />
<!-- Models -->
<section>
<div class="flex justify-between items-center mb-4">
<h3 class="text-xs font-medium">
{{ $t('speech.models') }}
</h3>
</div>
<div
v-if="providerModels.length === 0"
class="text-xs text-muted-foreground py-4 text-center"
>
{{ $t('speech.noModels') }}
</div>
<div
v-for="model in providerModels"
:key="model.id"
class="border border-border rounded-lg mb-4"
>
<button
type="button"
class="w-full flex items-center justify-between p-3 text-left hover:bg-accent/50 rounded-t-lg transition-colors"
@click="toggleModel(model.id ?? '')"
>
<div>
<span class="text-xs font-medium">{{ model.name || model.model_id }}</span>
<span
v-if="model.name"
class="text-xs text-muted-foreground ml-2"
>{{ model.model_id }}</span>
</div>
<component
:is="expandedModelId === model.id ? ChevronUp : ChevronDown"
class="size-3 text-muted-foreground"
/>
</button>
<div
v-if="expandedModelId === model.id"
class="px-3 pb-3 space-y-4 border-t border-border pt-3"
>
<ModelConfigEditor
:model-id="model.id ?? ''"
:model-name="model.model_id ?? ''"
:config="model.config || {}"
:capabilities="getModelCapabilities(model.model_id ?? '')"
@test="(text, cfg) => handleTestModel(model.id ?? '', text, cfg)"
/>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import {
Separator,
Switch,
} from '@memohai/ui'
import ModelConfigEditor from './model-config-editor.vue'
import { Volume2, ChevronUp, ChevronDown } from 'lucide-vue-next'
import { computed, inject, ref } from 'vue'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import { useQuery, useQueryCache } from '@pinia/colada'
import { getSpeechProvidersMeta, getSpeechModels, putProvidersById } from '@memohai/sdk'
import type { TtsSpeechProviderResponse, TtsProviderMetaResponse, TtsModelInfo } from '@memohai/sdk'
const { t } = useI18n()
const curProvider = inject('curTtsProvider', ref<TtsSpeechProviderResponse>())
const curProviderId = computed(() => curProvider.value?.id)
const enableLoading = ref(false)
const { data: metaList } = useQuery({
key: () => ['speech-providers-meta'],
query: async () => {
const { data } = await getSpeechProvidersMeta({ throwOnError: true })
return data
},
})
const currentMeta = computed<TtsProviderMetaResponse | null>(() => {
if (!metaList.value || !curProvider.value?.client_type) return null
return (metaList.value as TtsProviderMetaResponse[]).find((m) => m.provider === curProvider.value?.client_type) ?? null
})
function getModelCapabilities(modelId: string) {
const meta = currentMeta.value
if (!meta?.models) return null
return meta.models.find((m: TtsModelInfo) => m.id === modelId)?.capabilities ?? null
}
const { data: allSpeechModels } = useQuery({
key: () => ['speech-models'],
query: async () => {
const { data } = await getSpeechModels({ throwOnError: true })
return data
},
})
const providerModels = computed(() => {
if (!allSpeechModels.value || !curProviderId.value) return []
return allSpeechModels.value.filter((m) => m.provider_id === curProviderId.value)
})
const expandedModelId = ref('')
function toggleModel(id: string) {
expandedModelId.value = expandedModelId.value === id ? '' : id
}
const queryCache = useQueryCache()
async function handleToggleEnable(value: boolean) {
if (!curProviderId.value || !curProvider.value) return
const prev = curProvider.value.enable ?? false
curProvider.value = { ...curProvider.value, enable: value }
enableLoading.value = true
try {
await putProvidersById({
path: { id: curProviderId.value },
body: { enable: value },
throwOnError: true,
})
queryCache.invalidateQueries({ key: ['speech-providers'] })
} catch {
curProvider.value = { ...curProvider.value, enable: prev }
toast.error(t('common.saveFailed'))
} finally {
enableLoading.value = false
}
}
async function handleTestModel(modelId: string, text: string, config: Record<string, unknown>) {
const apiBase = import.meta.env.VITE_API_URL?.trim() || '/api'
const token = localStorage.getItem('token')
const resp = await fetch(`${apiBase}/speech-models/${modelId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ text, config }),
})
if (!resp.ok) {
const errBody = await resp.text()
let msg: string
try {
msg = JSON.parse(errBody)?.message ?? errBody
} catch {
msg = errBody
}
throw new Error(msg)
}
return resp.blob()
}
</script>