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
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>
|
|
|