You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

413 lines
20 KiB

<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>,用于生成纪要总结;
还可以再勾选几个<b>供用户在 App 里自选</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="llmPick && llmChoiceCandidates.length" class="form-control max-w-xl mt-4">
<label class="label">
<span class="label-text font-medium">供用户选择的模型</span>
<span class="label-text-alt text-slate-400">App「生成」弹窗里的「总结模型」;都不勾 = 只用默认模型</span>
</label>
<div class="flex flex-col gap-2">
<label v-for="s in llmChoiceCandidates" :key="s.id" class="flex items-center gap-2 cursor-pointer">
<input v-model="llmChoices" type="checkbox" class="checkbox checkbox-sm" :value="s.id">
<span class="text-sm">{{ s.name }}<span class="text-slate-400">({{ s.id }})</span></span>
</label>
</div>
</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('、') }})。
保存后会从编排里移除(不影响第三方服务配置里的服务本身);要保留就在上面勾上。
</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
client_visible?: 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)
// 作用域开关:允许客户端自己指定总结模型。不单独给开关:保存会议总结时按
// 「有没有勾供用户选择的模型」由后端一并置位(勾了=开,一个没勾=关)。
const clientChoice = ref(false)
// 库里当前的样子,用于「有没有改动」与「放弃修改」;编辑态是 asrPick / llmPick。
const asrSaved = ref<string[]>([])
const llmSaved = ref('')
const extraLlm = ref<string[]>([])
const asrPick = ref<string[]>([])
const llmPick = ref('')
// 供用户在 App 里选择的备选总结模型(不含默认那个)
const llmChoicesSaved = ref<string[]>([])
const llmChoices = ref<string[]>([])
const llmChoiceCandidates = computed(() => svcOf('llm').filter((s) => s.id !== llmPick.value))
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 sameSet = (a: string[], b: string[]) => a.length === b.length && a.every((x) => b.includes(x))
const llmDirty = computed(() => llmPick.value !== llmSaved.value || !sameSet(llmChoices.value, llmChoicesSaved.value))
// 下拉没动过、但库里还躺着多余的总结行时也允许保存——保存正是为了把那些清掉。
// 这种情况不算「未保存的改动」(用户什么都没改),所以只放开按钮,不打那条提示。
const llmSavable = computed(() => llmDirty.value || extraLlm.value.length > 0)
function resetAsr() { asrPick.value = asrSaved.value.slice() }
function resetLlm() {
llmPick.value = llmSaved.value
llmChoices.value = llmChoicesSaved.value.slice()
}
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 ?? ''
// 备选只在应用级开关开着时才算数(关着时运行时不会把它们下发给 App)
llmChoicesSaved.value = clientChoice.value
? llms.filter((o) => o.svc_id !== llmSaved.value && o.client_visible).map((o) => o.svc_id)
: []
extraLlm.value = llms
.filter((o) => o.svc_id !== llmSaved.value && !llmChoicesSaved.value.includes(o.svc_id))
.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,
// 默认模型不在备选里重复;没选默认时备选没有意义
llm_choices: llmPick.value ? llmChoices.value.filter((id) => id !== 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>