mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
63fe03cfff
This reverts commit c9dcfe287f.
478 lines
14 KiB
Vue
478 lines
14 KiB
Vue
<template>
|
|
<div class="p-4">
|
|
<section class="flex items-center gap-3">
|
|
<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 }}
|
|
</h2>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ currentMeta?.display_name ?? curProvider?.client_type }}
|
|
</p>
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-2">
|
|
<span class="text-xs text-muted-foreground">
|
|
{{ $t('common.enable') }}
|
|
</span>
|
|
<Switch
|
|
:model-value="curProvider?.enable ?? false"
|
|
:disabled="!curProvider?.id || enableLoading"
|
|
@update:model-value="handleToggleEnable"
|
|
/>
|
|
</div>
|
|
</section>
|
|
<Separator class="mt-4 mb-6" />
|
|
|
|
<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
|
|
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 || {}"
|
|
:schema="getModelSchema(model.model_id ?? '')"
|
|
:on-test="(text, cfg) => handleTestModel(model.id ?? '', text, cfg)"
|
|
@save="(cfg) => handleSaveModel(model.id ?? '', cfg)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<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 { 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 { 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 ?? []) as SpeechProviderMeta[]
|
|
},
|
|
})
|
|
|
|
const currentMeta = computed(() => {
|
|
if (!metaList.value || !curProvider.value?.client_type) return null
|
|
return (metaList.value as SpeechProviderMeta[]).find((m) => m.provider === curProvider.value?.client_type) ?? null
|
|
})
|
|
|
|
const orderedProviderFields = computed(() => {
|
|
const fields = currentMeta.value?.config_schema?.fields ?? []
|
|
return [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
})
|
|
|
|
const { data: providerSpeechModels } = useQuery({
|
|
key: () => ['speech-provider-models', curProviderId.value],
|
|
query: async () => {
|
|
if (!curProviderId.value) return []
|
|
const { data } = await getSpeechProvidersByIdModels({
|
|
path: { id: curProviderId.value },
|
|
throwOnError: true,
|
|
})
|
|
return data ?? []
|
|
},
|
|
})
|
|
|
|
const providerModels = computed(() => {
|
|
return (providerSpeechModels.value as TtsSpeechModelResponse[] | undefined) ?? []
|
|
})
|
|
|
|
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
|
|
}
|
|
|
|
async function handleToggleEnable(value: boolean) {
|
|
if (!curProviderId.value || !curProvider.value) return
|
|
const prev = curProvider.value.enable ?? false
|
|
curProvider.value = { ...curProvider.value, enable: value }
|
|
|
|
enableLoading.value = true
|
|
try {
|
|
await putProvidersById({
|
|
path: { id: curProviderId.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'))
|
|
} finally {
|
|
enableLoading.value = false
|
|
}
|
|
}
|
|
|
|
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')
|
|
const resp = await fetch(`${apiBase}/speech-models/${modelId}/test`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify({ text, config }),
|
|
})
|
|
if (!resp.ok) {
|
|
const errBody = await resp.text()
|
|
let msg: string
|
|
try {
|
|
msg = JSON.parse(errBody)?.message ?? errBody
|
|
} catch {
|
|
msg = errBody
|
|
}
|
|
throw new Error(msg)
|
|
}
|
|
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>
|