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