package console import ( "errors" "strconv" "strings" "time" "yunyan/comm" "yunyan/lego/sys/postgres" "yunyan/pb" "github.com/gin-gonic/gin" ) // ============================ Agent 配置域 (agent) ============================ // // yunyan 自管 agents 的管理端 CRUD,存 console 主库(postgres.GetSys())。 // agent 编排引用第三方服务(ThirdSvcConfig)的 id;保存时按 Type 校验必需的服务引用「存在且类别匹配」。 // 配置「下发客户端」由后续阶段的下发接口 + modules/agents 改造承接。 // agentSvcRef 一个待校验的服务引用(哪个位置、引用的 svcId、允许的服务类别集合)。 // cats 是「任一命中即可」:LLM 槽位既收文本大模型也收多媒体大模型—— // 多媒体模型(GPT-4o / 千问 VL / Gemini / 豆包视觉)本身就能处理纯文本对话, // 是文本模型能力的超集,没有理由把它挡在对话型 agent 之外。 type agentSvcRef struct { label string svcId string cats []int32 } // agentLLMCats 对话型 agent 的 LLM 槽位可选类别(与 admin agents.vue 的 CAT_LLM/CAT_LLM_VISION 对齐)。 var agentLLMCats = []int32{svcCatLLM, svcCatLLMVision} // collectAgentSvcRefs 按 agent 类型收集其应校验的服务引用集合。 func collectAgentSvcRefs(a *AgentConfig) []agentSvcRef { switch a.Type { case AgentTypeChat: return []agentSvcRef{ {"STT 语音识别", a.SttSvcId, []int32{svcCatSTT}}, {"LLM 大语言", a.LlmSvcId, agentLLMCats}, {"TTS 语音合成", a.TtsSvcId, []int32{svcCatTTS}}, } case AgentTypeTranslate: return []agentSvcRef{ {"STT 语音识别", a.SttSvcId, []int32{svcCatSTT}}, {"MT 机器翻译", a.MtSvcId, []int32{svcCatMT}}, {"TTS 语音合成", a.TtsSvcId, []int32{svcCatTTS}}, } case AgentTypeStsChat: return []agentSvcRef{{"STS 端到端对话", a.StsSvcId, []int32{svcCatSTS}}} case AgentTypeAstTranslate: return []agentSvcRef{{"AST 端到端翻译", a.AstSvcId, []int32{svcCatAST}}} } return nil } // svcHasAnyCategory 判断 ThirdSvcConfig.Categories("1,2,3") 是否命中 cats 中任意一个类别。 func svcHasAnyCategory(categories string, cats []int32) bool { have := make(map[string]bool, 4) for _, s := range strings.Split(categories, ",") { if s = strings.TrimSpace(s); s != "" { have[s] = true } } for _, c := range cats { if have[strconv.Itoa(int(c))] { return true } } return false } // validateAgentSvcRefs 校验 agent 引用的服务:必填、存在、类别匹配。返回错误说明(空串=通过)。 // 引用在 agent 自己的应用作用域内解析,未命中回退全局(app_name=”)——与运行时「应用优先、无则全局」一致。 func validateAgentSvcRefs(a *AgentConfig) string { refs := collectAgentSvcRefs(a) if len(refs) == 0 { return "未知的智能体类型: " + a.Type } for _, r := range refs { if strings.TrimSpace(r.svcId) == "" { return r.label + " 必选" } cfg := &ThirdSvcConfig{} err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", a.AppName, r.svcId) if err != nil && a.AppName != "" { // 应用作用域未命中 → 回退全局 err = postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", "", r.svcId) } if err != nil { return r.label + " 引用的服务不存在: " + r.svcId } if !svcHasAnyCategory(cfg.Categories, r.cats) { return r.label + " 引用的服务类别不匹配: " + r.svcId } } return "" } // validateAgentType 校验类型可用。翻译类已下线,只在「历史行本来就是这个类型且没改」时放行, // 保证老数据还能打开、改名字、停用,而不是一保存就被卡住。 func validateAgentType(a *AgentConfig, old *AgentConfig) string { if comm.AgentTypeSelectable(a.Type) { return "" } if old != nil && old.Type == a.Type { return "" // 历史行保持原类型,放行 } return "智能体类型不支持: " + a.Type + "(现只支持 对话(chat) / 端到端对话(sts-chat))" } // validateAgentMcpRefs 校验绑定的 MCP 服务:存在且类别含 MCP。空列表直接通过。 // 解析口径同服务引用:应用作用域优先、未命中回退全局。 func validateAgentMcpRefs(a *AgentConfig) string { for _, id := range a.McpSvcIds { id = strings.TrimSpace(id) if id == "" { continue } cfg := &ThirdSvcConfig{} err := postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", a.AppName, id) if err != nil && a.AppName != "" { err = postgres.FindOne(comm.TableSvcConfig, cfg, "app_name=? AND id=?", "", id) } if err != nil { return "绑定的 MCP 服务不存在: " + id } if !svcHasAnyCategory(cfg.Categories, []int32{svcCatMCP}) { return "绑定的服务不是 MCP 类型: " + id } } return "" } // normalizeAgent 落库前清洗:去掉空的 MCP 引用与无 key 的变量项,避免脏数据下发到客户端。 func normalizeAgent(a *AgentConfig) { ids := make([]string, 0, len(a.McpSvcIds)) seen := map[string]bool{} for _, id := range a.McpSvcIds { if id = strings.TrimSpace(id); id != "" && !seen[id] { seen[id] = true ids = append(ids, id) } } a.McpSvcIds = ids // 工具白名单只保留仍绑着的服务:取消绑定后再重新勾上,不该冒出上次的残留选择。 // 某服务的列表为空就整条删掉——空列表与「没这个 key」同义(全放开),留着只会让下发数据变脏。 tools := make(map[string][]string, len(a.McpTools)) for _, id := range ids { names := make([]string, 0, len(a.McpTools[id])) dup := map[string]bool{} for _, n := range a.McpTools[id] { if n = strings.TrimSpace(n); n != "" && !dup[n] { dup[n] = true names = append(names, n) } } if len(names) > 0 { tools[id] = names } } a.McpTools = tools vars := make([]comm.AgentVariable, 0, len(a.Variables)) for _, v := range a.Variables { if v.Key = strings.TrimSpace(v.Key); v.Key != "" { vars = append(vars, v) } } a.Variables = vars } // ============================== Agent ID 生成 ============================== // agentIdPrefix agent 业务 id 的类型前缀:id 由后台生成,但仍希望肉眼能看出是哪类 agent。 func agentIdPrefix(t string) string { switch t { case AgentTypeChat: return "chat" case AgentTypeStsChat: return "sts" case AgentTypeTranslate: return "trans" case AgentTypeAstTranslate: return "ast" } return "agent" } // genAgentId 生成作用域内唯一的 agent 业务 id(形如 chat-a3k7m2qp)。 // 运营只填名称/类型,id 一律由后台生成——避免人工起名撞车,也免得下发给客户端的标识随手改。 // 唯一性最终由 (app_name,id) 唯一索引兜底,这里做几次探测降低冲突概率。 func genAgentId(appName, agentType string) (string, error) { prefix := agentIdPrefix(agentType) for i := 0; i < 8; i++ { code, err := comm.GenerateShortCode(8) if err != nil { return "", err } id := prefix + "-" + strings.ToLower(code) exist := &AgentConfig{} if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", appName, id); err != nil { return id, nil // 查不到 = 没被占用 } } return "", errors.New("生成智能体ID失败,请重试") } // ============================== Agent CRUD ============================== // getAgents 列出某作用域(app_name)下全部 agent 配置。app_name=” 为全局默认。 func (this *serverComp) getAgents(c *gin.Context) { var req struct { AppName string `json:"app_name"` } _ = c.ShouldBindJSON(&req) items := make([]*AgentConfig, 0) if err := postgres.Find(comm.TableAgentConfig, &items, "app_name=?", req.AppName); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, gin.H{"items": items}) } // getAgent 取单个 agent 配置(按 app_name+id)。 func (this *serverComp) getAgent(c *gin.Context) { var req struct { AppName string `json:"app_name"` Id string `json:"id"` } if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } m := &AgentConfig{} if err := postgres.FindOne(comm.TableAgentConfig, m, "app_name=? AND id=?", req.AppName, req.Id); err != nil { writeErr(c, pb.ErrorCode_DBError, "智能体不存在: "+req.Id) return } writeOK(c, gin.H{"agent": m}) } // addAgent 新增 agent 配置(作用域内 app_name+id 唯一)。 func (this *serverComp) addAgent(c *gin.Context) { var m AgentConfig if err := c.ShouldBindJSON(&m); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if !this.requireAppNameScope(c, m.AppName) { // 应用级账号只能在自己名下的应用里建 return } m.Name = strings.TrimSpace(m.Name) if m.Name == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "智能体名称 必填") return } if msg := validateAgentType(&m, nil); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } // 业务 id 由后台生成:前端不再填。仍兼容显式传入(导入/迁移场景),此时校验作用域内不重复。 if m.Id = strings.TrimSpace(m.Id); m.Id == "" { id, err := genAgentId(m.AppName, m.Type) if err != nil { writeErr(c, pb.ErrorCode_SystemError, err.Error()) return } m.Id = id } else { exist := &AgentConfig{} if err := postgres.FindOne(comm.TableAgentConfig, exist, "app_name=? AND id=?", m.AppName, m.Id); err == nil { writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 已存在: "+m.Id) return } } if msg := validateAgentSvcRefs(&m); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } if msg := validateAgentMcpRefs(&m); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } normalizeAgent(&m) m.RowId = 0 // 代理主键由库自增回填 now := time.Now().UnixMilli() m.Createtime = now m.Updatetime = now if err := postgres.Insert(comm.TableAgentConfig, &m); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } broadcastConfigChanged(comm.ConfigKindAgent, "add", 0) writeOK(c, &m) } // updateAgent 更新 agent 配置(按 app_name+id)。 func (this *serverComp) updateAgent(c *gin.Context) { var m AgentConfig if err := c.ShouldBindJSON(&m); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if strings.TrimSpace(m.Id) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "智能体ID 必填") return } if !this.requireAppNameScope(c, m.AppName) { return } old := &AgentConfig{} if err := postgres.FindOne(comm.TableAgentConfig, old, "app_name=? AND id=?", m.AppName, m.Id); err != nil { writeErr(c, pb.ErrorCode_DBError, "智能体不存在: "+m.Id) return } if msg := validateAgentType(&m, old); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } if msg := validateAgentSvcRefs(&m); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } if msg := validateAgentMcpRefs(&m); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } normalizeAgent(&m) m.RowId = old.RowId // 按代理主键更新本行 m.Createtime = old.Createtime m.Updatetime = time.Now().UnixMilli() if err := postgres.Save(comm.TableAgentConfig, &m); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } broadcastConfigChanged(comm.ConfigKindAgent, "update", 0) writeOK(c, &m) } // delAgent 删除 agent 配置(按 app_name+id)。 func (this *serverComp) delAgent(c *gin.Context) { var req struct { AppName string `json:"app_name"` Id string `json:"id"` } if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Id) == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } if !this.requireAppNameScope(c, req.AppName) { return } if err := postgres.Delete(comm.TableAgentConfig, "app_name=? AND id=?", req.AppName, req.Id); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } broadcastConfigChanged(comm.ConfigKindAgent, "delete", 0) writeOK(c, gin.H{"id": req.Id}) }