mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user