31 changed files with 1727 additions and 141 deletions
@ -0,0 +1 @@ |
|||
{} |
|||
@ -0,0 +1,354 @@ |
|||
<template> |
|||
<div class="p-4 space-y-4"> |
|||
<!-- 顶部:标题 + 应用选择 --> |
|||
<div class="flex flex-wrap items-center justify-between gap-3"> |
|||
<div> |
|||
<h1 class="text-lg font-semibold">会议记录服务配置</h1> |
|||
<p class="text-xs text-base-content/60 mt-1"> |
|||
识别 / 翻译 / 总结 三类服务统一在此管理:按优先级自动选择,识别叠加「支持语言」过滤,总结为客户端可选的具体模型条目。改配置后 home 需重启生效。 |
|||
</p> |
|||
</div> |
|||
<label class="flex items-center gap-2 text-sm"> |
|||
<span class="text-base-content/60">部署应用</span> |
|||
<select v-model.number="appId" class="select select-bordered select-sm" @change="loadList"> |
|||
<option v-for="a in apps" :key="a.id" :value="a.id">{{ a.app_name || a.name }}({{ a.region }})</option> |
|||
</select> |
|||
</label> |
|||
</div> |
|||
|
|||
<!-- 类别 tab --> |
|||
<div class="tabs tabs-boxed w-fit"> |
|||
<a |
|||
v-for="c in categories" |
|||
:key="c.value" |
|||
class="tab" |
|||
:class="{ 'tab-active': curCat === c.value }" |
|||
@click="curCat = c.value" |
|||
>{{ c.label }}<span class="ml-1 opacity-60">{{ (grouped[c.value] || []).length }}</span></a> |
|||
</div> |
|||
|
|||
<div v-if="dbError" class="alert alert-error text-sm">{{ dbError }}</div> |
|||
|
|||
<!-- 列表 --> |
|||
<div class="bg-base-100 rounded-lg border border-base-200 overflow-x-auto"> |
|||
<table class="table table-sm"> |
|||
<thead> |
|||
<tr> |
|||
<th class="w-16">优先级</th> |
|||
<th>名称 / 条目id</th> |
|||
<th>服务商</th> |
|||
<th v-if="curCatHasLangs">支持语言</th> |
|||
<th class="w-20">启用</th> |
|||
<th class="w-28">凭据</th> |
|||
<th class="w-40 text-right">操作</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody> |
|||
<tr v-for="(it, idx) in curList" :key="it.id"> |
|||
<td> |
|||
<div class="flex items-center gap-1"> |
|||
<button class="btn btn-ghost btn-xs" :disabled="idx === 0" @click="move(idx, -1)">↑</button> |
|||
<button class="btn btn-ghost btn-xs" :disabled="idx === curList.length - 1" @click="move(idx, 1)">↓</button> |
|||
</div> |
|||
</td> |
|||
<td> |
|||
<div class="font-medium">{{ it.name || it.id }}</div> |
|||
<div class="text-xs text-base-content/50 font-mono">{{ it.id }}</div> |
|||
</td> |
|||
<td><span class="badge badge-ghost badge-sm">{{ it.provider }}</span></td> |
|||
<td v-if="curCatHasLangs"> |
|||
<div v-if="it.support_langs" class="flex flex-wrap gap-1"> |
|||
<span v-for="l in it.support_langs.split(',').slice(0, 6)" :key="l" class="badge badge-outline badge-xs font-mono">{{ l }}</span> |
|||
<span v-if="it.support_langs.split(',').length > 6" class="text-xs opacity-50">…</span> |
|||
</div> |
|||
<span v-else class="text-xs opacity-40">全部(默认)</span> |
|||
</td> |
|||
<td> |
|||
<input type="checkbox" class="toggle toggle-success toggle-sm" :checked="it.enabled" @change="toggleEnabled(it)" /> |
|||
</td> |
|||
<td> |
|||
<span v-if="secretCount(it) > 0" class="text-xs">🔒 {{ secretCount(it) }}</span> |
|||
<span v-else class="text-xs opacity-40">—</span> |
|||
</td> |
|||
<td class="text-right"> |
|||
<button class="btn btn-ghost btn-xs" @click="openEdit(it)">编辑</button> |
|||
<button class="btn btn-ghost btn-xs text-error" @click="del(it)">删除</button> |
|||
</td> |
|||
</tr> |
|||
<tr v-if="!curList.length"> |
|||
<td :colspan="curCatHasLangs ? 7 : 6" class="text-center text-base-content/40 py-6">暂无服务,点击下方「添加」</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<button class="btn btn-primary btn-sm" @click="openAdd">+ 添加{{ curCatLabel }}服务</button> |
|||
|
|||
<!-- 编辑/新增 弹窗 --> |
|||
<dialog ref="dlg" class="modal"> |
|||
<div class="modal-box max-w-xl"> |
|||
<h3 class="font-semibold mb-3">{{ editing.__isNew ? '添加' : '编辑' }}{{ curCatLabel }}服务</h3> |
|||
|
|||
<div class="space-y-3"> |
|||
<!-- 服务商(新增时可选,编辑时锁定) --> |
|||
<label class="form-control"> |
|||
<span class="label-text text-xs">服务商</span> |
|||
<select |
|||
v-model="editing.provider" |
|||
class="select select-bordered select-sm" |
|||
:disabled="!editing.__isNew" |
|||
@change="onProviderChange" |
|||
> |
|||
<option v-for="p in providerOptions" :key="p.provider" :value="p.provider">{{ p.name }}({{ p.provider }})</option> |
|||
</select> |
|||
</label> |
|||
|
|||
<div class="grid grid-cols-2 gap-3"> |
|||
<label class="form-control"> |
|||
<span class="label-text text-xs">条目 id(唯一)</span> |
|||
<input v-model="editing.id" class="input input-bordered input-sm font-mono" :disabled="!editing.__isNew" /> |
|||
</label> |
|||
<label class="form-control"> |
|||
<span class="label-text text-xs">展示名{{ curCat === 3 ? '(客户端可见)' : '' }}</span> |
|||
<input v-model="editing.name" class="input input-bordered input-sm" /> |
|||
</label> |
|||
</div> |
|||
|
|||
<!-- 目录字段 --> |
|||
<div v-for="f in curFields" :key="f.key" class="form-control"> |
|||
<span class="label-text text-xs">{{ f.label }}<span v-if="f.required" class="text-error">*</span></span> |
|||
<textarea |
|||
v-if="f.type === 'textarea'" |
|||
v-model="editing.values[f.key]" |
|||
class="textarea textarea-bordered textarea-sm font-mono" |
|||
rows="3" |
|||
:placeholder="f.placeholder" |
|||
></textarea> |
|||
<input |
|||
v-else |
|||
v-model="editing.values[f.key]" |
|||
:type="f.secret ? 'password' : 'text'" |
|||
class="input input-bordered input-sm" |
|||
:placeholder="f.placeholder" |
|||
autocomplete="off" |
|||
/> |
|||
<span v-if="f.desc" class="text-xs text-base-content/50 mt-0.5">{{ f.desc }}</span> |
|||
</div> |
|||
|
|||
<!-- 识别:支持语言 --> |
|||
<div v-if="curCatHasLangs" class="form-control"> |
|||
<span class="label-text text-xs">支持语言(留空=不限,按引擎默认)</span> |
|||
<LangSelect v-model="editing.support_langs" :multiple="true" /> |
|||
</div> |
|||
|
|||
<div class="grid grid-cols-2 gap-3 items-end"> |
|||
<label class="form-control"> |
|||
<span class="label-text text-xs">优先级(小=优先)</span> |
|||
<input v-model.number="editing.priority" type="number" class="input input-bordered input-sm" /> |
|||
</label> |
|||
<label class="flex items-center gap-2 pb-2"> |
|||
<input v-model="editing.enabled" type="checkbox" class="toggle toggle-success toggle-sm" /> |
|||
<span class="text-sm">启用</span> |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="modal-action"> |
|||
<button class="btn btn-ghost btn-sm" @click="closeEdit">取消</button> |
|||
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存' }}</button> |
|||
</div> |
|||
</div> |
|||
<form method="dialog" class="modal-backdrop"><button>close</button></form> |
|||
</dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, reactive, computed, onMounted } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
|
|||
definePageMeta({ title: '会议记录服务配置' }) |
|||
|
|||
const { consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
const EncMask = '[encrypted]' |
|||
|
|||
interface Field { key: string; label: string; type: string; secret: boolean; required: boolean; placeholder?: string; desc?: string } |
|||
interface CatalogItem { category: number; provider: string; name: string; has_langs: boolean; multi: boolean; fields: Field[] } |
|||
interface Category { value: number; label: string } |
|||
interface SvcConfig { |
|||
id: string; category: number; provider: string; name: string |
|||
priority: number; enabled: boolean; support_langs: string |
|||
values: Record<string, string> |
|||
__isNew?: boolean |
|||
} |
|||
interface AppItem { id: number; app_name: string; name: string; region: string } |
|||
|
|||
const apps = ref<AppItem[]>([]) |
|||
const appId = ref(0) |
|||
const catalog = ref<CatalogItem[]>([]) |
|||
const categories = ref<Category[]>([]) |
|||
const curCat = ref(1) |
|||
const items = ref<SvcConfig[]>([]) |
|||
const dbError = ref('') |
|||
const saving = ref(false) |
|||
const dlg = ref<HTMLDialogElement | null>(null) |
|||
const editing = reactive<SvcConfig>(blank(1, '')) |
|||
|
|||
function blank(cat: number, provider: string): SvcConfig { |
|||
return { id: '', category: cat, provider, name: '', priority: 100, enabled: true, support_langs: '', values: {}, __isNew: true } |
|||
} |
|||
|
|||
// 当前类别的目录(服务商列表) |
|||
const providerOptions = computed(() => catalog.value.filter((c) => c.category === curCat.value)) |
|||
const curCatLabel = computed(() => categories.value.find((c) => c.value === curCat.value)?.label || '') |
|||
const curCatHasLangs = computed(() => providerOptions.value.some((c) => c.has_langs)) |
|||
const curDef = computed(() => catalog.value.find((c) => c.category === editing.category && c.provider === editing.provider) || null) |
|||
const curFields = computed(() => curDef.value?.fields || []) |
|||
|
|||
// 按类别归集 + 优先级排序 |
|||
const grouped = computed<Record<number, SvcConfig[]>>(() => { |
|||
const m: Record<number, SvcConfig[]> = {} |
|||
for (const it of items.value) (m[it.category] ||= []).push(it) |
|||
for (const k in m) m[k].sort((a, b) => a.priority - b.priority) |
|||
return m |
|||
}) |
|||
const curList = computed(() => grouped.value[curCat.value] || []) |
|||
|
|||
function secretCount(it: SvcConfig): number { |
|||
const def = catalog.value.find((c) => c.category === it.category && c.provider === it.provider) |
|||
if (!def) return 0 |
|||
return def.fields.filter((f) => f.secret && (it.values?.[f.key] ?? '') !== '').length |
|||
} |
|||
|
|||
async function loadApps() { |
|||
try { |
|||
const list = await consoleApi<AppItem[]>('apps/list', {}) |
|||
apps.value = list ?? [] |
|||
if (apps.value.length && !appId.value) appId.value = apps.value[0].id |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取应用列表失败') |
|||
} |
|||
} |
|||
|
|||
async function loadCatalog() { |
|||
try { |
|||
const d = await consoleApi<{ catalog: CatalogItem[]; categories: Category[] }>('echomeetsvc/catalog', {}) |
|||
catalog.value = d?.catalog ?? [] |
|||
categories.value = d?.categories ?? [] |
|||
if (categories.value.length) curCat.value = categories.value[0].value |
|||
} catch (e: any) { |
|||
error(e?.message ?? '获取配置目录失败') |
|||
} |
|||
} |
|||
|
|||
async function loadList() { |
|||
if (!appId.value) return |
|||
dbError.value = '' |
|||
try { |
|||
const d = await consoleApi<{ items: SvcConfig[] }>('echomeetsvc/list', { app_id: appId.value }) |
|||
items.value = (d?.items ?? []).map((r) => ({ ...r, values: r.values || {} })) |
|||
} catch (e: any) { |
|||
dbError.value = e?.message ?? '获取配置失败' |
|||
} |
|||
} |
|||
|
|||
function openAdd() { |
|||
const first = providerOptions.value[0] |
|||
Object.assign(editing, blank(curCat.value, first?.provider || '')) |
|||
onProviderChange() |
|||
dlg.value?.showModal() |
|||
} |
|||
|
|||
function openEdit(it: SvcConfig) { |
|||
Object.assign(editing, JSON.parse(JSON.stringify(it)), { __isNew: false }) |
|||
if (!editing.values) editing.values = {} |
|||
dlg.value?.showModal() |
|||
} |
|||
|
|||
function closeEdit() { dlg.value?.close() } |
|||
|
|||
// 新增时切换服务商:重置字段容器 + 生成默认 id/名称 |
|||
function onProviderChange() { |
|||
const def = curDef.value |
|||
editing.values = {} |
|||
if (def) { |
|||
for (const f of def.fields) editing.values[f.key] = '' |
|||
const prefix = curCat.value === 1 ? 'asr' : curCat.value === 2 ? 'mt' : 'llm' |
|||
let base = `${prefix}_${editing.provider}` |
|||
// 总结允许一商多模型,冲突则追加序号 |
|||
if (items.value.some((x) => x.id === base)) { |
|||
let n = 2 |
|||
while (items.value.some((x) => x.id === `${base}_${n}`)) n++ |
|||
base = `${base}_${n}` |
|||
} |
|||
editing.id = base |
|||
if (!editing.name) editing.name = def.name |
|||
if (curCat.value === 1) editing.support_langs = '' |
|||
} |
|||
} |
|||
|
|||
async function save() { |
|||
if (!editing.id.trim()) { error('条目 id 必填'); return } |
|||
if (editing.__isNew && items.value.some((x) => x.id === editing.id)) { error('条目 id 已存在'); return } |
|||
saving.value = true |
|||
try { |
|||
const { __isNew, ...cfg } = editing |
|||
await consoleApi('echomeetsvc/save', { app_id: appId.value, config: cfg }) |
|||
success('已保存') |
|||
closeEdit() |
|||
await loadList() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function del(it: SvcConfig) { |
|||
if (!confirm(`确定删除「${it.name || it.id}」?`)) return |
|||
try { |
|||
await consoleApi('echomeetsvc/del', { app_id: appId.value, id: it.id }) |
|||
success('已删除') |
|||
await loadList() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '删除失败') |
|||
} |
|||
} |
|||
|
|||
async function toggleEnabled(it: SvcConfig) { |
|||
try { |
|||
// 保存时密钥字段是脱敏值 [encrypted],后端会保留原密文,安全。 |
|||
await consoleApi('echomeetsvc/save', { app_id: appId.value, config: { ...it, enabled: !it.enabled } }) |
|||
await loadList() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '操作失败') |
|||
await loadList() |
|||
} |
|||
} |
|||
|
|||
// 同类内上下移:与相邻项交换优先级并提交 |
|||
async function move(idx: number, dir: number) { |
|||
const list = curList.value |
|||
const a = list[idx] |
|||
const b = list[idx + dir] |
|||
if (!a || !b) return |
|||
const pa = a.priority, pb = b.priority |
|||
try { |
|||
await consoleApi('echomeetsvc/reorder', { |
|||
app_id: appId.value, |
|||
items: [{ id: a.id, priority: pb }, { id: b.id, priority: pa }], |
|||
}) |
|||
await loadList() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '排序失败') |
|||
} |
|||
} |
|||
|
|||
onMounted(async () => { |
|||
await Promise.all([loadApps(), loadCatalog()]) |
|||
await loadList() |
|||
}) |
|||
</script> |
|||
@ -0,0 +1,218 @@ |
|||
package comm |
|||
|
|||
import ( |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/lego/sys/mysql" |
|||
) |
|||
|
|||
// 会议记录(echomeet)服务配置托管:把「识别 / 翻译 / 总结」三类服务配置全部搬进管理后台。
|
|||
//
|
|||
// 与旧的「一会一服务商,三段同源」不同:识别/翻译/总结各自维护一份**按优先级排序的服务清单**,
|
|||
// - 识别:按优先级 + 各服务「支持语言」自动选择(跳过不支持源语言的服务);
|
|||
// - 翻译:按优先级自动选择;
|
|||
// - 总结:每条 = 一个具体文本大模型条目,客户端按 id 选择。
|
|||
//
|
|||
// 存储沿用 [[app_module_config]] 的路子:存**应用自己的业务库**(mysql/pg,随部署)、**全局一份**(无 app_name)。
|
|||
// console 后台经 getServiceDB 写、home 的 echomeet 启动时 LoadOrSeed 读(改配置需重启生效)。
|
|||
// 密钥字段以 AES-256-GCM 密文落库(comm.Encrypt,${FIELD_ENCRYPT_KEY},console 与业务侧同一把 key),
|
|||
// 返回前端脱敏为 EncMask。字段清单由 EchoSvcCatalog 数据驱动(哪些字段、类型、是否密钥)。
|
|||
|
|||
// 服务类别。
|
|||
const ( |
|||
EchoSvcCatASR = 1 // 识别(转写)
|
|||
EchoSvcCatMT = 2 // 翻译
|
|||
EchoSvcCatLLM = 3 // 总结(文本大模型)
|
|||
) |
|||
|
|||
// EchoSvcConfig 一行 = 某类别下一条服务/模型条目。存应用业务库,全局一份。
|
|||
type EchoSvcConfig struct { |
|||
Id string `gorm:"primaryKey;column:id;size:64" json:"id"` // 稳定键:asr_ali / mt_bytedance / llm_qwen_plus
|
|||
Category int32 `gorm:"column:category;index:idx_echosvc_cat" json:"category"` // 1 识别 / 2 翻译 / 3 总结
|
|||
Provider string `gorm:"column:provider;size:32" json:"provider"` // bytedance/ali/google/microsoft(识别/翻译);doubao/openai/gemini(总结)
|
|||
Name string `gorm:"column:name;size:128" json:"name"` // 展示名(总结类即模型友好名,客户端可见)
|
|||
Priority int32 `gorm:"column:priority" json:"priority"` // 同类内升序,小=优先
|
|||
Enabled bool `gorm:"column:enabled" json:"enabled"` // 是否启用
|
|||
SupportLangs string `gorm:"column:support_langs;type:text" json:"support_langs"` // 识别用:逗号分隔 BCP-47;空=按代码默认支持集
|
|||
Values map[string]string `gorm:"column:values;type:text;serializer:json" json:"values"` // 参数/凭据值;密钥字段存密文
|
|||
Createtime int64 `gorm:"column:createtime" json:"createtime"` |
|||
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` |
|||
} |
|||
|
|||
func (EchoSvcConfig) TableName() string { return TableEchomeetSvcConfig } |
|||
|
|||
// EchoSvcField 一个配置字段的元数据(驱动前端表单 + 后端密钥判定)。
|
|||
type EchoSvcField struct { |
|||
Key string `json:"key"` |
|||
Label string `json:"label"` |
|||
Type string `json:"type"` // text|password|textarea|url
|
|||
Secret bool `json:"secret"` |
|||
Required bool `json:"required"` |
|||
Placeholder string `json:"placeholder,omitempty"` |
|||
Desc string `json:"desc,omitempty"` |
|||
} |
|||
|
|||
// EchoSvcProviderDef 某类别下某服务商/模型实现的字段模板。
|
|||
type EchoSvcProviderDef struct { |
|||
Category int32 `json:"category"` // 所属类别
|
|||
Provider string `json:"provider"` // 存库 Provider 值
|
|||
Name string `json:"name"` // 展示名
|
|||
HasLangs bool `json:"has_langs"` // 是否展示「支持语言」(识别类为 true)
|
|||
Multi bool `json:"multi"` // 是否允许同 provider 多条(总结类为 true:一个服务商多个模型)
|
|||
Fields []EchoSvcField `json:"fields"` |
|||
} |
|||
|
|||
// 常用字段构造助手。
|
|||
func esfText(key, label string, required bool) EchoSvcField { |
|||
return EchoSvcField{Key: key, Label: label, Type: "text", Required: required} |
|||
} |
|||
func esfSecret(key, label string, required bool) EchoSvcField { |
|||
return EchoSvcField{Key: key, Label: label, Type: "password", Secret: true, Required: required} |
|||
} |
|||
|
|||
// EchoSvcCatalog 会议记录服务字段目录:识别/翻译/总结 × 各服务商,字段依据 home.yaml 的 echomeet 配置结构。
|
|||
var EchoSvcCatalog = []EchoSvcProviderDef{ |
|||
// ===== 识别(转写)=====
|
|||
{Category: EchoSvcCatASR, Provider: "bytedance", Name: "字节跳动", HasLangs: true, Fields: []EchoSvcField{ |
|||
esfText("AppID", "AppID", true), |
|||
esfSecret("Token", "Token", false), |
|||
esfText("Callback", "回调地址", false), |
|||
}}, |
|||
{Category: EchoSvcCatASR, Provider: "ali", Name: "阿里云 DashScope", HasLangs: true, Fields: []EchoSvcField{ |
|||
esfSecret("ApiKey", "ApiKey", true), |
|||
esfText("Callback", "回调地址", false), |
|||
}}, |
|||
{Category: EchoSvcCatASR, Provider: "google", Name: "Google Cloud", HasLangs: true, Fields: []EchoSvcField{ |
|||
{Key: "JsonPath", Label: "服务账号 JSON", Type: "textarea", Secret: true, Required: true, |
|||
Desc: "服务账号 JSON 内容或服务器上的文件路径"}, |
|||
}}, |
|||
{Category: EchoSvcCatASR, Provider: "microsoft", Name: "Azure 语音", HasLangs: true, Fields: []EchoSvcField{ |
|||
esfSecret("Key", "Key", true), |
|||
esfText("Region", "Region", true), |
|||
}}, |
|||
|
|||
// ===== 翻译 =====
|
|||
{Category: EchoSvcCatMT, Provider: "bytedance", Name: "火山翻译", Fields: []EchoSvcField{ |
|||
esfText("AccessKey", "AccessKey", true), |
|||
esfSecret("SecretKey", "SecretKey", true), |
|||
esfText("Region", "Region", false), |
|||
}}, |
|||
{Category: EchoSvcCatMT, Provider: "ali", Name: "阿里机器翻译", Fields: []EchoSvcField{ |
|||
esfText("AccessKeyId", "AccessKeyId", true), |
|||
esfSecret("AccessKeySecret", "AccessKeySecret", true), |
|||
esfText("Region", "Region", false), |
|||
}}, |
|||
{Category: EchoSvcCatMT, Provider: "google", Name: "Google 翻译", Fields: []EchoSvcField{ |
|||
{Key: "JsonPath", Label: "服务账号 JSON", Type: "textarea", Secret: true, Required: true, |
|||
Desc: "服务账号 JSON 内容或服务器上的文件路径"}, |
|||
}}, |
|||
{Category: EchoSvcCatMT, Provider: "microsoft", Name: "Azure 翻译", Fields: []EchoSvcField{ |
|||
esfSecret("Key", "Key", true), |
|||
esfText("Region", "Region", false), |
|||
esfText("Endpoint", "Endpoint", false), |
|||
}}, |
|||
|
|||
// ===== 总结(文本大模型;一个服务商可配多条不同模型)=====
|
|||
{Category: EchoSvcCatLLM, Provider: "doubao", Name: "豆包(字节)", Multi: true, Fields: []EchoSvcField{ |
|||
esfSecret("Apikey", "ApiKey", true), |
|||
esfText("Model", "模型 ID", true), |
|||
}}, |
|||
{Category: EchoSvcCatLLM, Provider: "openai", Name: "OpenAI 兼容端点(通义/Azure OpenAI 等)", Multi: true, Fields: []EchoSvcField{ |
|||
esfText("BaseURL", "BaseURL", true), |
|||
esfSecret("Token", "Token", true), |
|||
esfText("Model", "模型 ID", true), |
|||
}}, |
|||
{Category: EchoSvcCatLLM, Provider: "gemini", Name: "Gemini(Google)", Multi: true, Fields: []EchoSvcField{ |
|||
esfSecret("Apikey", "ApiKey", true), |
|||
esfText("Model", "模型 ID", true), |
|||
esfText("Endpoint", "自定义 Endpoint(可选)", false), |
|||
}}, |
|||
} |
|||
|
|||
// FindEchoSvcDef 按 (类别, provider) 取字段模板;找不到返回 nil。
|
|||
func FindEchoSvcDef(category int32, provider string) *EchoSvcProviderDef { |
|||
for i := range EchoSvcCatalog { |
|||
if EchoSvcCatalog[i].Category == category && EchoSvcCatalog[i].Provider == provider { |
|||
return &EchoSvcCatalog[i] |
|||
} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// EchoSvcFieldIsSecret 判断某 (类别,provider,字段) 是否密钥字段。目录优先,回退通用命名规则。
|
|||
func EchoSvcFieldIsSecret(category int32, provider, key string) bool { |
|||
if def := FindEchoSvcDef(category, provider); def != nil { |
|||
for _, f := range def.Fields { |
|||
if f.Key == key { |
|||
return f.Secret |
|||
} |
|||
} |
|||
} |
|||
return IsSecretField(key) |
|||
} |
|||
|
|||
// EncryptEchoValues 返回 values 的副本,把密钥字段加密(空值/已是 EncMask 的不动,交由调用方决定)。
|
|||
func EncryptEchoValues(encKey string, category int32, provider string, values map[string]string) map[string]string { |
|||
out := make(map[string]string, len(values)) |
|||
for k, v := range values { |
|||
if EchoSvcFieldIsSecret(category, provider, k) && v != "" && v != EncMask { |
|||
if ct, err := Encrypt(encKey, v); err == nil { |
|||
out[k] = ct |
|||
continue |
|||
} |
|||
} |
|||
out[k] = v |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// DecryptEchoValues 返回 values 的副本,把密钥字段解密(供运行时消费)。
|
|||
func DecryptEchoValues(encKey string, category int32, provider string, values map[string]string) map[string]string { |
|||
out := make(map[string]string, len(values)) |
|||
for k, v := range values { |
|||
if EchoSvcFieldIsSecret(category, provider, k) && v != "" { |
|||
plain, err := Decrypt(encKey, v) |
|||
if err == nil { |
|||
out[k] = plain |
|||
continue |
|||
} |
|||
// 仍沿用密文(保持既有行为),但必须留痕:否则密文会被当明文用作第三方 API key,
|
|||
// 表现为鉴权失败,而后台显示"已配置",无从排查。
|
|||
log.Errorf("[EchoSvcConfig] 密钥字段解密失败 category=%d provider=%s key=%s err=%v —— 检查本服务 ${FIELD_ENCRYPT_KEY} 是否与 console 完全一致", category, provider, k, err) |
|||
} |
|||
out[k] = v |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// MaskEchoValues 返回 values 的副本,把密钥字段脱敏为 EncMask(返回前端用)。
|
|||
func MaskEchoValues(category int32, provider string, values map[string]string) map[string]string { |
|||
out := make(map[string]string, len(values)) |
|||
for k, v := range values { |
|||
if EchoSvcFieldIsSecret(category, provider, k) && v != "" { |
|||
out[k] = EncMask |
|||
continue |
|||
} |
|||
out[k] = v |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// EnsureEchoSvcTable 建表(幂等)。db 为应用业务库。
|
|||
func EnsureEchoSvcTable(db mysql.ISys) error { |
|||
if db == nil { |
|||
return nil |
|||
} |
|||
return db.CreateTable(TableEchomeetSvcConfig, &EchoSvcConfig{}) |
|||
} |
|||
|
|||
// LoadEchoSvcConfigs 读全部会议记录服务配置行(未解密)。
|
|||
func LoadEchoSvcConfigs(db mysql.ISys) ([]*EchoSvcConfig, error) { |
|||
if db == nil { |
|||
return nil, nil |
|||
} |
|||
rows := make([]*EchoSvcConfig, 0) |
|||
if err := db.Find(TableEchomeetSvcConfig, &rows, ""); err != nil { |
|||
return nil, err |
|||
} |
|||
return rows, nil |
|||
} |
|||
@ -0,0 +1,218 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// ============================ 会议记录服务配置 (echomeetsvc) ============================
|
|||
//
|
|||
// admin「会议记录服务配置」页的后端:读写**应用业务库**里的 echomeet_svc_config 表
|
|||
// (识别/翻译/总结三类,各自按优先级;识别可配支持语言;总结为具体模型条目)。全局一份、无 app_name。
|
|||
//
|
|||
// 业务库连接按部署 appId 从 registry 动态取(与 modulecfg 同机制);
|
|||
// 密钥字段(是否密钥以 comm.EchoSvcCatalog 为准)AES-GCM 加密落库、返回前端脱敏为 comm.EncMask。
|
|||
// 改配置后广播 ConfigKindEchomeetSvc;home 侧 echomeet 改配置需重启生效。
|
|||
|
|||
// echoSvcCatalog 返回会议记录服务字段目录(前端据此渲染各服务商表单、识别语言选择器)。与选中应用无关。
|
|||
func (this *serverComp) echoSvcCatalog(c *gin.Context) { |
|||
writeOK(c, gin.H{ |
|||
"catalog": comm.EchoSvcCatalog, |
|||
"categories": []gin.H{ |
|||
{"value": comm.EchoSvcCatASR, "label": "识别"}, |
|||
{"value": comm.EchoSvcCatMT, "label": "翻译"}, |
|||
{"value": comm.EchoSvcCatLLM, "label": "总结"}, |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
// echoSvcList 列出某部署业务库里的会议记录服务配置;密钥脱敏。
|
|||
func (this *serverComp) echoSvcList(c *gin.Context) { |
|||
var req struct { |
|||
AppId uint32 `json:"app_id"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
if req.AppId == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填") |
|||
return |
|||
} |
|||
db, err := this.module.registry.getServiceDB(req.AppId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|||
return |
|||
} |
|||
if err := comm.EnsureEchoSvcTable(db); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
rows, err := comm.LoadEchoSvcConfigs(db) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
for _, r := range rows { |
|||
r.Values = comm.MaskEchoValues(r.Category, r.Provider, r.Values) |
|||
} |
|||
writeOK(c, gin.H{"items": rows}) |
|||
} |
|||
|
|||
// echoSvcSave 保存单条服务配置:新增/更新(按 id)。
|
|||
// 是否密钥以目录为准(不信前端);密钥字段回传占位/空则保留原密文,否则加密新明文;目录外的键忽略。
|
|||
func (this *serverComp) echoSvcSave(c *gin.Context) { |
|||
var req struct { |
|||
AppId uint32 `json:"app_id"` |
|||
Config comm.EchoSvcConfig `json:"config"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
cfg := req.Config |
|||
cfg.Id = strings.TrimSpace(cfg.Id) |
|||
cfg.Provider = strings.TrimSpace(cfg.Provider) |
|||
if req.AppId == 0 || cfg.Id == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 与 id 必填") |
|||
return |
|||
} |
|||
def := comm.FindEchoSvcDef(cfg.Category, cfg.Provider) |
|||
if def == nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "未知的服务类别/服务商组合") |
|||
return |
|||
} |
|||
db, err := this.module.registry.getServiceDB(req.AppId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|||
return |
|||
} |
|||
if err := comm.EnsureEchoSvcTable(db); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
|
|||
// 取旧行以保留密钥密文。
|
|||
old := &comm.EchoSvcConfig{} |
|||
hasOld := db.FindOne(comm.TableEchomeetSvcConfig, old, "id=?", cfg.Id) == nil && old.Id != "" |
|||
|
|||
// 合法字段过滤 + 密钥处理。
|
|||
allowed := make(map[string]bool, len(def.Fields)) |
|||
for _, f := range def.Fields { |
|||
allowed[f.Key] = true |
|||
} |
|||
encKey := this.options.EncryptKey |
|||
stored := make(map[string]string, len(cfg.Values)) |
|||
for k, v := range cfg.Values { |
|||
if !allowed[k] { |
|||
continue |
|||
} |
|||
if comm.EchoSvcFieldIsSecret(cfg.Category, cfg.Provider, k) { |
|||
if v == "" || v == comm.EncMask { |
|||
if hasOld { |
|||
stored[k] = old.Values[k] // 保留原密文
|
|||
} |
|||
continue |
|||
} |
|||
ct, ee := comm.Encrypt(encKey, v) |
|||
if ee != nil { |
|||
writeErr(c, pb.ErrorCode_SystemError, "加密失败: "+ee.Error()) |
|||
return |
|||
} |
|||
stored[k] = ct |
|||
continue |
|||
} |
|||
stored[k] = v |
|||
} |
|||
cfg.Values = stored |
|||
|
|||
now := time.Now().Unix() |
|||
cfg.Updatetime = now |
|||
if hasOld { |
|||
cfg.Createtime = old.Createtime |
|||
if e := db.Save(comm.TableEchomeetSvcConfig, &cfg); e != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, e.Error()) |
|||
return |
|||
} |
|||
} else { |
|||
cfg.Createtime = now |
|||
if e := db.Insert(comm.TableEchomeetSvcConfig, &cfg); e != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, e.Error()) |
|||
return |
|||
} |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "update", 0) |
|||
writeOK(c, gin.H{"id": cfg.Id}) |
|||
} |
|||
|
|||
// echoSvcDel 删除一条服务配置。
|
|||
func (this *serverComp) echoSvcDel(c *gin.Context) { |
|||
var req struct { |
|||
AppId uint32 `json:"app_id"` |
|||
Id string `json:"id"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
req.Id = strings.TrimSpace(req.Id) |
|||
if req.AppId == 0 || req.Id == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 与 id 必填") |
|||
return |
|||
} |
|||
db, err := this.module.registry.getServiceDB(req.AppId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|||
return |
|||
} |
|||
if err := db.Delete(comm.TableEchomeetSvcConfig, "id=?", req.Id); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "delete", 0) |
|||
writeOK(c, gin.H{"deleted": req.Id}) |
|||
} |
|||
|
|||
// echoSvcReorder 批量调整同类内优先级(拖拽排序后提交)。
|
|||
func (this *serverComp) echoSvcReorder(c *gin.Context) { |
|||
var req struct { |
|||
AppId uint32 `json:"app_id"` |
|||
Items []struct { |
|||
Id string `json:"id"` |
|||
Priority int32 `json:"priority"` |
|||
} `json:"items"` |
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
if req.AppId == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填") |
|||
return |
|||
} |
|||
db, err := this.module.registry.getServiceDB(req.AppId) |
|||
if err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, "连接应用业务库失败: "+err.Error()) |
|||
return |
|||
} |
|||
now := time.Now().Unix() |
|||
updated := 0 |
|||
for _, it := range req.Items { |
|||
id := strings.TrimSpace(it.Id) |
|||
if id == "" { |
|||
continue |
|||
} |
|||
row := &comm.EchoSvcConfig{} |
|||
if e := db.FindOne(comm.TableEchomeetSvcConfig, row, "id=?", id); e != nil || row.Id == "" { |
|||
continue |
|||
} |
|||
row.Priority = it.Priority |
|||
row.Updatetime = now |
|||
if e := db.Save(comm.TableEchomeetSvcConfig, row); e != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, e.Error()) |
|||
return |
|||
} |
|||
updated++ |
|||
} |
|||
broadcastConfigChanged(comm.ConfigKindEchomeetSvc, "update", 0) |
|||
writeOK(c, gin.H{"updated": updated}) |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
package echomeet |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/pb" |
|||
) |
|||
|
|||
// @Summary 获取可选的总结文本大模型清单
|
|||
// @Description 返回后台配置的、按优先级排序的启用总结模型条目,供客户端在发起转写/重新总结时选择(summary_model)。
|
|||
// @Tags Echomeet
|
|||
// @Accept json
|
|||
// @Produce json
|
|||
// @Security BearerAuth
|
|||
// @Param user body pb.EchomeetGetSummaryModelsReq true "请求数据"
|
|||
// @Success 200 {object} comm.HttpResult{data=pb.EchomeetGetSummaryModelsResp} "响应数据"
|
|||
// @Router /api/home/echomeet_getsummarymodels [post]
|
|||
func (this *apiComp) GetSummaryModels(session comm.IUserSession, req *pb.EchomeetGetSummaryModelsReq) (resp *pb.EchomeetGetSummaryModelsResp, errdata *pb.ErrorData) { |
|||
models := this.module.providers.ListSummaryModels() |
|||
out := make([]*pb.SummaryModelInfo, 0, len(models)) |
|||
for i, m := range models { |
|||
out = append(out, &pb.SummaryModelInfo{ |
|||
Id: m.Id, |
|||
Name: m.Name, |
|||
Provider: m.Provider, |
|||
IsDefault: i == 0, |
|||
}) |
|||
} |
|||
resp = &pb.EchomeetGetSummaryModelsResp{Models: out} |
|||
return |
|||
} |
|||
@ -0,0 +1,466 @@ |
|||
package echomeet |
|||
|
|||
import ( |
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/log" |
|||
"yunyan/lego/sys/mysql" |
|||
"yunyan/pb" |
|||
"yunyan/sys/doubao" |
|||
"yunyan/sys/google/gemini" |
|||
"yunyan/sys/openai" |
|||
"fmt" |
|||
"os" |
|||
"sort" |
|||
"strings" |
|||
"time" |
|||
) |
|||
|
|||
// 会议记录服务配置的运行时消费(数据模型见 comm/echomeetsvc.go)。
|
|||
//
|
|||
// 启动流程(providersComp.Init 末尾调用 loadOrSeedSvcConfig):
|
|||
// 1. 建表(应用业务库,全局一份);
|
|||
// 2. 库空 → 把当前 yaml(Options) 的四套服务商 seed 成识别/翻译/总结行(密钥加密落库);
|
|||
// 3. 加载识别/翻译两类的启用清单,按 priority 升序,供选择逻辑使用。
|
|||
//
|
|||
// 选择逻辑:
|
|||
// - 识别 selectASR(lang):按优先级取第一个「支持该语言」的服务(支持语言以库里 SupportLangs 为准,
|
|||
// 为空则回退各 Transcriber.SupportsLanguage)——这修复了「西语被强路由到不支持西语的阿里 paraformer-v2」。
|
|||
// - 翻译 selectMT(from,to):按优先级取第一个启用的服务。
|
|||
//
|
|||
// 凭据托管:seed 会把 yaml 里的 AK/SK/Token 等一并写入库(密钥字段 AES 落库),
|
|||
// 便于后台面板管理;SDK 侧改读库值将在后续阶段接入(当前仍由 yaml 初始化,行为不变)。
|
|||
|
|||
// paraformerV2Langs 阿里 DashScope paraformer-v2 录音文件识别真实支持的语种(BCP-47)。
|
|||
// 依据阿里官方文档:中文(及方言)/英文/日语/韩语/德语/法语/俄语——**不含西班牙语等**。
|
|||
// 作为阿里识别行 SupportLangs 的 seed 默认值;用户可在后台面板增删。
|
|||
var paraformerV2Langs = []string{ |
|||
"zh-CN", "zh-TW", "zh-HK", "yue-HK", |
|||
"en-US", "en-GB", "ja-JP", "ko-KR", |
|||
"de-DE", "fr-FR", "ru-RU", "ru-UA", |
|||
} |
|||
|
|||
// providerToEnum 把库里的 provider 字符串(识别/翻译类)映射回 TranscribeService 枚举。
|
|||
func providerToEnum(provider string) pb.TranscribeService { |
|||
switch provider { |
|||
case "bytedance": |
|||
return pb.TranscribeService_TranscribeService_ByteDance |
|||
case "ali": |
|||
return pb.TranscribeService_TranscribeService_Ali |
|||
case "google": |
|||
return pb.TranscribeService_TranscribeService_Google |
|||
case "microsoft": |
|||
return pb.TranscribeService_TranscribeService_Microsoft |
|||
default: |
|||
return pb.TranscribeService_TranscribeService_Unknown |
|||
} |
|||
} |
|||
|
|||
// loadOrSeedSvcConfig 建表 + 库空则 seed + 加载识别/翻译优先级清单。best-effort,失败仅记录日志。
|
|||
func (this *providersComp) loadOrSeedSvcConfig(opt *Options) { |
|||
db := mysql.GetSys() |
|||
if db == nil { |
|||
return |
|||
} |
|||
if err := comm.EnsureEchoSvcTable(db); err != nil { |
|||
this.module.Errorf("echomeet svcconfig 建表失败 err:%v", err) |
|||
return |
|||
} |
|||
rows, err := comm.LoadEchoSvcConfigs(db) |
|||
if err != nil { |
|||
this.module.Errorf("echomeet svcconfig 读取失败 err:%v", err) |
|||
return |
|||
} |
|||
if len(rows) == 0 { |
|||
if rows, err = this.seedSvcConfig(db, opt); err != nil { |
|||
this.module.Errorf("echomeet svcconfig seed 失败 err:%v", err) |
|||
return |
|||
} |
|||
this.module.Infof("echomeet svcconfig 首次 seed 完成 rows:%d", len(rows)) |
|||
} |
|||
|
|||
// 统一把 Values 解密为明文供运行时消费(seed 返回的本就是明文,Decrypt 对明文会失败并保留原值,安全)。
|
|||
encKey := os.Getenv("FIELD_ENCRYPT_KEY") |
|||
for _, r := range rows { |
|||
r.Values = comm.DecryptEchoValues(encKey, r.Category, r.Provider, r.Values) |
|||
} |
|||
|
|||
// 分类归集 + 优先级排序(仅启用项)。
|
|||
asr := make([]*comm.EchoSvcConfig, 0) |
|||
mt := make([]*comm.EchoSvcConfig, 0) |
|||
llm := make([]*comm.EchoSvcConfig, 0) |
|||
for _, r := range rows { |
|||
if !r.Enabled { |
|||
continue |
|||
} |
|||
switch r.Category { |
|||
case comm.EchoSvcCatASR: |
|||
asr = append(asr, r) |
|||
case comm.EchoSvcCatMT: |
|||
mt = append(mt, r) |
|||
case comm.EchoSvcCatLLM: |
|||
llm = append(llm, r) |
|||
} |
|||
} |
|||
sort.SliceStable(asr, func(i, j int) bool { return asr[i].Priority < asr[j].Priority }) |
|||
sort.SliceStable(mt, func(i, j int) bool { return mt[i].Priority < mt[j].Priority }) |
|||
sort.SliceStable(llm, func(i, j int) bool { return llm[i].Priority < llm[j].Priority }) |
|||
this.asrOrder = asr |
|||
this.mtOrder = mt |
|||
this.llmOrder = llm |
|||
|
|||
// 用库值(凭据来自库)重建各服务商的转写/翻译/总结实例;总结按条目建实例支持一商多模型。
|
|||
this.buildProvidersFromDB(rows, opt) |
|||
|
|||
this.svcLoaded = true |
|||
this.module.Infof("echomeet svcconfig 加载完成 识别:%d 翻译:%d 总结:%d", len(asr), len(mt), len(llm)) |
|||
} |
|||
|
|||
// buildProvidersFromDB 用库里的(已解密)配置重建 provider:
|
|||
// - 识别/翻译:逐服务商用库值重新初始化底层 sys(凭据托管到后台的落点),覆盖 enum→provider 映射;某项缺行则回退 yaml。
|
|||
// - 总结:每条启用的 LLM 行建一个模型实例(key=条目 id),客户端按 id 选择;同时按源服务商回填 enum→summarizer 保持旧调用兼容。
|
|||
func (this *providersComp) buildProvidersFromDB(rows []*comm.EchoSvcConfig, opt *Options) { |
|||
byId := make(map[string]*comm.EchoSvcConfig, len(rows)) |
|||
for _, r := range rows { |
|||
byId[r.Id] = r |
|||
} |
|||
type provSeed struct { |
|||
name string |
|||
enum pb.TranscribeService |
|||
po ProviderOptions |
|||
} |
|||
provs := []provSeed{ |
|||
{"bytedance", pb.TranscribeService_TranscribeService_ByteDance, opt.ByteDance}, |
|||
{"ali", pb.TranscribeService_TranscribeService_Ali, opt.Ali}, |
|||
{"google", pb.TranscribeService_TranscribeService_Google, opt.Google}, |
|||
{"microsoft", pb.TranscribeService_TranscribeService_Microsoft, opt.Microsoft}, |
|||
} |
|||
for _, p := range provs { |
|||
po := p.po // 缺行回退 yaml
|
|||
if r := byId["asr_"+p.name]; r != nil { |
|||
po.Transcribe = toIfaceMap(r.Values) |
|||
if cb := r.Values["Callback"]; cb != "" { |
|||
po.Callback = cb |
|||
} |
|||
} |
|||
if r := byId["mt_"+p.name]; r != nil { |
|||
po.Translate = toIfaceMap(r.Values) |
|||
} |
|||
if r := byId["llm_"+p.name]; r != nil { |
|||
po.Summary = summaryFromValues(r.Provider, r.Values) |
|||
} |
|||
t, tr, sm := buildProviderSet(p.name, po) |
|||
if t != nil { |
|||
this.transcribers[p.enum] = t |
|||
} |
|||
if tr != nil { |
|||
this.translators[p.enum] = tr |
|||
} |
|||
if sm != nil { |
|||
this.summarizers[p.enum] = sm |
|||
} |
|||
} |
|||
// 总结模型实例(一个服务商可多条不同模型)。
|
|||
this.summarizersById = make(map[string]Summarizer) |
|||
for _, r := range rows { |
|||
if r.Category != comm.EchoSvcCatLLM || !r.Enabled { |
|||
continue |
|||
} |
|||
if s := buildSummarizer(r.Provider, r.Values); s != nil { |
|||
this.summarizersById[r.Id] = s |
|||
} |
|||
} |
|||
} |
|||
|
|||
// buildProviderSet 按服务商名调对应的成套初始化(从 DB 值构造的 ProviderOptions)。
|
|||
func buildProviderSet(name string, po ProviderOptions) (Transcriber, Translator, Summarizer) { |
|||
switch name { |
|||
case "bytedance": |
|||
return newByteDanceProviders(po) |
|||
case "ali": |
|||
return newAliProviders(po) |
|||
case "google": |
|||
return newGoogleProviders(po) |
|||
case "microsoft": |
|||
return newMicrosoftProviders(po) |
|||
} |
|||
return nil, nil, nil |
|||
} |
|||
|
|||
// buildSummarizer 按总结条目的 provider(doubao/openai/gemini) + 值构造一个总结模型实例。
|
|||
func buildSummarizer(provider string, v map[string]string) Summarizer { |
|||
switch provider { |
|||
case "doubao": |
|||
sys, err := doubao.NewSys(doubao.SetApikey(v["Apikey"]), doubao.SetModel(v["Model"])) |
|||
if err != nil { |
|||
log.Errorf("echomeet buildSummarizer doubao err:%v", err) |
|||
return nil |
|||
} |
|||
return &doubaoSummarizer{sys: sys} |
|||
case "openai": |
|||
sys, err := openai.NewSys(openai.SetBaseURL(v["BaseURL"]), openai.SetToken(v["Token"]), openai.SetModel(v["Model"]), openai.SetMaxfunccall(1)) |
|||
if err != nil { |
|||
log.Errorf("echomeet buildSummarizer openai err:%v", err) |
|||
return nil |
|||
} |
|||
return &aliSummarizer{sys: sys} // aliSummarizer 是通用 OpenAI 兼容端点包装
|
|||
case "gemini": |
|||
sys, err := gemini.NewSys(gemini.SetApikey(v["Apikey"]), gemini.SetModel(v["Model"]), gemini.SetEndpoint(v["Endpoint"])) |
|||
if err != nil { |
|||
log.Errorf("echomeet buildSummarizer gemini err:%v", err) |
|||
return nil |
|||
} |
|||
return &geminiSummarizer{sys: sys} |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// summaryFromValues 把库 Values 还原成 SummaryOptions(summaryToValues 的逆过程)。
|
|||
func summaryFromValues(provider string, v map[string]string) SummaryOptions { |
|||
switch provider { |
|||
case "doubao": |
|||
return SummaryOptions{Apikey: v["Apikey"], Model: v["Model"]} |
|||
case "gemini": |
|||
return SummaryOptions{Apikey: v["Apikey"], Model: v["Model"], Endpoint: v["Endpoint"]} |
|||
case "openai": |
|||
return SummaryOptions{BaseURL: v["BaseURL"], Token: v["Token"], Model: v["Model"]} |
|||
} |
|||
return SummaryOptions{} |
|||
} |
|||
|
|||
// toIfaceMap 把 map[string]string 转成底层 sys OnInit 需要的 map[string]interface{}。
|
|||
func toIfaceMap(m map[string]string) map[string]interface{} { |
|||
out := make(map[string]interface{}, len(m)) |
|||
for k, v := range m { |
|||
out[k] = v |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// GetSummarizerById 按条目 id 取总结模型实例;空或未命中回退优先级最高的启用条目。
|
|||
func (this *providersComp) GetSummarizerById(id string) Summarizer { |
|||
if id != "" && this.summarizersById != nil { |
|||
if s, ok := this.summarizersById[id]; ok && s != nil { |
|||
return s |
|||
} |
|||
} |
|||
if len(this.llmOrder) > 0 && this.summarizersById != nil { |
|||
return this.summarizersById[this.llmOrder[0].Id] |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// DefaultSummaryId 返回优先级最高的启用总结条目 id(无则空)。
|
|||
func (this *providersComp) DefaultSummaryId() string { |
|||
if len(this.llmOrder) > 0 { |
|||
return this.llmOrder[0].Id |
|||
} |
|||
return "" |
|||
} |
|||
|
|||
// ListSummaryModels 返回按优先级排序的启用总结条目(供客户端选择)。
|
|||
func (this *providersComp) ListSummaryModels() []*comm.EchoSvcConfig { |
|||
return this.llmOrder |
|||
} |
|||
|
|||
// seedSvcConfig 把 yaml(Options) 的四套服务商映射为识别/翻译/总结行并落库;返回落库后的行(Values 为明文,供内存直接用)。
|
|||
func (this *providersComp) seedSvcConfig(db mysql.ISys, opt *Options) ([]*comm.EchoSvcConfig, error) { |
|||
encKey := os.Getenv("FIELD_ENCRYPT_KEY") |
|||
now := time.Now().Unix() |
|||
// provider 顺序即默认优先级(字节→阿里→Google→微软,留间隔便于后台调序)。
|
|||
type provSeed struct { |
|||
provider string |
|||
prio int32 |
|||
po ProviderOptions |
|||
} |
|||
provs := []provSeed{ |
|||
{"bytedance", 10, opt.ByteDance}, |
|||
{"ali", 20, opt.Ali}, |
|||
{"google", 30, opt.Google}, |
|||
{"microsoft", 40, opt.Microsoft}, |
|||
} |
|||
|
|||
rows := make([]*comm.EchoSvcConfig, 0, len(provs)*3) |
|||
add := func(r *comm.EchoSvcConfig) { |
|||
r.Createtime, r.Updatetime = now, now |
|||
rows = append(rows, r) |
|||
} |
|||
for _, p := range provs { |
|||
// —— 识别 ——
|
|||
asrVals := mapToStrings(p.po.Transcribe) |
|||
if p.po.Callback != "" { |
|||
asrVals["Callback"] = p.po.Callback |
|||
} |
|||
add(&comm.EchoSvcConfig{ |
|||
Id: "asr_" + p.provider, Category: comm.EchoSvcCatASR, Provider: p.provider, |
|||
Name: providerDisplayName(comm.EchoSvcCatASR, p.provider), Priority: p.prio, |
|||
Enabled: hasCreds(asrVals), SupportLangs: seedSupportLangs(p.provider), Values: asrVals, |
|||
}) |
|||
// —— 翻译 ——
|
|||
mtVals := mapToStrings(p.po.Translate) |
|||
add(&comm.EchoSvcConfig{ |
|||
Id: "mt_" + p.provider, Category: comm.EchoSvcCatMT, Provider: p.provider, |
|||
Name: providerDisplayName(comm.EchoSvcCatMT, p.provider), Priority: p.prio, |
|||
Enabled: hasCreds(mtVals), Values: mtVals, |
|||
}) |
|||
// —— 总结(文本大模型)——
|
|||
if llmProvider, llmVals := summaryToValues(p.provider, p.po.Summary); llmVals != nil { |
|||
add(&comm.EchoSvcConfig{ |
|||
Id: "llm_" + p.provider, Category: comm.EchoSvcCatLLM, Provider: llmProvider, |
|||
Name: summaryDisplayName(p.provider, p.po.Summary.Model), Priority: p.prio, |
|||
Enabled: p.po.Summary.Model != "", Values: llmVals, |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// 落库:密钥字段加密。内存返回明文副本。
|
|||
for _, r := range rows { |
|||
enc := *r |
|||
enc.Values = comm.EncryptEchoValues(encKey, r.Category, r.Provider, r.Values) |
|||
if err := db.Insert(comm.TableEchomeetSvcConfig, &enc); err != nil { |
|||
return nil, err |
|||
} |
|||
} |
|||
return rows, nil |
|||
} |
|||
|
|||
// selectASR 按优先级 + 支持语言选识别服务;无命中回退旧的 ResolveServiceType。
|
|||
func (this *providersComp) selectASR(lang string) pb.TranscribeService { |
|||
if this.svcLoaded { |
|||
for _, r := range this.asrOrder { |
|||
if this.asrSupportsLang(r, lang) { |
|||
if svc := providerToEnum(r.Provider); svc != pb.TranscribeService_TranscribeService_Unknown { |
|||
return svc |
|||
} |
|||
} |
|||
} |
|||
} |
|||
// 回退:沿用旧的按语言自动路由(字节支持则字节,否则阿里)。
|
|||
return this.ResolveServiceType(pb.TranscribeService_TranscribeService_Auto, lang) |
|||
} |
|||
|
|||
// selectMT 按优先级选翻译服务;无命中回退到给定识别服务(保持三段同源的旧行为)。
|
|||
func (this *providersComp) selectMT(from, to string, fallback pb.TranscribeService) pb.TranscribeService { |
|||
if this.svcLoaded { |
|||
for _, r := range this.mtOrder { |
|||
if svc := providerToEnum(r.Provider); svc != pb.TranscribeService_TranscribeService_Unknown { |
|||
if _, ok := this.translators[svc]; ok { |
|||
return svc |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return fallback |
|||
} |
|||
|
|||
// asrSupportsLang 判断某识别行是否支持该语言:库里 SupportLangs 非空以其为准,否则回退 Transcriber.SupportsLanguage。
|
|||
func (this *providersComp) asrSupportsLang(r *comm.EchoSvcConfig, lang string) bool { |
|||
if strings.TrimSpace(r.SupportLangs) != "" { |
|||
return langInCSV(r.SupportLangs, lang) |
|||
} |
|||
if t, ok := this.transcribers[providerToEnum(r.Provider)]; ok && t != nil { |
|||
return t.SupportsLanguage(lang) |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// ==================== seed 辅助 ====================
|
|||
|
|||
// mapToStrings 把 yaml 的 map[string]interface{} 值统一转成字符串。
|
|||
func mapToStrings(m map[string]interface{}) map[string]string { |
|||
out := make(map[string]string, len(m)) |
|||
for k, v := range m { |
|||
if v == nil { |
|||
continue |
|||
} |
|||
if s, ok := v.(string); ok { |
|||
out[k] = s |
|||
} else { |
|||
out[k] = fmt.Sprintf("%v", v) |
|||
} |
|||
} |
|||
return out |
|||
} |
|||
|
|||
// summaryToValues 把结构化的 SummaryOptions 转成库 Values,并返回该总结条目的 provider(doubao/openai/gemini)。
|
|||
func summaryToValues(sourceProvider string, s SummaryOptions) (provider string, values map[string]string) { |
|||
switch sourceProvider { |
|||
case "bytedance": |
|||
return "doubao", map[string]string{"Apikey": s.Apikey, "Model": s.Model} |
|||
case "google": |
|||
return "gemini", map[string]string{"Apikey": s.Apikey, "Model": s.Model, "Endpoint": s.Endpoint} |
|||
case "ali", "microsoft": |
|||
return "openai", map[string]string{"BaseURL": s.BaseURL, "Token": s.Token, "Model": s.Model} |
|||
} |
|||
return "", nil |
|||
} |
|||
|
|||
// hasCreds 判断一组配置是否有实质凭据(任一非空即视为已配)。
|
|||
func hasCreds(vals map[string]string) bool { |
|||
for k, v := range vals { |
|||
if k == "Region" || k == "Endpoint" || k == "Callback" { |
|||
continue // 这些是环境/地址项,不算凭据
|
|||
} |
|||
if strings.TrimSpace(v) != "" { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
// seedSupportLangs 识别行的支持语言默认 seed 值。
|
|||
func seedSupportLangs(provider string) string { |
|||
switch provider { |
|||
case "ali": |
|||
// 阿里 paraformer-v2 真实支持集(不含西语等)——从根上避免西语被路由到阿里后转写空。
|
|||
return strings.Join(paraformerV2Langs, ",") |
|||
case "bytedance": |
|||
return strings.Join(byteDanceSupportLangs(), ",") |
|||
default: |
|||
return "" // Google/微软支持较广,留空表示按代码默认(SupportsLanguage=true)
|
|||
} |
|||
} |
|||
|
|||
// byteDanceSupportLangs 从 bcp47ToByteDance 收集字节支持的 BCP-47 语种。
|
|||
func byteDanceSupportLangs() []string { |
|||
out := make([]string, 0, len(bcp47ToByteDance)) |
|||
for k := range bcp47ToByteDance { |
|||
if k != "" { |
|||
out = append(out, k) |
|||
} |
|||
} |
|||
sort.Strings(out) |
|||
return out |
|||
} |
|||
|
|||
// langInCSV 判断 lang 是否在逗号分隔集合内(精确匹配,或按基础子标签匹配,如 es 命中 es-ES)。
|
|||
func langInCSV(csv, lang string) bool { |
|||
if lang == "" { |
|||
return false |
|||
} |
|||
base := baseLang(lang) |
|||
for _, item := range strings.Split(csv, ",") { |
|||
item = strings.TrimSpace(item) |
|||
if item == "" { |
|||
continue |
|||
} |
|||
if strings.EqualFold(item, lang) || strings.EqualFold(baseLang(item), base) { |
|||
return true |
|||
} |
|||
} |
|||
return false |
|||
} |
|||
|
|||
func providerDisplayName(category int32, provider string) string { |
|||
if def := comm.FindEchoSvcDef(category, provider); def != nil { |
|||
return def.Name |
|||
} |
|||
return provider |
|||
} |
|||
|
|||
func summaryDisplayName(sourceProvider, model string) string { |
|||
name := providerDisplayName(comm.EchoSvcCatASR, sourceProvider) |
|||
if model != "" { |
|||
return name + "(" + model + ")" |
|||
} |
|||
return name |
|||
} |
|||
Loading…
Reference in new issue