feat: expand speech provider support with new client types and config… (#389)

* feat: expand speech provider support with new client types and configuration schema

* feat: add icon support for speech providers and update related configurations

* feat: add SVG support for Deepgram and Elevenlabs with Vue components

* feat: except *-speech client type in llm provider

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

* chore: remove go.mod replace

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

* chore: update go module dependencies

---------

Co-authored-by: Acbox <acbox0328@gmail.com>
This commit is contained in:
Yiming Qi
2026-04-19 22:58:16 +09:00
committed by GitHub
parent 8e013ad1ad
commit 8d78925a23
46 changed files with 2808 additions and 565 deletions
@@ -1,9 +1,19 @@
<template>
<div class="p-4">
<section class="flex items-center gap-3">
<Volume2
class="size-5"
/>
<span class="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted">
<ProviderIcon
v-if="curProvider?.icon"
:icon="curProvider.icon"
size="1.5em"
/>
<span
v-else
class="text-xs font-medium text-muted-foreground"
>
{{ getInitials(curProvider?.name) }}
</span>
</span>
<div class="min-w-0">
<h2 class="text-sm font-semibold truncate">
{{ curProvider?.name }}
@@ -25,12 +35,121 @@
</section>
<Separator class="mt-4 mb-6" />
<!-- Models -->
<form
class="space-y-4"
@submit.prevent="handleSaveProvider"
>
<section class="space-y-2">
<Label for="speech-provider-name">{{ $t('common.name') }}</Label>
<Input
id="speech-provider-name"
v-model="providerName"
type="text"
:placeholder="$t('common.namePlaceholder')"
/>
</section>
<section
v-for="field in orderedProviderFields"
:key="field.key"
class="space-y-2"
>
<Label :for="field.type === 'bool' || field.type === 'enum' ? undefined : `speech-provider-${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="`speech-provider-${field.key}`"
v-model="providerConfig[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="!!providerConfig[field.key]"
@update:model-value="(val) => providerConfig[field.key] = !!val"
/>
<Input
v-else-if="field.type === 'number'"
:id="`speech-provider-${field.key}`"
v-model.number="providerConfig[field.key] as number"
type="number"
:placeholder="field.example ? String(field.example) : ''"
/>
<Select
v-else-if="field.type === 'enum' && field.enum"
:model-value="String(providerConfig[field.key] ?? '')"
@update:model-value="(val) => providerConfig[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="`speech-provider-${field.key}`"
v-model="providerConfig[field.key] as string"
type="text"
:placeholder="field.example ? String(field.example) : ''"
/>
</section>
<div class="flex justify-end">
<LoadingButton
type="submit"
:loading="saveLoading"
>
{{ $t('provider.saveChanges') }}
</LoadingButton>
</div>
</form>
<Separator class="mt-6 mb-6" />
<section>
<div class="flex justify-between items-center mb-4">
<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
@@ -71,8 +190,9 @@
: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)"
:schema="getModelSchema(model.model_id ?? '')"
:on-test="(text, cfg) => handleTestModel(model.id ?? '', text, cfg)"
@save="(cfg) => handleSaveModel(model.id ?? '', cfg)"
/>
</div>
</div>
@@ -82,65 +202,152 @@
<script setup lang="ts">
import {
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
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 { ChevronDown, ChevronUp, Eye, EyeOff } from 'lucide-vue-next'
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 { getSpeechProvidersMeta, getSpeechModels, putProvidersById } from '@memohai/sdk'
import type { TtsSpeechProviderResponse, TtsProviderMetaResponse, TtsModelInfo } 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'
interface SpeechFieldSchema {
key: string
type: string
title?: string
description?: string
required?: boolean
advanced?: boolean
enum?: string[]
example?: unknown
order?: number
}
interface SpeechConfigSchema {
fields?: SpeechFieldSchema[]
}
interface SpeechModelMeta {
id: string
name: string
description?: string
config_schema?: SpeechConfigSchema
capabilities?: {
config_schema?: SpeechConfigSchema
}
}
interface SpeechProviderMeta {
provider: string
display_name: string
description?: string
config_schema?: SpeechConfigSchema
default_model?: string
models?: SpeechModelMeta[]
}
function getInitials(name: string | undefined) {
const label = name?.trim() ?? ''
return label ? label.slice(0, 2).toUpperCase() : '?'
}
const { t } = useI18n()
const curProvider = inject('curTtsProvider', ref<TtsSpeechProviderResponse>())
const curProviderId = computed(() => curProvider.value?.id)
const providerName = ref('')
const providerConfig = reactive<Record<string, unknown>>({})
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 () => {
const { data } = await getSpeechProvidersMeta({ throwOnError: true })
return data
return (data ?? []) as SpeechProviderMeta[]
},
})
const currentMeta = computed<TtsProviderMetaResponse | null>(() => {
const currentMeta = computed(() => {
if (!metaList.value || !curProvider.value?.client_type) return null
return (metaList.value as TtsProviderMetaResponse[]).find((m) => m.provider === curProvider.value?.client_type) ?? null
return (metaList.value as SpeechProviderMeta[]).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 orderedProviderFields = computed(() => {
const fields = currentMeta.value?.config_schema?.fields ?? []
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 })
return data
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.filter((m) => m.provider_id === curProviderId.value)
return (providerSpeechModels.value as TtsSpeechModelResponse[] | undefined) ?? []
})
const expandedModelId = ref('')
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 {
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 {
const meta = getModelMeta(modelID)
return meta?.config_schema ?? meta?.capabilities?.config_schema ?? null
}
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 }
@@ -148,10 +355,16 @@ async function handleToggleEnable(value: boolean) {
try {
await putProvidersById({
path: { id: curProviderId.value },
body: { enable: value },
body: {
name: providerName.value.trim() || curProvider.value.name,
client_type: curProvider.value.client_type,
enable: value,
config: sanitizeConfig(providerConfig),
},
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'))
@@ -160,6 +373,75 @@ async function handleToggleEnable(value: boolean) {
}
}
async function handleSaveProvider() {
if (!curProviderId.value || !curProvider.value) return
saveLoading.value = true
try {
await putProvidersById({
path: { id: curProviderId.value },
body: {
name: providerName.value.trim() || curProvider.value.name,
client_type: curProvider.value.client_type,
enable: curProvider.value.enable,
config: sanitizeConfig(providerConfig),
},
throwOnError: true,
})
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 {
saveLoading.value = false
}
}
async function handleSaveModel(modelId: string, config: Record<string, unknown>) {
const model = providerModels.value.find((item) => item.id === modelId)
if (!model) return
try {
await putModelsById({
path: { id: modelId },
body: {
model_id: model.model_id,
name: model.name ?? model.model_id,
provider_id: model.provider_id,
type: 'speech',
config,
},
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')
@@ -183,4 +465,13 @@ async function handleTestModel(modelId: string, text: string, config: Record<str
}
return resp.blob()
}
function sanitizeConfig(input: Record<string, unknown>) {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(input)) {
if (value === '' || value == null) continue
result[key] = value
}
return result
}
</script>