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.
323 lines
12 KiB
323 lines
12 KiB
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"` // 总结:默认模型,空串 = 不配总结
|
|
// 总结:除默认模型外,再给 App 用户挑选的模型(2026-09-26)。nil = 本次不动这些行的「对用户可见」,
|
|
// 也不动应用级开关;非 nil 时它就是完整的可选清单,并据此把「允许客户端选择」开关置为 len>0。
|
|
LLMChoices *[]string `json:"llm_choices"`
|
|
}
|
|
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}
|
|
}
|
|
if req.LLMChoices != nil {
|
|
seen := map[string]bool{llm: true}
|
|
for _, id := range *req.LLMChoices {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" || seen[id] {
|
|
continue
|
|
}
|
|
if llm == "" {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "先选默认总结模型,再勾选供用户选择的模型")
|
|
return
|
|
}
|
|
if ok, msg := this.echoOrchSvcAllowed(req.AppName, id, echoKindLLM); !ok {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, msg)
|
|
return
|
|
}
|
|
seen[id] = true
|
|
want[echoKindLLM] = append(want[echoKindLLM], id)
|
|
}
|
|
}
|
|
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;转写不用这个标记
|
|
// 总结段第 2 个起是「供用户选择」的备选;默认模型本来就会下发,不需要这个标记。
|
|
setVisible := kind == echoKindLLM && req.LLMChoices != nil
|
|
visible := i > 0
|
|
if row != nil {
|
|
// 已有行:只动顺序/启用/默认(和本次明确给了的「对用户可见」),cost_hint 等运营填过的字段原样保留。
|
|
row.Priority = priority
|
|
row.Enabled = true
|
|
row.IsDefault = isDefault
|
|
if setVisible {
|
|
row.ClientVisible = visible
|
|
}
|
|
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,
|
|
ClientVisible: setVisible && visible,
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 勾了备选模型 = 允许 App 用户挑;一个都没勾就关掉,客户端只看得到默认那一个。
|
|
if req.LLM != nil && req.LLMChoices != nil {
|
|
choice := len(want[echoKindLLM]) > 1
|
|
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, LLMClientChoice: choice, Updatetime: now}
|
|
if err := postgres.Insert(comm.TableEchomeetOrchSetting, setting); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
} else if setting.LLMClientChoice != choice {
|
|
setting.LLMClientChoice = choice
|
|
setting.Updatetime = now
|
|
if err := postgres.Save(comm.TableEchomeetOrchSetting, setting); err != nil {
|
|
writeErr(c, pb.ErrorCode_DBError, err.Error())
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
broadcastConfigChanged(comm.ConfigKindEchomeetOrch, "update", echoOrchEditRegion) // 通知 echomeet 运行时重载编排
|
|
writeOK(c, gin.H{"saved": kinds})
|
|
}
|
|
|