feat: enhance speech provider functionality with advanced settings and model import capabilities

This commit is contained in:
aki
2026-04-19 21:05:26 +09:00
parent 30ab6b4199
commit 460a0307f3
21 changed files with 1208 additions and 77 deletions
+4
View File
@@ -425,6 +425,10 @@
"noModels": "No models found. Click \"Import Models\" to discover available models or \"Add Model\" to create one manually.",
"noCapabilities": "No capabilities available for this model.",
"saveSuccess": "Speech configuration saved",
"advanced": {
"title": "Advanced Settings",
"description": "These fields usually map to underlying vendor implementation details. Most users can keep the defaults."
},
"fields": {
"language": "Language",
"languagePlaceholder": "Select language...",
+4
View File
@@ -421,6 +421,10 @@
"noModels": "暂无模型,点击\"导入模型\"发现可用模型,或点击\"新建模型\"手动创建。",
"noCapabilities": "该模型暂无可用能力信息。",
"saveSuccess": "语音配置已保存",
"advanced": {
"title": "高级设置",
"description": "这些字段通常对应底层服务商实现细节。大多数情况下保留默认值即可。"
},
"fields": {
"language": "语言",
"languagePlaceholder": "选择语言...",
@@ -1,8 +1,8 @@
<template>
<div class="space-y-4">
<template v-if="orderedFields.length > 0">
<template v-if="basicFields.length > 0">
<section
v-for="field in orderedFields"
v-for="field in basicFields"
:key="field.key"
class="space-y-2"
>
@@ -82,12 +82,115 @@
</template>
<div
v-else
v-else-if="advancedFields.length === 0"
class="text-xs text-muted-foreground"
>
{{ $t('speech.noCapabilities') }}
</div>
<div
v-if="advancedFields.length > 0"
class="rounded-lg border border-border"
>
<button
type="button"
class="flex w-full items-center justify-between px-3 py-2 text-left text-xs font-medium"
@click="showAdvanced = !showAdvanced"
>
<span>{{ $t('speech.advanced.title') }}</span>
<component
:is="showAdvanced ? ChevronUp : ChevronDown"
class="size-3 text-muted-foreground"
/>
</button>
<div
v-if="showAdvanced"
class="space-y-4 border-t border-border px-3 py-3"
>
<p class="text-xs text-muted-foreground">
{{ $t('speech.advanced.description') }}
</p>
<section
v-for="field in advancedFields"
:key="field.key"
class="space-y-2"
>
<Label :for="field.type === 'bool' || field.type === 'enum' ? undefined : `tts-field-${field.key}`">
{{ field.title || field.key }}
</Label>
<p
v-if="field.description"
class="text-xs text-muted-foreground"
>
{{ field.description }}
</p>
<div
v-if="field.type === 'secret'"
class="relative"
>
<Input
:id="`tts-field-${field.key}`"
v-model="configData[field.key] as string"
:type="visibleSecrets[field.key] ? 'text' : 'password'"
:placeholder="field.example ? String(field.example) : ''"
/>
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
@click="visibleSecrets[field.key] = !visibleSecrets[field.key]"
>
<component
:is="visibleSecrets[field.key] ? EyeOff : Eye"
class="size-3.5"
/>
</button>
</div>
<Switch
v-else-if="field.type === 'bool'"
:model-value="!!configData[field.key]"
@update:model-value="(val) => configData[field.key] = !!val"
/>
<Input
v-else-if="field.type === 'number'"
:id="`tts-field-${field.key}`"
v-model.number="configData[field.key] as number"
type="number"
:placeholder="field.example ? String(field.example) : ''"
/>
<Select
v-else-if="field.type === 'enum' && field.enum"
:model-value="String(configData[field.key] ?? '')"
@update:model-value="(val) => configData[field.key] = val"
>
<SelectTrigger>
<SelectValue :placeholder="field.title || field.key" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in field.enum"
:key="opt"
:value="opt"
>
{{ opt }}
</SelectItem>
</SelectContent>
</Select>
<Input
v-else
:id="`tts-field-${field.key}`"
v-model="configData[field.key] as string"
type="text"
:placeholder="field.example ? String(field.example) : ''"
/>
</section>
</div>
</div>
<Separator class="my-3" />
<div class="space-y-3">
@@ -166,7 +269,7 @@ import {
Switch,
Textarea,
} from '@memohai/ui'
import { Eye, EyeOff, Play } from 'lucide-vue-next'
import { ChevronDown, ChevronUp, Eye, EyeOff, Play } from 'lucide-vue-next'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
@@ -178,6 +281,7 @@ interface SpeechFieldSchema {
title?: string
description?: string
required?: boolean
advanced?: boolean
enum?: string[]
example?: unknown
order?: number
@@ -203,6 +307,7 @@ const { t } = useI18n()
const configData = reactive<Record<string, unknown>>({})
const visibleSecrets = reactive<Record<string, boolean>>({})
const saving = ref(false)
const showAdvanced = ref(false)
const testText = ref('')
const testLoading = ref(false)
const testError = ref('')
@@ -215,9 +320,16 @@ const orderedFields = computed(() => {
return [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
})
const basicFields = computed(() => orderedFields.value.filter(field => !field.advanced))
const advancedFields = computed(() => orderedFields.value.filter(field => field.advanced))
watch(() => props.config, (cfg) => {
Object.keys(configData).forEach((key) => delete configData[key])
Object.assign(configData, { ...(cfg ?? {}) })
showAdvanced.value = advancedFields.value.some(field => {
const value = cfg?.[field.key]
return value !== '' && value != null
})
}, { immediate: true, deep: true })
function buildConfig(): Record<string, unknown> {
@@ -140,6 +140,16 @@
<h3 class="text-xs font-medium">
{{ $t('speech.models') }}
</h3>
<LoadingButton
v-if="curProviderId"
type="button"
variant="outline"
size="sm"
:loading="importLoading"
@click="handleImportModels"
>
{{ $t('speech.importModels') }}
</LoadingButton>
</div>
<div
@@ -208,7 +218,7 @@ import { computed, inject, reactive, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import { useQuery, useQueryCache } from '@pinia/colada'
import { getSpeechModels, getSpeechProvidersMeta, putModelsById, putProvidersById } from '@memohai/sdk'
import { getSpeechProvidersById, getSpeechProvidersByIdModels, getSpeechProvidersMeta, postSpeechProvidersByIdImportModels, putModelsById, putProvidersById } from '@memohai/sdk'
import type { TtsSpeechModelResponse, TtsSpeechProviderResponse } from '@memohai/sdk'
import LoadingButton from '@/components/loading-button/index.vue'
import ProviderIcon from '@/components/provider-icon/index.vue'
@@ -219,6 +229,7 @@ interface SpeechFieldSchema {
title?: string
description?: string
required?: boolean
advanced?: boolean
enum?: string[]
example?: unknown
order?: number
@@ -243,6 +254,7 @@ interface SpeechProviderMeta {
display_name: string
description?: string
config_schema?: SpeechConfigSchema
default_model?: string
models?: SpeechModelMeta[]
}
@@ -260,8 +272,21 @@ const visibleSecrets = reactive<Record<string, boolean>>({})
const expandedModelId = ref('')
const enableLoading = ref(false)
const saveLoading = ref(false)
const importLoading = ref(false)
const queryCache = useQueryCache()
const { data: providerDetail } = useQuery({
key: () => ['speech-provider-detail', curProviderId.value],
query: async () => {
if (!curProviderId.value) return null
const { data } = await getSpeechProvidersById({
path: { id: curProviderId.value },
throwOnError: true,
})
return data ?? null
},
})
const { data: metaList } = useQuery({
key: () => ['speech-providers-meta'],
query: async () => {
@@ -280,27 +305,36 @@ const orderedProviderFields = computed(() => {
return [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
})
const { data: allSpeechModels } = useQuery({
key: () => ['speech-models'],
const { data: providerSpeechModels } = useQuery({
key: () => ['speech-provider-models', curProviderId.value],
query: async () => {
const { data } = await getSpeechModels({ throwOnError: true })
if (!curProviderId.value) return []
const { data } = await getSpeechProvidersByIdModels({
path: { id: curProviderId.value },
throwOnError: true,
})
return data ?? []
},
})
const providerModels = computed(() => {
if (!allSpeechModels.value || !curProviderId.value) return []
return (allSpeechModels.value as TtsSpeechModelResponse[]).filter((m) => m.provider_id === curProviderId.value)
return (providerSpeechModels.value as TtsSpeechModelResponse[] | undefined) ?? []
})
watch(() => curProvider.value, (provider) => {
providerName.value = provider?.name ?? ''
watch(() => providerDetail.value, (provider) => {
providerName.value = provider?.name ?? curProvider.value?.name ?? ''
Object.keys(providerConfig).forEach((key) => delete providerConfig[key])
Object.assign(providerConfig, { ...(provider?.config ?? {}) })
}, { immediate: true, deep: true })
function getModelMeta(modelID: string): SpeechModelMeta | null {
return currentMeta.value?.models?.find((m) => m.id === modelID) ?? null
const models = currentMeta.value?.models ?? []
const exact = models.find(m => m.id === modelID)
if (exact) return exact
if (currentMeta.value?.default_model) {
return models.find(m => m.id === currentMeta.value?.default_model) ?? null
}
return models[0] ?? null
}
function getModelSchema(modelID: string): SpeechConfigSchema | null {
@@ -330,6 +364,7 @@ async function handleToggleEnable(value: boolean) {
throwOnError: true,
})
queryCache.invalidateQueries({ key: ['speech-providers'] })
queryCache.invalidateQueries({ key: ['speech-provider-detail', curProviderId.value] })
} catch {
curProvider.value = { ...curProvider.value, enable: prev }
toast.error(t('common.saveFailed'))
@@ -354,6 +389,7 @@ async function handleSaveProvider() {
})
toast.success(t('speech.saveSuccess'))
queryCache.invalidateQueries({ key: ['speech-providers'] })
queryCache.invalidateQueries({ key: ['speech-provider-detail', curProviderId.value] })
} catch {
toast.error(t('common.saveFailed'))
} finally {
@@ -377,12 +413,35 @@ async function handleSaveModel(modelId: string, config: Record<string, unknown>)
throwOnError: true,
})
toast.success(t('speech.saveSuccess'))
queryCache.invalidateQueries({ key: ['speech-provider-models', curProviderId.value] })
queryCache.invalidateQueries({ key: ['speech-models'] })
} catch {
toast.error(t('common.saveFailed'))
}
}
async function handleImportModels() {
if (!curProviderId.value) return
importLoading.value = true
try {
const { data } = await postSpeechProvidersByIdImportModels({
path: { id: curProviderId.value },
throwOnError: true,
})
toast.success(t('speech.importSuccess', {
created: data?.created ?? 0,
skipped: data?.skipped ?? 0,
}))
queryCache.invalidateQueries({ key: ['speech-provider-models', curProviderId.value] })
queryCache.invalidateQueries({ key: ['speech-models'] })
queryCache.invalidateQueries({ key: ['speech-providers-meta'] })
} catch {
toast.error(t('speech.importFailed'))
} finally {
importLoading.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')