Browse Source
后台恢复了会议转写段与总结段的配置入口——它们随「会议记录服务」页一起下线后, 只能直接改库(echomeet_orch),而那正是语音纪要整条流水线的服务选型来源。 - 路由 /meettemplates → /meetconfig,旧地址留重定向;**菜单 id 不变**: 存量后台账号的 access 清单里存的是这个串,改 id 等于让这些账号丢入口; - 一页全展开不分标签页,顶部一个应用选择器三块共用——各拉各的会出现 「转写配的是 A、模板改的是 B」这种对不上的状态; - 保存是声明式的,asr/llm 都是**指针**:缺哪个不动哪段。写成值类型的话 「保存会议总结」会带一个空的 asr 数组把转写编排整段清空; - 翻译段(kind=2) 本页不编辑但保存时必须原样保留,跟着 reconcile 会让纪要 能转写、译文整体消失; - 服务池查询是 app_name=? 不带 OR app_name='':作用域归一后没有全局默认层了, 照旧版写回退查询会让一个应用读到另一个应用的服务选型,且不报任何错。 段→服务类别的映射由 TestEchoKindSvcCats 钉住:类别编号是 comm / console / admin 三处同写的,漏改一处的表现是某一段的下拉空空如也且不报错。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
8 changed files with 775 additions and 49 deletions
@ -0,0 +1,383 @@ |
|||
<template> |
|||
<div> |
|||
<!-- 页头 + 应用选择:三块配置共用一个作用域 --> |
|||
<div class="flex flex-wrap items-start justify-between gap-3 mb-4"> |
|||
<div> |
|||
<h2 class="text-lg font-semibold text-slate-800">会议配置</h2> |
|||
<p class="text-xs text-slate-400 mt-1 max-w-3xl"> |
|||
语音纪要这条流水线的应用级参数:<b>会议转写</b>用哪家录音文件识别、<b>会议总结</b>用哪个大模型、 |
|||
以及<b>会议模板</b>。都<b>按应用各一套</b>,互不干涉。 |
|||
</p> |
|||
</div> |
|||
<div class="flex items-center gap-2"> |
|||
<span class="text-sm text-slate-500">应用</span> |
|||
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40"> |
|||
<option value="" disabled>请选择应用</option> |
|||
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option> |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="!scopeApp" class="panel py-16 text-center text-slate-400 text-sm"> |
|||
请先在右上角选择应用。 |
|||
</div> |
|||
|
|||
<template v-else> |
|||
<!-- 上半区:左「会议转写」右「会议总结」,窄屏自动叠成一列 --> |
|||
<div class="topgrid"> |
|||
<!-- ══════════════ 会议转写 ══════════════ --> |
|||
<section class="panel p-5"> |
|||
<div class="flex flex-wrap items-start justify-between gap-3 mb-1"> |
|||
<div> |
|||
<div class="text-sm font-semibold text-slate-700">会议转写(录音文件识别)</div> |
|||
<div class="text-xs text-slate-400 mt-1 max-w-3xl"> |
|||
从<b>第三方服务配置</b>里已配好的「录音文件识别」类服务中选。 |
|||
<b>选一个就只用这一个;选两个及以上,排在后面的是兜底</b>—— |
|||
第一家提交失败(凭据失效、欠费、报错)时自动换下一家。 |
|||
另外,服务若填了支持语言,遇到它不支持的源语言也会直接顺延。 |
|||
</div> |
|||
</div> |
|||
<button class="btn btn-sm shrink-0" :disabled="!addableAsr.length || saving" @click="openAdd()"> |
|||
+ 添加转写服务 |
|||
</button> |
|||
</div> |
|||
|
|||
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div> |
|||
|
|||
<template v-else> |
|||
<div v-if="!asrPick.length" class="empty mt-4"> |
|||
还没有选转写服务,语音纪要会直接转写失败。 |
|||
<span v-if="!svcOf('asr').length"> |
|||
当前应用下也没有可选的「录音文件识别」服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。 |
|||
</span> |
|||
</div> |
|||
|
|||
<ol v-else class="picklist mt-4"> |
|||
<li v-for="(id, i) in asrPick" :key="id" class="pick"> |
|||
<span class="rank" :class="{ main: i === 0 }">{{ i === 0 ? '主用' : '兜底 ' + i }}</span> |
|||
<div class="min-w-0 flex-1"> |
|||
<div class="pick-name"> |
|||
{{ svcName(id) }} |
|||
<span class="pick-id">{{ id }}</span> |
|||
<span v-if="!svcMap.get(id)" class="chip chip-warn">该服务已停用或不存在</span> |
|||
</div> |
|||
<div class="pick-sub"> |
|||
{{ svcProvider(id) }} · |
|||
<template v-if="svcLangs(id)">支持 {{ svcLangs(id).length }} 种语言</template> |
|||
<template v-else>未限定支持语言(由引擎判断)</template> |
|||
</div> |
|||
</div> |
|||
<div class="flex items-center gap-1 shrink-0"> |
|||
<button class="btn btn-ghost btn-xs" :disabled="i === 0" title="上移" @click="moveAsr(i, -1)">↑</button> |
|||
<button class="btn btn-ghost btn-xs" :disabled="i === asrPick.length - 1" title="下移" @click="moveAsr(i, 1)">↓</button> |
|||
<button class="btn btn-ghost btn-xs text-red-500" @click="asrPick.splice(i, 1)">移除</button> |
|||
</div> |
|||
</li> |
|||
</ol> |
|||
|
|||
<div class="savebar"> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !asrDirty" @click="saveAsr()">保存会议转写</button> |
|||
<button class="btn btn-ghost btn-sm" :disabled="saving || !asrDirty" @click="resetAsr()">放弃修改</button> |
|||
<span v-if="asrDirty" class="text-xs text-amber-600">有未保存的改动</span> |
|||
</div> |
|||
</template> |
|||
</section> |
|||
|
|||
<!-- ══════════════ 会议总结 ══════════════ --> |
|||
<section class="panel p-5"> |
|||
<div class="text-sm font-semibold text-slate-700">会议总结(大模型)</div> |
|||
<div class="text-xs text-slate-400 mt-1 max-w-3xl"> |
|||
从<b>第三方服务配置</b>里已配好的「文本大模型 / 多媒体大模型」类服务中选一个,用于生成纪要总结。 |
|||
这一段<b>没有兜底</b>:选中的服务不可用时总结直接失败。 |
|||
</div> |
|||
|
|||
<div v-if="loading" class="py-14 text-center"><span class="loading loading-spinner loading-sm"></span></div> |
|||
|
|||
<template v-else> |
|||
<div class="form-control max-w-xl mt-4"> |
|||
<label class="label"><span class="label-text font-medium">总结模型</span></label> |
|||
<select v-model="llmPick" class="select select-bordered"> |
|||
<option value="">不配置(不生成总结)</option> |
|||
<option v-for="s in svcOf('llm')" :key="s.id" :value="s.id">{{ s.name }}({{ s.id }})</option> |
|||
<!-- 已选服务在服务池里找不到(被停用/删除)时仍要列出来,否则下拉会静默跳到「不配置」 --> |
|||
<option v-if="llmPick && !svcMap.get(llmPick)" :value="llmPick">{{ llmPick }}(已停用或不存在)</option> |
|||
</select> |
|||
</div> |
|||
|
|||
<div v-if="!svcOf('llm').length" class="empty mt-4"> |
|||
当前应用下没有可选的大模型服务,请先到<NuxtLink class="link" to="/serviceconfig">第三方服务配置</NuxtLink>添加并启用。 |
|||
</div> |
|||
<div v-if="extraLlm.length" class="alert alert-warning py-2 text-xs mt-4"> |
|||
<span> |
|||
该应用还另有 {{ extraLlm.length }} 个已配置的总结服务({{ extraLlm.join('、') }})。 |
|||
保存后只保留上面选中的这一个,其余会从编排里移除(不影响第三方服务配置里的服务本身)。 |
|||
<template v-if="clientChoice"> |
|||
⚠️ 该应用开着「客户端可指定总结模型」,这些服务现在可能正被 App 端选用,移除后客户端只剩这一个可选。 |
|||
</template> |
|||
</span> |
|||
</div> |
|||
|
|||
<div class="savebar"> |
|||
<button class="btn btn-primary btn-sm text-white" :disabled="saving || !llmSavable" @click="saveLlm()">保存会议总结</button> |
|||
<button class="btn btn-ghost btn-sm" :disabled="saving || !llmDirty" @click="resetLlm()">放弃修改</button> |
|||
<span v-if="llmDirty" class="text-xs text-amber-600">有未保存的改动</span> |
|||
</div> |
|||
</template> |
|||
</section> |
|||
</div> |
|||
|
|||
<!-- 区域分叉:本页只编辑「全区域」那一层,有分叉就说清楚,别让后台显示的和线上跑的不是一回事 --> |
|||
<div v-if="regionRows" class="alert alert-warning py-2 text-xs mt-4"> |
|||
<span> |
|||
该应用另有 {{ regionRows }} 条<b>按区域</b>的编排,本页只编辑「全区域」这一层。 |
|||
区域分叉优先于全区域,对应区域的部署实际跑的是那几条,如需调整请直接改库 |
|||
(表 <span class="font-mono">echomeet_orch</span>)。 |
|||
</span> |
|||
</div> |
|||
|
|||
<!-- ══════════════ 会议模板:占满下方,内容多了整页往下滚 ══════════════ --> |
|||
<section class="tplsec"> |
|||
<h3 class="tplsec-title">会议模板</h3> |
|||
<MeetTemplatePanel :app-name="scopeApp" /> |
|||
</section> |
|||
</template> |
|||
|
|||
<!-- 添加转写服务 --> |
|||
<div v-if="addOpen" class="modal modal-open"> |
|||
<div class="modal-box max-w-lg"> |
|||
<h3 class="font-bold text-base mb-1">添加转写服务</h3> |
|||
<p class="text-xs text-slate-400 mb-4">加进列表末尾作为兜底,可再用上下箭头调整顺序。</p> |
|||
<div class="form-control"> |
|||
<select v-model="addSvc" class="select select-bordered"> |
|||
<option value="" disabled>选择一个服务</option> |
|||
<option v-for="s in addableAsr" :key="s.id" :value="s.id">{{ s.name }}({{ s.id }})</option> |
|||
</select> |
|||
</div> |
|||
<div class="modal-action"> |
|||
<button class="btn btn-ghost" @click="addOpen = false">取消</button> |
|||
<button class="btn btn-primary text-white" :disabled="!addSvc" @click="doAdd()">添加</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted, watch } from 'vue' |
|||
import { useApi } from '~/composables/useApi' |
|||
import { useToast } from '~/composables/useToast' |
|||
|
|||
definePageMeta({ title: '会议配置' }) |
|||
|
|||
const { webApi, consoleApi } = useApi() |
|||
const { success, error } = useToast() |
|||
|
|||
// 编排类别(与后端 comm.EchoKindASR/MT/LLM 同一套数字)。翻译段(2)本页不编辑, |
|||
// 后端 saveechoorch 也不会碰它——它同样是语音纪要流水线的一环,跟着一起重置会让译文整体消失。 |
|||
const KIND_ASR = 1 |
|||
const KIND_LLM = 3 |
|||
|
|||
interface Orch { |
|||
row_id: number |
|||
app_name: string |
|||
region: number |
|||
kind: number |
|||
svc_id: string |
|||
priority: number |
|||
is_default: boolean |
|||
enabled: boolean |
|||
} |
|||
interface SvcView { id: string; name: string; provider: string; categories: string; languages: string } |
|||
|
|||
const loading = ref(false) |
|||
const saving = ref(false) |
|||
|
|||
// 应用名来源同 appversion.vue:products/list ∪ apps/list,无权读时走 getmyapps。 |
|||
// 用的是应用名(app_name),运行时 ANALYZE_APP_NAME 就是它。 |
|||
const scopeApp = ref('') |
|||
const scopeApps = ref<string[]>([]) |
|||
async function loadScopeApps() { |
|||
const names = new Set<string>() |
|||
const [products, deploys] = await Promise.all([ |
|||
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>), |
|||
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>), |
|||
]) |
|||
;(products ?? []).forEach((p) => p.name && names.add(p.name)) |
|||
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name)) |
|||
if (!names.size) { |
|||
try { |
|||
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {}) |
|||
;(d?.apps ?? []).forEach((a) => { const n = a.app_name || a.name; if (n) names.add(n) }) |
|||
} catch { /* 空列表,页面提示先选应用 */ } |
|||
} |
|||
scopeApps.value = [...names].sort() |
|||
if (!scopeApp.value && scopeApps.value.length) scopeApp.value = scopeApps.value[0]! |
|||
} |
|||
|
|||
const available = ref<SvcView[]>([]) |
|||
const kindCats = ref<Record<string, number[]>>({ '1': [8], '3': [5, 9] }) |
|||
const regionRows = ref(0) |
|||
// 作用域开关:允许客户端自己指定总结模型。本页不编辑它(没有后台入口,需要时改库), |
|||
// 但要读出来——开着的时候「只保留一个总结服务」这件事对 App 端是有感知的。 |
|||
const clientChoice = ref(false) |
|||
// 库里当前的样子,用于「有没有改动」与「放弃修改」;编辑态是 asrPick / llmPick。 |
|||
const asrSaved = ref<string[]>([]) |
|||
const llmSaved = ref('') |
|||
const extraLlm = ref<string[]>([]) |
|||
const asrPick = ref<string[]>([]) |
|||
const llmPick = ref('') |
|||
|
|||
const addOpen = ref(false) |
|||
const addSvc = ref('') |
|||
|
|||
const svcMap = computed(() => { |
|||
const m = new Map<string, SvcView>() |
|||
for (const s of available.value) m.set(s.id, s) |
|||
return m |
|||
}) |
|||
function svcName(id: string) { return svcMap.value.get(id)?.name ?? id } |
|||
function svcProvider(id: string) { return svcMap.value.get(id)?.provider ?? '未知服务商' } |
|||
function svcLangs(id: string) { |
|||
return (svcMap.value.get(id)?.languages ?? '').split(',').map((x) => x.trim()).filter(Boolean) |
|||
} |
|||
function catsOf(s: SvcView) { |
|||
return (s.categories || '').split(',').map((x) => Number(x.trim())).filter((n) => !Number.isNaN(n)) |
|||
} |
|||
// 某段可选的服务 = 服务池里 categories 命中该段类别的 |
|||
function svcOf(seg: 'asr' | 'llm') { |
|||
const want = kindCats.value[String(seg === 'asr' ? KIND_ASR : KIND_LLM)] ?? [] |
|||
return available.value.filter((s) => catsOf(s).some((c) => want.includes(c))) |
|||
} |
|||
const addableAsr = computed(() => svcOf('asr').filter((s) => !asrPick.value.includes(s.id))) |
|||
|
|||
const asrDirty = computed(() => asrPick.value.join(',') !== asrSaved.value.join(',')) |
|||
const llmDirty = computed(() => llmPick.value !== llmSaved.value) |
|||
// 下拉没动过、但库里还躺着多余的总结行时也允许保存——保存正是为了把那些清掉。 |
|||
// 这种情况不算「未保存的改动」(用户什么都没改),所以只放开按钮,不打那条提示。 |
|||
const llmSavable = computed(() => llmDirty.value || extraLlm.value.length > 0) |
|||
|
|||
function resetAsr() { asrPick.value = asrSaved.value.slice() } |
|||
function resetLlm() { llmPick.value = llmSaved.value } |
|||
|
|||
function openAdd() { |
|||
addSvc.value = '' |
|||
addOpen.value = true |
|||
} |
|||
function doAdd() { |
|||
if (addSvc.value && !asrPick.value.includes(addSvc.value)) asrPick.value.push(addSvc.value) |
|||
addOpen.value = false |
|||
} |
|||
function moveAsr(i: number, dir: number) { |
|||
const list = asrPick.value |
|||
const j = i + dir |
|||
if (j < 0 || j >= list.length) return |
|||
const tmp = list[i]! |
|||
list[i] = list[j]! |
|||
list[j] = tmp |
|||
} |
|||
|
|||
async function loadOrch() { |
|||
if (!scopeApp.value) { available.value = []; asrSaved.value = []; llmSaved.value = ''; return } |
|||
loading.value = true |
|||
try { |
|||
const d = await webApi<{ |
|||
items: Orch[] |
|||
available: SvcView[] |
|||
kind_cats: Record<string, number[]> |
|||
other_regions: Record<string, number> |
|||
setting?: { llm_client_choice?: boolean } |
|||
}>('getechoorch', { app_name: scopeApp.value }) |
|||
available.value = d?.available ?? [] |
|||
clientChoice.value = !!d?.setting?.llm_client_choice |
|||
if (d?.kind_cats) kindCats.value = d.kind_cats |
|||
regionRows.value = Object.values(d?.other_regions ?? {}).reduce((a, b) => a + b, 0) |
|||
|
|||
const items = (d?.items ?? []).filter((o) => o.enabled) |
|||
asrSaved.value = items.filter((o) => o.kind === KIND_ASR).sort((a, b) => a.priority - b.priority).map((o) => o.svc_id) |
|||
// 总结取默认模型;没有标默认就按优先级第一个(与运行时 PickLLM 的口径一致)。 |
|||
const llms = items.filter((o) => o.kind === KIND_LLM).sort((a, b) => a.priority - b.priority) |
|||
const def = llms.find((o) => o.is_default) ?? llms[0] |
|||
llmSaved.value = def?.svc_id ?? '' |
|||
extraLlm.value = llms.filter((o) => o.svc_id !== llmSaved.value).map((o) => o.svc_id) |
|||
resetAsr() |
|||
resetLlm() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '加载失败') |
|||
available.value = [] |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
async function saveAsr() { |
|||
saving.value = true |
|||
try { |
|||
await webApi('saveechoorch', { app_name: scopeApp.value, asr: asrPick.value }) |
|||
success('会议转写已保存') |
|||
await loadOrch() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
async function saveLlm() { |
|||
saving.value = true |
|||
try { |
|||
await webApi('saveechoorch', { app_name: scopeApp.value, llm: llmPick.value }) |
|||
success('会议总结已保存') |
|||
await loadOrch() |
|||
} catch (e: any) { |
|||
error(e?.message ?? '保存失败') |
|||
} finally { |
|||
saving.value = false |
|||
} |
|||
} |
|||
|
|||
watch(scopeApp, () => { loadOrch() }) |
|||
|
|||
onMounted(async () => { |
|||
await loadScopeApps() |
|||
await loadOrch() |
|||
}) |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.panel { background:#fff; border:1px solid #f1f5f9; border-radius:16px; box-shadow:0 1px 2px rgba(0,0,0,0.04); } |
|||
|
|||
/* 上半区两栏:左转写、右总结。两块内容长短不一,用 stretch 让卡片等高,视觉上是一排。 |
|||
1100px 以下叠成一列——两栏挤在窄屏上会让服务名和按钮换行成一团。 */ |
|||
.topgrid { display:grid; grid-template-columns:1fr 1fr; gap:16px; align-items:stretch; } |
|||
@media (max-width: 1100px) { .topgrid { grid-template-columns:1fr; } } |
|||
/* 两块内容长短不一,保存条用 margin-top:auto 压到卡片底部,左右才对得齐 */ |
|||
.topgrid > .panel { display:flex; flex-direction:column; } |
|||
.topgrid .savebar { margin-top:auto; } |
|||
|
|||
/* 会议模板占满下方剩余空间,超出就整页往下滚(不给它自己开滚动条: |
|||
模板卡片网格本来就高,嵌套滚动会出现两条滚动条、还会把筛选条挤在半空) */ |
|||
.tplsec { margin-top:20px; } |
|||
.tplsec-title { font-size:15px; font-weight:600; color:#1e293b; margin-bottom:12px; } |
|||
|
|||
.picklist { display:flex; flex-direction:column; gap:8px; } |
|||
.pick { |
|||
display:flex; align-items:center; gap:12px; padding:11px 13px; |
|||
border:1px solid #eef2f7; border-radius:12px; background:#fff; |
|||
} |
|||
.rank { |
|||
flex-shrink:0; font-size:11px; font-weight:600; padding:2px 9px; border-radius:999px; |
|||
background:#f1f5f9; color:#64748b; white-space:nowrap; |
|||
} |
|||
.rank.main { background:#e0e7ff; color:#4338ca; } |
|||
.pick-name { font-size:13.5px; font-weight:600; color:#1e293b; display:flex; align-items:center; gap:7px; flex-wrap:wrap; } |
|||
.pick-id { font-size:11px; color:#94a3b8; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-weight:400; } |
|||
.pick-sub { font-size:11.5px; color:#94a3b8; margin-top:2px; } |
|||
|
|||
.chip { display:inline-block; font-size:11px; padding:1px 7px; border-radius:999px; background:#f1f5f9; color:#64748b; white-space:nowrap; } |
|||
.chip-warn { background:#fef3c7; color:#b45309; } |
|||
|
|||
.empty { font-size:12.5px; color:#94a3b8; background:#f8fafc; border:1px dashed #e2e8f0; border-radius:12px; padding:18px; } |
|||
.link { color:#4338ca; text-decoration:underline; } |
|||
|
|||
.savebar { display:flex; align-items:center; gap:10px; margin-top:20px; padding-top:16px; border-top:1px solid #f1f5f9; } |
|||
</style> |
|||
@ -0,0 +1,11 @@ |
|||
<template> |
|||
<div class="py-16 text-center text-slate-400 text-sm">正在跳转到「会议配置」…</div> |
|||
</template> |
|||
|
|||
<script setup lang="ts"> |
|||
// 2026-09-17:「会议模板」并入「会议配置」(/meetconfig)的一个标签页,页面本体已拆成 |
|||
// components/MeetTemplateTab.vue。这里只留重定向——后台里这个地址被收藏/直接输入的不在少数, |
|||
// 直接删文件会变成 404(同「全局环境配置」下线时的处理)。 |
|||
definePageMeta({ title: '会议配置' }) |
|||
onMounted(() => { navigateTo('/meetconfig', { replace: true }) }) |
|||
</script> |
|||
@ -0,0 +1,274 @@ |
|||
package console |
|||
|
|||
import ( |
|||
"sort" |
|||
"strconv" |
|||
"strings" |
|||
"time" |
|||
|
|||
"yunyan/comm" |
|||
"yunyan/lego/sys/postgres" |
|||
"yunyan/pb" |
|||
|
|||
"github.com/gin-gonic/gin" |
|||
) |
|||
|
|||
// ============================ 会议配置 · 服务编排 (echomeet orch) ============================
|
|||
//
|
|||
// admin「会议配置」页的后端(2026-09-17 恢复;上一版「会议记录服务」页于 2026-09-12 随菜单收敛
|
|||
// 一起删掉了后台入口,但 echomeet_orch 的数据与运行时一直在跑,换服务商只能直接改库)。
|
|||
//
|
|||
// 编排**不存凭据**,只引用「第三方服务配置」(svc_config) 里配好的服务 id:
|
|||
// · 会议转写 kind=1 → 服务类别「录音文件识别」(svcCatASRFile)
|
|||
// · 会议总结 kind=3 → 服务类别「文本大模型」(svcCatLLM) / 「多媒体大模型」(svcCatLLMVision)
|
|||
// 运行时选路见 modules/echomeet/providers.go 的 PickASR/NextASR/PickLLM:
|
|||
// 转写按 priority 升序取第一个「已启用 + 支持源语言」的,提交失败再顺延下一个,所以
|
|||
// **配两家 = 第二家兜底**;总结取 is_default,没有则按 priority 第一个。
|
|||
//
|
|||
// ⚠️ 作用域是 app_name,**没有「全局默认」层了**(2026-09-14 作用域归一,见 migrate_scope.go):
|
|||
// 每个应用一套、互不干涉,app_name 空一律拒绝。别再照旧版写 `OR app_name=''` 的回退查询——
|
|||
// 那会让一个应用读到另一个应用(或迁移前残留的全局行)的服务选型,且不报任何错。
|
|||
//
|
|||
// 本页只编辑 region=0(全区域)那一层。区域分叉(region>0)仍由运行时的 scopeRank 消费,
|
|||
// 页面不编辑但会把行数回报给前端,免得后台显示的与线上跑的不是一回事。
|
|||
|
|||
// 本页编辑的区域层:0 = 全区域。区域分叉留给运行时,后台不再提供按区域编排的入口。
|
|||
const echoOrchEditRegion int32 = 0 |
|||
|
|||
// echoOrchScopeOK app_name 必填 + 账号权限(同 svcScopeOK / meetTplScopeOK 的规矩)。
|
|||
func (this *serverComp) echoOrchScopeOK(c *gin.Context, appName string) bool { |
|||
if appName == "" { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "app_name 必填:会议配置按应用隔离,请先选择应用") |
|||
return false |
|||
} |
|||
return this.requireAppNameScope(c, appName) |
|||
} |
|||
|
|||
// echoOrchSvcView 供前端选服务/显示名的精简服务视图(**不含凭据**)。
|
|||
type echoOrchSvcView struct { |
|||
Id string `json:"id"` |
|||
Name string `json:"name"` |
|||
Provider string `json:"provider"` |
|||
Categories string `json:"categories"` // 逗号分隔类别,前端按 kind_cats 筛
|
|||
Languages string `json:"languages"` // 支持语言(逗号分隔 BCP-47),空=不限;转写选路按它跳过不支持的
|
|||
} |
|||
|
|||
// echoOrchAvailableSvcs 该应用作用域下启用中的服务(凭据一律不出这个函数)。
|
|||
func (this *serverComp) echoOrchAvailableSvcs(appName string) []echoOrchSvcView { |
|||
rows := make([]*ThirdSvcConfig, 0) |
|||
if err := postgres.Find(comm.TableSvcConfig, &rows, "app_name=? AND enable=true", appName); err != nil { |
|||
return nil |
|||
} |
|||
out := make([]echoOrchSvcView, 0, len(rows)) |
|||
for _, r := range rows { |
|||
langs := "" |
|||
for _, f := range r.Fields { |
|||
if f.Key == "languages" { |
|||
langs = f.DefValue |
|||
break |
|||
} |
|||
} |
|||
out = append(out, echoOrchSvcView{ |
|||
Id: r.Id, Name: r.Name, Provider: r.Provider, Categories: r.Categories, Languages: langs, |
|||
}) |
|||
} |
|||
sort.SliceStable(out, func(i, j int) bool { return out[i].Id < out[j].Id }) |
|||
return out |
|||
} |
|||
|
|||
// echoOrchSvcAllowed 校验 svc_id 在该应用下可见、已启用,且类别与 kind 匹配。
|
|||
func (this *serverComp) echoOrchSvcAllowed(appName, svcId string, kind int32) (bool, string) { |
|||
wantCats := echoKindSvcCats[kind] |
|||
if len(wantCats) == 0 { |
|||
return false, "未知的编排类别" |
|||
} |
|||
rows := make([]*ThirdSvcConfig, 0) |
|||
if err := postgres.Find(comm.TableSvcConfig, &rows, "app_name=? AND id=?", appName, svcId); err != nil { |
|||
return false, err.Error() |
|||
} |
|||
for _, r := range rows { |
|||
if !r.Enable { |
|||
continue |
|||
} |
|||
for _, cat := range strings.Split(r.Categories, ",") { |
|||
for _, want := range wantCats { |
|||
if strings.TrimSpace(cat) == strconv.Itoa(int(want)) { |
|||
return true, "" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return false, "所选服务「" + svcId + "」不存在于该应用、已停用,或类别与该段不匹配" |
|||
} |
|||
|
|||
// getEchoOrch 列出某应用的会议服务编排 + 可选服务池。
|
|||
func (this *serverComp) getEchoOrch(c *gin.Context) { |
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
} |
|||
_ = c.ShouldBindJSON(&req) |
|||
req.AppName = strings.TrimSpace(req.AppName) |
|||
if !this.echoOrchScopeOK(c, req.AppName) { |
|||
return |
|||
} |
|||
|
|||
all := make([]*EchoOrchestration, 0) |
|||
if err := postgres.Find(comm.TableEchomeetOrch, &all, "app_name=?", req.AppName); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
items := make([]*EchoOrchestration, 0, len(all)) |
|||
// 区域分叉本页不编辑,但要如实回报行数:后台显示的若与线上跑的不是一回事,排查会被带偏。
|
|||
otherRegions := map[string]int{} |
|||
for _, r := range all { |
|||
if r.Region == echoOrchEditRegion { |
|||
items = append(items, r) |
|||
continue |
|||
} |
|||
otherRegions[strconv.Itoa(int(r.Region))]++ |
|||
} |
|||
sort.SliceStable(items, func(i, j int) bool { |
|||
if items[i].Kind != items[j].Kind { |
|||
return items[i].Kind < items[j].Kind |
|||
} |
|||
return items[i].Priority < items[j].Priority |
|||
}) |
|||
|
|||
setting := &EchoOrchSetting{} |
|||
if err := postgres.FindOne(comm.TableEchomeetOrchSetting, setting, "app_name=? AND region=?", req.AppName, echoOrchEditRegion); err != nil { |
|||
setting = &EchoOrchSetting{AppName: req.AppName, Region: echoOrchEditRegion} // 未设置 → 零值
|
|||
} |
|||
|
|||
writeOK(c, gin.H{ |
|||
"items": items, |
|||
"setting": setting, |
|||
"available": this.echoOrchAvailableSvcs(req.AppName), |
|||
// 各段对应的服务池类别,前端据此从「可选服务」里筛出每段能选的服务。
|
|||
"kind_cats": gin.H{ |
|||
strconv.Itoa(int(echoKindASR)): echoKindSvcCats[echoKindASR], |
|||
strconv.Itoa(int(echoKindMT)): echoKindSvcCats[echoKindMT], |
|||
strconv.Itoa(int(echoKindLLM)): echoKindSvcCats[echoKindLLM], |
|||
}, |
|||
"other_regions": otherRegions, |
|||
}) |
|||
} |
|||
|
|||
// saveEchoOrch 声明式保存:请求给的就是该应用**最终**的转写顺序与总结模型,后端按差异增删改。
|
|||
//
|
|||
// 为什么不是逐行 CRUD:这一页只有「选哪几家、谁在前」两个决定,声明式保存让页面所见即库内所存,
|
|||
// 不会出现「界面上删掉了但库里还留着一行 enabled=true」这种后台与运行时不一致的中间态。
|
|||
//
|
|||
// ⚠️ **翻译段(kind=2) 不在本页范围内,必须原样保留**:它同样是语音纪要流水线的一环
|
|||
// (见 orch_resolver.go),这里若跟着一起 reconcile 就会把翻译服务清空,表现为纪要能转写、
|
|||
// 译文整体消失。
|
|||
func (this *serverComp) saveEchoOrch(c *gin.Context) { |
|||
// ⚠️ 两段都是**指针**:nil = 本次不动这一段。页面上转写与总结是两个标签页各自保存,
|
|||
// 用值类型的话「保存总结」会带着一个空的 asr 数组过来,把转写编排整段清空。
|
|||
var req struct { |
|||
AppName string `json:"app_name"` |
|||
ASR *[]string `json:"asr"` // 转写:按顺序,第 1 个主用,其余依次兜底
|
|||
LLM *string `json:"llm"` // 总结:单选,空串 = 不配总结
|
|||
} |
|||
if err := c.ShouldBindJSON(&req); err != nil { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) |
|||
return |
|||
} |
|||
req.AppName = strings.TrimSpace(req.AppName) |
|||
if !this.echoOrchScopeOK(c, req.AppName) { |
|||
return |
|||
} |
|||
|
|||
// 去重并保序;顺带校验每个 svc 的可用性与类别,有一个不合法就整体拒绝(别写一半)。
|
|||
want := map[int32][]string{} |
|||
kinds := make([]int32, 0, 2) |
|||
if req.ASR != nil { |
|||
asr := make([]string, 0, len(*req.ASR)) |
|||
seen := map[string]bool{} |
|||
for _, id := range *req.ASR { |
|||
id = strings.TrimSpace(id) |
|||
if id == "" || seen[id] { |
|||
continue |
|||
} |
|||
if ok, msg := this.echoOrchSvcAllowed(req.AppName, id, echoKindASR); !ok { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
seen[id] = true |
|||
asr = append(asr, id) |
|||
} |
|||
want[echoKindASR] = asr |
|||
kinds = append(kinds, echoKindASR) |
|||
} |
|||
if req.LLM != nil { |
|||
llm := strings.TrimSpace(*req.LLM) |
|||
if llm != "" { |
|||
if ok, msg := this.echoOrchSvcAllowed(req.AppName, llm, echoKindLLM); !ok { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, msg) |
|||
return |
|||
} |
|||
want[echoKindLLM] = []string{llm} |
|||
} |
|||
kinds = append(kinds, echoKindLLM) |
|||
} |
|||
if len(kinds) == 0 { |
|||
writeErr(c, pb.ErrorCode_ReqParameterError, "asr / llm 至少给一段") |
|||
return |
|||
} |
|||
|
|||
old := make([]*EchoOrchestration, 0) |
|||
if err := postgres.Find(comm.TableEchomeetOrch, &old, "app_name=? AND region=?", req.AppName, echoOrchEditRegion); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
oldBy := map[int32]map[string]*EchoOrchestration{} |
|||
for _, r := range old { |
|||
if oldBy[r.Kind] == nil { |
|||
oldBy[r.Kind] = map[string]*EchoOrchestration{} |
|||
} |
|||
oldBy[r.Kind][r.SvcId] = r |
|||
} |
|||
|
|||
now := time.Now().Unix() |
|||
for _, kind := range kinds { |
|||
keep := map[string]bool{} |
|||
for i, id := range want[kind] { |
|||
keep[id] = true |
|||
row := oldBy[kind][id] |
|||
priority := int32((i + 1) * 10) |
|||
isDefault := kind == echoKindLLM && i == 0 // 总结取 is_default;转写不用这个标记
|
|||
if row != nil { |
|||
// 已有行:只动顺序/启用/默认,cost_hint、client_visible 等运营填过的字段原样保留。
|
|||
row.Priority = priority |
|||
row.Enabled = true |
|||
row.IsDefault = isDefault |
|||
row.Updatetime = now |
|||
if err := postgres.Save(comm.TableEchomeetOrch, row); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
continue |
|||
} |
|||
add := &EchoOrchestration{ |
|||
AppName: req.AppName, Region: echoOrchEditRegion, Kind: kind, SvcId: id, |
|||
Priority: priority, IsDefault: isDefault, Enabled: true, |
|||
Createtime: now, Updatetime: now, |
|||
} |
|||
if err := postgres.Insert(comm.TableEchomeetOrch, add); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
} |
|||
for id, row := range oldBy[kind] { |
|||
if keep[id] { |
|||
continue |
|||
} |
|||
if err := postgres.Delete(comm.TableEchomeetOrch, "row_id=?", row.RowId); err != nil { |
|||
writeErr(c, pb.ErrorCode_DBError, err.Error()) |
|||
return |
|||
} |
|||
} |
|||
} |
|||
|
|||
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", echoOrchEditRegion) // 通知 echomeet 运行时重载编排
|
|||
writeOK(c, gin.H{"saved": kinds}) |
|||
} |
|||
@ -0,0 +1,33 @@ |
|||
package console |
|||
|
|||
import "testing" |
|||
|
|||
// 会议配置页的两段各自对应哪些服务类别,是后台下拉能不能列出服务的唯一依据,
|
|||
// 而类别 id 同时写在三处(comm.SvcCat* / console svcCat* / admin 前端 SCAT)。
|
|||
// 这里把编排段 → 类别的映射钉死:改类别编号而漏改这张表,表现是后台某一段的下拉
|
|||
// **空空如也且不报任何错**(服务都在、只是筛不出来),排查时很容易去怀疑数据。
|
|||
func TestEchoKindSvcCats(t *testing.T) { |
|||
want := map[int32][]int32{ |
|||
echoKindASR: {8}, // 录音文件识别 → 会议转写
|
|||
echoKindMT: {3}, // 机器翻译(本页不编辑,运行时仍在用)
|
|||
echoKindLLM: {5, 9}, // 文本大模型 + 多媒体大模型 → 会议总结
|
|||
} |
|||
if len(echoKindSvcCats) != len(want) { |
|||
t.Fatalf("编排段数量变了: got %d want %d", len(echoKindSvcCats), len(want)) |
|||
} |
|||
for kind, cats := range want { |
|||
got := echoKindSvcCats[kind] |
|||
if len(got) != len(cats) { |
|||
t.Fatalf("kind=%d 类别数不符: got %v want %v", kind, got, cats) |
|||
} |
|||
for i := range cats { |
|||
if got[i] != cats[i] { |
|||
t.Fatalf("kind=%d 类别不符: got %v want %v", kind, got, cats) |
|||
} |
|||
} |
|||
} |
|||
// 前端 meetconfig.vue 的兜底常量与这里同值,接口拿不到 kind_cats 时按它筛。
|
|||
if echoKindASR != 1 || echoKindLLM != 3 { |
|||
t.Fatalf("编排段编号变了: asr=%d llm=%d", echoKindASR, echoKindLLM) |
|||
} |
|||
} |
|||
Loading…
Reference in new issue