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
This commit is contained in:
Acbox
2026-04-07 00:26:06 +08:00
parent 43c4153938
commit a04b8fd564
78 changed files with 3191 additions and 5737 deletions
@@ -186,8 +186,13 @@ const clientTypeOptions = computed(() =>
const queryCache = useQueryCache()
const { mutateAsync: createProviderMutation, isLoading } = useMutation({
mutation: async (data: Record<string, unknown>) => {
const config: Record<string, unknown> = {}
if (data.base_url) config.base_url = data.base_url
if (data.api_key) config.api_key = data.api_key
const payload = {
...data,
name: data.name,
client_type: data.client_type,
config,
metadata: { additionalProp1: {} },
}
const { data: result } = await postProviders({ body: payload as ProvidersCreateRequest, throwOnError: true })
@@ -297,7 +297,7 @@ async function addModel() {
const payload: Record<string, unknown> = {
type,
model_id,
llm_provider_id: id,
provider_id: id,
config,
}
+5
View File
@@ -30,6 +30,11 @@ export const CLIENT_TYPE_META: Record<string, ClientTypeMeta> = {
label: 'Google Generative AI',
hint: 'Gemini API',
},
'edge-speech': {
value: 'edge-speech',
label: 'Edge Speech',
hint: 'Microsoft Edge Read Aloud TTS',
},
}
export const CLIENT_TYPE_LIST: ClientTypeMeta[] = Object.values(CLIENT_TYPE_META)
@@ -330,7 +330,7 @@ import MemoryProviderSelect from './memory-provider-select.vue'
import TtsModelSelect from './tts-model-select.vue'
import BrowserContextSelect from './browser-context-select.vue'
import { useQuery, useMutation, useQueryCache } from '@pinia/colada'
import { getBotsByBotIdSettings, putBotsByBotIdSettings, deleteBotsById, getModels, getProviders, getSearchProviders, getMemoryProviders, getTtsProviders, getBrowserContexts, getBotsByBotIdMemoryStatus, postBotsByBotIdMemoryRebuild } from '@memohai/sdk'
import { getBotsByBotIdSettings, putBotsByBotIdSettings, deleteBotsById, getModels, getProviders, getSearchProviders, getMemoryProviders, getSpeechProviders, getSpeechModels, getBrowserContexts, getBotsByBotIdMemoryStatus, postBotsByBotIdMemoryRebuild } from '@memohai/sdk'
import type { SettingsSettings } from '@memohai/sdk'
import type { Ref } from 'vue'
import { resolveApiErrorMessage } from '@/utils/api-error'
@@ -389,23 +389,18 @@ const { data: memoryProviderData } = useQuery({
})
const { data: ttsProviderData } = useQuery({
key: ['tts-providers'],
key: ['speech-providers'],
query: async () => {
const { data } = await getTtsProviders({ throwOnError: true })
const { data } = await getSpeechProviders({ throwOnError: true })
return data
},
})
const { data: ttsModelData } = useQuery({
key: ['tts-models'],
key: ['speech-models'],
query: async () => {
const apiBase = import.meta.env.VITE_API_URL?.trim() || '/api'
const token = localStorage.getItem('token')
const resp = await fetch(`${apiBase}/tts-models`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!resp.ok) throw new Error('Failed to fetch TTS models')
return resp.json()
const { data } = await getSpeechModels({ throwOnError: true })
return data
},
})
@@ -448,7 +443,7 @@ const searchProviders = computed(() => (searchProviderData.value ?? []).filter((
const memoryProviders = computed(() => memoryProviderData.value ?? [])
const ttsProviders = computed(() => (ttsProviderData.value ?? []).filter((p) => p.enable !== false))
const enabledTtsProviderIds = computed(() => new Set(ttsProviders.value.map((p) => p.id)))
const ttsModels = computed(() => (ttsModelData.value ?? []).filter((m: Record<string, unknown>) => enabledTtsProviderIds.value.has(m.tts_provider_id as string)))
const ttsModels = computed(() => (ttsModelData.value ?? []).filter((m: Record<string, unknown>) => enabledTtsProviderIds.value.has(m.provider_id as string)))
const browserContexts = computed(() => browserContextData.value ?? [])
// ---- Form ----
@@ -122,7 +122,7 @@ const typeFilteredModels = computed(() =>
const options = computed<ModelOption[]>(() =>
typeFilteredModels.value.map((model) => {
const providerId = model.llm_provider_id ?? ''
const providerId = model.provider_id ?? ''
const config = model.config as { compatibilities?: string[]; context_window?: number } | undefined
return {
value: model.id || model.model_id || '',
@@ -59,14 +59,14 @@ export interface TtsModelOption {
id: string
model_id: string
name: string
tts_provider_id: string
provider_id: string
provider_type?: string
}
export interface TtsProviderOption {
id: string
name: string
provider: string
client_type: string
}
const props = defineProps<{
@@ -96,8 +96,8 @@ const options = computed<SearchableSelectOption[]>(() => {
value: model.id || '',
label: model.name || model.model_id || '',
description: model.model_id,
group: model.tts_provider_id,
groupLabel: providerMap.value.get(model.tts_provider_id) ?? model.tts_provider_id,
group: model.provider_id,
groupLabel: providerMap.value.get(model.provider_id) ?? model.provider_id,
keywords: [model.name ?? '', model.model_id ?? '', model.provider_type ?? ''],
}))
return [noneOption, ...modelOptions]
@@ -32,7 +32,7 @@
<section class="flex flex-col gap-4">
<ModelItem
v-for="model in displayedModels"
:key="model.id || `${model.llm_provider_id}:${model.model_id}`"
:key="model.id || `${model.provider_id}:${model.model_id}`"
:model="model"
:delete-loading="deleteModelLoading"
@edit="(model) => $emit('edit', model)"
@@ -33,7 +33,7 @@
<FormControl>
<Input
type="password"
:placeholder="providerWithAuth?.api_key || $t('provider.apiKeyPlaceholder')"
:placeholder="(providerWithAuth?.config as Record<string, unknown> | undefined)?.api_key as string || $t('provider.apiKeyPlaceholder')"
:aria-label="$t('provider.apiKey')"
v-bind="componentField"
/>
@@ -317,7 +317,7 @@ const providerSchema = toTypedSchema(z.object({
additionalProp1: z.object({}),
}),
}).superRefine((value, ctx) => {
if (value.client_type !== 'openai-codex' && !value.api_key?.trim() && !providerWithAuth.value?.api_key) {
if (value.client_type !== 'openai-codex' && !value.api_key?.trim() && !(providerWithAuth.value?.config as Record<string, unknown> | undefined)?.api_key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_key'],
@@ -332,10 +332,11 @@ const form = useForm({
watch(() => props.provider, (newVal) => {
if (newVal) {
const cfg = newVal.config as Record<string, unknown> | undefined
form.setValues({
enable: newVal.enable ?? true,
name: newVal.name,
base_url: newVal.base_url,
base_url: (cfg?.base_url as string) ?? '',
api_key: '',
client_type: newVal.client_type || 'openai-completions',
})
@@ -362,6 +363,7 @@ watch(() => [props.provider?.id, form.values.client_type] as const, async ([id,
const hasChanges = computed(() => {
const raw = props.provider
const cfg = raw?.config as Record<string, unknown> | undefined
const baseChanged = JSON.stringify({
enable: form.values.enable,
name: form.values.name,
@@ -371,7 +373,7 @@ const hasChanges = computed(() => {
}) !== JSON.stringify({
enable: raw?.enable ?? true,
name: raw?.name,
base_url: raw?.base_url,
base_url: (cfg?.base_url as string) ?? '',
client_type: raw?.client_type || 'openai-completions',
metadata: { additionalProp1: {} },
})
@@ -381,16 +383,17 @@ const hasChanges = computed(() => {
})
const editProvider = form.handleSubmit(async (value) => {
const config: Record<string, unknown> = { base_url: value.base_url }
if (value.api_key && value.api_key.trim() !== '') {
config.api_key = value.api_key
}
const payload: Record<string, unknown> = {
enable: value.enable,
name: value.name,
base_url: value.base_url,
config,
client_type: value.client_type,
metadata: value.metadata,
}
if (value.api_key && value.api_key.trim() !== '') {
payload.api_key = value.api_key
}
emit('submit', payload)
})
@@ -1,130 +0,0 @@
<template>
<FormDialogShell
v-model:open="open"
:title="$t('speech.addModel')"
:cancel-text="$t('common.cancel')"
:submit-text="$t('speech.addModel')"
:submit-disabled="(form.meta.value.valid === false) || isLoading"
:loading="isLoading"
@submit="handleCreate"
>
<template #trigger>
<Button variant="default">
{{ $t('speech.addModel') }}
</Button>
</template>
<template #body>
<div class="flex-col gap-3 flex mt-4">
<FormField
v-slot="{ componentField }"
name="model_id"
>
<FormItem>
<Label :for="componentField.id || 'tts-model-id'">
{{ $t('speech.modelId') }}
</Label>
<FormControl>
<Input
:id="componentField.id || 'tts-model-id'"
type="text"
:placeholder="$t('speech.modelIdPlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="name"
>
<FormItem>
<Label :for="componentField.id || 'tts-model-name'">
{{ $t('common.name') }}
</Label>
<FormControl>
<Input
:id="componentField.id || 'tts-model-name'"
type="text"
:placeholder="$t('common.namePlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
</div>
</template>
</FormDialogShell>
</template>
<script setup lang="ts">
import {
Button,
Input,
FormField,
FormControl,
FormItem,
Label,
} from '@memohai/ui'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useForm } from 'vee-validate'
import { useMutation, useQueryCache } from '@pinia/colada'
import { postTtsModels } from '@memohai/sdk'
import { useI18n } from 'vue-i18n'
import FormDialogShell from '@/components/form-dialog-shell/index.vue'
import { useDialogMutation } from '@/composables/useDialogMutation'
const props = defineProps<{
providerId: string
}>()
const emit = defineEmits<{
created: []
}>()
const open = defineModel<boolean>('open')
const { t } = useI18n()
const { run } = useDialogMutation()
const queryCache = useQueryCache()
const { mutateAsync: createMutation, isLoading } = useMutation({
mutation: async (data: { model_id: string; name: string }) => {
const { data: result } = await postTtsModels({
body: {
model_id: data.model_id,
name: data.name,
tts_provider_id: props.providerId,
},
throwOnError: true,
})
return result
},
onSettled: () => {
queryCache.invalidateQueries({ key: ['tts-provider-models'] })
queryCache.invalidateQueries({ key: ['tts-models'] })
},
})
const schema = toTypedSchema(z.object({
model_id: z.string().min(1),
name: z.string(),
}))
const form = useForm({
validationSchema: schema,
initialValues: { model_id: '', name: '' },
})
const handleCreate = form.handleSubmit(async (value) => {
await run(
() => createMutation({ model_id: value.model_id, name: value.name ?? '' }),
{
fallbackMessage: t('common.saveFailed'),
onSuccess: () => {
open.value = false
emit('created')
},
},
)
})
</script>
@@ -1,142 +0,0 @@
<template>
<section>
<FormDialogShell
v-model:open="open"
:title="$t('speech.add')"
:cancel-text="$t('common.cancel')"
:submit-text="$t('speech.add')"
:submit-disabled="(form.meta.value.valid === false) || isLoading"
:loading="isLoading"
@submit="handleCreate"
>
<template #trigger>
<Button
class="w-full shadow-none! text-muted-foreground mb-4"
variant="outline"
>
<Plus
class="mr-1"
/> {{ $t('speech.add') }}
</Button>
</template>
<template #body>
<div class="flex-col gap-3 flex mt-4">
<FormField
v-slot="{ componentField }"
name="name"
>
<FormItem>
<Label :for="componentField.id || 'tts-provider-name'">
{{ $t('common.name') }}
</Label>
<FormControl>
<Input
:id="componentField.id || 'tts-provider-name'"
type="text"
:placeholder="$t('common.namePlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
<FormField
v-slot="{ componentField }"
name="provider"
>
<FormItem>
<Label :for="componentField.id || 'tts-provider-type'">
{{ $t('speech.providerType') }}
</Label>
<FormControl>
<Select v-bind="componentField">
<SelectTrigger
:id="componentField.id || 'tts-provider-type'"
class="w-full"
>
<SelectValue :placeholder="$t('common.typePlaceholder')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="meta in providerMetas"
:key="meta.provider"
:value="meta.provider!"
>
{{ meta.display_name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
</FormField>
</div>
</template>
</FormDialogShell>
</section>
</template>
<script setup lang="ts">
import {
Button,
Input,
FormField,
FormControl,
FormItem,
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectGroup,
SelectItem,
Label,
} from '@memohai/ui'
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useForm } from 'vee-validate'
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { postTtsProviders, getTtsProvidersMeta } from '@memohai/sdk'
import type { TtsCreateProviderRequest } from '@memohai/sdk'
import { useI18n } from 'vue-i18n'
import { Plus } from 'lucide-vue-next'
import FormDialogShell from '@/components/form-dialog-shell/index.vue'
import { useDialogMutation } from '@/composables/useDialogMutation'
const open = defineModel<boolean>('open')
const { t } = useI18n()
const { run } = useDialogMutation()
const { data: providerMetas } = useQuery({
key: () => ['tts-providers-meta'],
query: async () => {
const { data } = await getTtsProvidersMeta({ throwOnError: true })
return data
},
})
const queryCache = useQueryCache()
const { mutateAsync: createMutation, isLoading } = useMutation({
mutation: async (data: Record<string, unknown>) => {
const { data: result } = await postTtsProviders({ body: data as TtsCreateProviderRequest, throwOnError: true })
return result
},
onSettled: () => queryCache.invalidateQueries({ key: ['tts-providers'] }),
})
const schema = toTypedSchema(z.object({
name: z.string().min(1),
provider: z.string().min(1),
}))
const form = useForm({ validationSchema: schema })
const handleCreate = form.handleSubmit(async (value) => {
await run(
() => createMutation({ ...value, config: {} }),
{
fallbackMessage: t('common.saveFailed'),
onSuccess: () => { open.value = false },
},
)
})
</script>
@@ -371,7 +371,7 @@ async function handleTest() {
try {
const apiBase = import.meta.env.VITE_API_URL?.trim() || '/api'
const token = localStorage.getItem('token')
const resp = await fetch(`${apiBase}/tts-models/${props.modelId}/test`, {
const resp = await fetch(`${apiBase}/speech-models/${props.modelId}/test`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -9,7 +9,7 @@
{{ curProvider?.name }}
</h2>
<p class="text-xs text-muted-foreground">
{{ currentMeta?.display_name ?? curProvider?.provider }}
{{ currentMeta?.display_name ?? curProvider?.client_type }}
</p>
</div>
<div class="ml-auto flex items-center gap-2">
@@ -25,179 +25,91 @@
</section>
<Separator class="mt-4 mb-6" />
<form @submit="handleSave">
<div class="space-y-5">
<section>
<FormField
v-slot="{ componentField }"
name="name"
>
<FormItem>
<Label :for="componentField.id || 'tts-provider-name'">
{{ $t('common.name') }}
</Label>
<FormControl>
<Input
:id="componentField.id || 'tts-provider-name'"
type="text"
:placeholder="$t('common.namePlaceholder')"
v-bind="componentField"
/>
</FormControl>
</FormItem>
</FormField>
</section>
<Separator class="my-4" />
<!-- Models -->
<section>
<div class="flex justify-between items-center mb-4">
<h3 class="text-xs font-medium">
{{ $t('speech.models') }}
</h3>
<div
v-if="curProviderId"
class="flex items-center gap-2 ml-auto"
>
<LoadingButton
type="button"
variant="outline"
class="flex items-center gap-2"
:loading="importLoading"
@click="handleImportModels"
>
<FileInput />
{{ $t('speech.importModels') }}
</LoadingButton>
<AddTtsModel
:provider-id="curProviderId"
@created="refreshModels"
/>
</div>
</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)"
@save="(cfg) => handleSaveModelConfig(model.id, cfg)"
@test="(text, cfg) => handleTestModel(model.id, text, cfg)"
/>
</div>
</div>
</section>
<!-- Models -->
<section>
<div class="flex justify-between items-center mb-4">
<h3 class="text-xs font-medium">
{{ $t('speech.models') }}
</h3>
</div>
<section class="flex justify-end mt-6 gap-4">
<ConfirmPopover
:message="$t('speech.deleteConfirm')"
:loading="deleteLoading"
@confirm="handleDelete"
<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 ?? '')"
>
<template #trigger>
<Button
type="button"
variant="outline"
>
<Trash2 />
</Button>
</template>
</ConfirmPopover>
<LoadingButton
type="submit"
:loading="editLoading"
<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"
>
{{ $t('provider.saveChanges') }}
</LoadingButton>
</section>
</form>
<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 {
Input,
Button,
FormControl,
FormField,
FormItem,
Separator,
Label,
Switch,
} from '@memohai/ui'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import LoadingButton from '@/components/loading-button/index.vue'
import ModelConfigEditor from './model-config-editor.vue'
import { Volume2, FileInput, ChevronUp, ChevronDown, Trash2 } from 'lucide-vue-next'
import AddTtsModel from './add-tts-model.vue'
import { computed, inject, ref, watch } from '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 { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useForm } from 'vee-validate'
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { putTtsProvidersById, deleteTtsProvidersById, getTtsProvidersMeta } from '@memohai/sdk'
import type { TtsProviderResponse, TtsProviderMetaResponse, TtsModelInfo } from '@memohai/sdk'
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<TtsProviderResponse>())
const curProvider = inject('curTtsProvider', ref<TtsSpeechProviderResponse>())
const curProviderId = computed(() => curProvider.value?.id)
const enableLoading = ref(false)
const apiBase = import.meta.env.VITE_API_URL?.trim() || '/api'
function authHeaders(): Record<string, string> {
const token = localStorage.getItem('token')
return token ? { Authorization: `Bearer ${token}` } : {}
}
const { data: metaList } = useQuery({
key: () => ['tts-providers-meta'],
key: () => ['speech-providers-meta'],
query: async () => {
const { data } = await getTtsProvidersMeta({ throwOnError: true })
const { data } = await getSpeechProvidersMeta({ throwOnError: true })
return data
},
})
const currentMeta = computed<TtsProviderMetaResponse | null>(() => {
if (!metaList.value || !curProvider.value?.provider) return null
return (metaList.value as TtsProviderMetaResponse[]).find((m) => m.provider === curProvider.value?.provider) ?? 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) {
@@ -206,21 +118,18 @@ function getModelCapabilities(modelId: string) {
return meta.models.find((m: TtsModelInfo) => m.id === modelId)?.capabilities ?? null
}
// Provider models
const { data: providerModelsData, refresh: refreshModels } = useQuery({
key: () => ['tts-provider-models', curProviderId.value],
const { data: allSpeechModels } = useQuery({
key: () => ['speech-models'],
query: async () => {
if (!curProviderId.value) return []
const resp = await fetch(`${apiBase}/tts-providers/${curProviderId.value}/models`, {
headers: authHeaders(),
})
if (!resp.ok) throw new Error('Failed to fetch models')
return resp.json()
const { data } = await getSpeechModels({ throwOnError: true })
return data
},
enabled: () => !!curProviderId.value,
})
const providerModels = computed(() => providerModelsData.value ?? [])
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) {
@@ -237,12 +146,12 @@ async function handleToggleEnable(value: boolean) {
enableLoading.value = true
try {
await putTtsProvidersById({
await putProvidersById({
path: { id: curProviderId.value },
body: { enable: value },
throwOnError: true,
})
queryCache.invalidateQueries({ key: ['tts-providers'] })
queryCache.invalidateQueries({ key: ['speech-providers'] })
} catch {
curProvider.value = { ...curProvider.value, enable: prev }
toast.error(t('common.saveFailed'))
@@ -251,108 +160,15 @@ async function handleToggleEnable(value: boolean) {
}
}
const schema = toTypedSchema(z.object({
name: z.string().min(1),
}))
const form = useForm({ validationSchema: schema })
let loadedProviderId = ''
watch(() => curProvider.value?.id, (id) => {
if (!id || id === loadedProviderId) return
loadedProviderId = id
expandedModelId.value = ''
const p = curProvider.value
if (p) {
form.setValues({ name: p.name ?? '' })
}
}, { immediate: true })
const { mutateAsync: submitUpdate, isLoading: editLoading } = useMutation({
mutation: async (data: { name: string }) => {
if (!curProviderId.value) return
const { data: result } = await putTtsProvidersById({
path: { id: curProviderId.value },
body: { name: data.name },
throwOnError: true,
})
return result
},
onSettled: () => queryCache.invalidateQueries({ key: ['tts-providers'] }),
})
const { mutateAsync: doDelete, isLoading: deleteLoading } = useMutation({
mutation: async () => {
if (!curProviderId.value) return
await deleteTtsProvidersById({ path: { id: curProviderId.value }, throwOnError: true })
},
onSettled: () => {
queryCache.invalidateQueries({ key: ['tts-providers'] })
queryCache.invalidateQueries({ key: ['tts-models'] })
},
})
const handleSave = form.handleSubmit(async (values) => {
try {
await submitUpdate({ name: values.name })
toast.success(t('provider.saveChanges'))
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('common.saveFailed'))
}
})
async function handleDelete() {
try {
await doDelete()
toast.success(t('common.deleteSuccess'))
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('common.saveFailed'))
}
}
// Import models
const importLoading = ref(false)
async function handleImportModels() {
if (!curProviderId.value) return
importLoading.value = true
try {
const resp = await fetch(`${apiBase}/tts-providers/${curProviderId.value}/import-models`, {
method: 'POST',
headers: authHeaders(),
})
if (!resp.ok) throw new Error('Import failed')
toast.success(t('speech.importSuccess'))
refreshModels()
queryCache.invalidateQueries({ key: ['tts-models'] })
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('speech.importFailed'))
} finally {
importLoading.value = false
}
}
// Save model config
async function handleSaveModelConfig(modelId: string, config: Record<string, unknown>) {
try {
const resp = await fetch(`${apiBase}/tts-models/${modelId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ config }),
})
if (!resp.ok) throw new Error('Save failed')
toast.success(t('provider.saveChanges'))
refreshModels()
queryCache.invalidateQueries({ key: ['tts-models'] })
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : t('common.saveFailed'))
}
}
// Test model synthesis
async function handleTestModel(modelId: string, text: string, config: Record<string, unknown>) {
const resp = await fetch(`${apiBase}/tts-models/${modelId}/test`, {
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', ...authHeaders() },
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ text, config }),
})
if (!resp.ok) {
+8 -26
View File
@@ -1,35 +1,32 @@
<script setup lang="ts">
import { computed, ref, provide, watch, reactive } from 'vue'
import { computed, ref, provide, watch } from 'vue'
import { useQuery } from '@pinia/colada'
import {
Button,
ScrollArea,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
Toggle,
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@memohai/ui'
import { getTtsProviders } from '@memohai/sdk'
import type { TtsProviderResponse } from '@memohai/sdk'
import AddTtsProvider from './components/add-tts-provider.vue'
import { getSpeechProviders } from '@memohai/sdk'
import type { TtsSpeechProviderResponse } from '@memohai/sdk'
import ProviderSetting from './components/provider-setting.vue'
import { Volume2, Plus } from 'lucide-vue-next'
import { Volume2 } from 'lucide-vue-next'
import MasterDetailSidebarLayout from '@/components/master-detail-sidebar-layout/index.vue'
const { data: providerData } = useQuery({
key: () => ['tts-providers'],
key: () => ['speech-providers'],
query: async () => {
const { data } = await getTtsProviders({ throwOnError: true })
const { data } = await getSpeechProviders({ throwOnError: true })
return data
},
})
const curProvider = ref<TtsProviderResponse>()
const curProvider = ref<TtsSpeechProviderResponse>()
provide('curTtsProvider', curProvider)
const selectProvider = (name: string) => computed(() => {
@@ -52,7 +49,7 @@ watch(filteredProviders, (list) => {
}
const currentId = curProvider.value?.id
if (currentId) {
const stillExists = list.find((p: TtsProviderResponse) => p.id === currentId)
const stillExists = list.find((p: TtsSpeechProviderResponse) => p.id === currentId)
if (stillExists) {
curProvider.value = stillExists
return
@@ -61,7 +58,6 @@ watch(filteredProviders, (list) => {
curProvider.value = list[0]
}, { immediate: true })
const openStatus = reactive({ addOpen: false })
</script>
<template>
@@ -99,10 +95,6 @@ const openStatus = reactive({ addOpen: false })
</SidebarMenu>
</template>
<template #sidebar-footer>
<AddTtsProvider v-model:open="openStatus.addOpen" />
</template>
<template #detail>
<ScrollArea
v-if="curProvider?.id"
@@ -121,16 +113,6 @@ const openStatus = reactive({ addOpen: false })
</EmptyHeader>
<EmptyTitle>{{ $t('speech.emptyTitle') }}</EmptyTitle>
<EmptyDescription>{{ $t('speech.emptyDescription') }}</EmptyDescription>
<EmptyContent>
<Button
variant="outline"
@click="openStatus.addOpen = true"
>
<Plus
class="mr-1"
/> {{ $t('speech.add') }}
</Button>
</EmptyContent>
</Empty>
</template>
</MasterDetailSidebarLayout>