mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat: enhance speech provider functionality with advanced settings and model import capabilities
This commit is contained in:
@@ -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...",
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user