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

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