From 5ce155eee666ffa909de42dac627172002ddfa29 Mon Sep 17 00:00:00 2001 From: ssongliu Date: Wed, 1 Apr 2026 17:12:07 +0800 Subject: [PATCH] feat: add agent role binding management (#12382) --- agent/app/api/v2/agents.go | 40 +++ agent/app/dto/agents.go | 7 + agent/app/service/agents.go | 2 + agent/app/service/agents_agents.go | 171 +++++++++++- agent/router/ro_ai.go | 2 + frontend/src/api/interface/ai.ts | 7 + frontend/src/api/modules/ai.ts | 8 + frontend/src/lang/modules/en.ts | 1 + frontend/src/lang/modules/zh.ts | 1 + frontend/src/styles/element.scss | 4 - .../config/tabs/agents/binding/index.vue | 247 ++++++++++++++++++ .../agent/config/tabs/agents/create/index.vue | 31 ++- .../agents/agent/config/tabs/agents/index.vue | 244 +++++++++++++---- 13 files changed, 688 insertions(+), 77 deletions(-) create mode 100644 frontend/src/views/ai/agents/agent/config/tabs/agents/binding/index.vue diff --git a/agent/app/api/v2/agents.go b/agent/app/api/v2/agents.go index 01ab82575..2a68460a3 100644 --- a/agent/app/api/v2/agents.go +++ b/agent/app/api/v2/agents.go @@ -393,6 +393,46 @@ func (b *BaseApi) DeleteAgentRole(c *gin.Context) { helper.Success(c) } +// @Tags AI +// @Summary Bind Agent role channel +// @Accept json +// @Param request body dto.AgentRoleBindReq true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /ai/agents/agent/bind [post] +func (b *BaseApi) BindAgentRole(c *gin.Context) { + var req dto.AgentRoleBindReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := agentService.BindRole(req); err != nil { + helper.BadRequest(c, err) + return + } + helper.Success(c) +} + +// @Tags AI +// @Summary Unbind Agent role channel +// @Accept json +// @Param request body dto.AgentRoleBindReq true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /ai/agents/agent/unbind [post] +func (b *BaseApi) UnbindAgentRole(c *gin.Context) { + var req dto.AgentRoleBindReq + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := agentService.UnbindRole(req); err != nil { + helper.BadRequest(c, err) + return + } + helper.Success(c) +} + // @Tags AI // @Summary Get configured Agent roles from config file // @Accept json diff --git a/agent/app/dto/agents.go b/agent/app/dto/agents.go index 6fc8b79ba..3e8734ea4 100644 --- a/agent/app/dto/agents.go +++ b/agent/app/dto/agents.go @@ -109,6 +109,13 @@ type AgentRoleDeleteReq struct { ID string `json:"id" validate:"required"` } +type AgentRoleBindReq struct { + AgentID uint `json:"agentId" validate:"required"` + ID string `json:"id" validate:"required"` + Channel string `json:"channel" validate:"required"` + AccountID string `json:"accountId"` +} + type AgentConfiguredAgentsReq struct { AgentID uint `json:"agentId" validate:"required"` } diff --git a/agent/app/service/agents.go b/agent/app/service/agents.go index f7d7d2994..cc0412ce2 100644 --- a/agent/app/service/agents.go +++ b/agent/app/service/agents.go @@ -46,6 +46,8 @@ type IAgentService interface { CreateRole(req dto.AgentRoleCreateReq) (*dto.AgentRoleCreateResp, error) DeleteRole(req dto.AgentRoleDeleteReq) error + BindRole(req dto.AgentRoleBindReq) error + UnbindRole(req dto.AgentRoleBindReq) error GetConfiguredAgents(req dto.AgentConfiguredAgentsReq) ([]dto.AgentConfiguredAgentItem, error) GetRoleChannels(req dto.AgentRoleChannelsReq) ([]dto.AgentRoleChannelItem, error) GetRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesReq) ([]dto.AgentRoleMarkdownFileItem, error) diff --git a/agent/app/service/agents_agents.go b/agent/app/service/agents_agents.go index c52c14f62..a895c8dc7 100644 --- a/agent/app/service/agents_agents.go +++ b/agent/app/service/agents_agents.go @@ -91,18 +91,19 @@ func (a AgentService) GetRoleChannels(req dto.AgentRoleChannelsReq) ([]dto.Agent if !ok || len(channels) == 0 { return []dto.AgentRoleChannelItem{}, nil } - boundChannels := loadBoundChannelSet(conf["bindings"]) + boundBindings := loadBoundChannelBindings(conf["bindings"]) result := make([]dto.AgentRoleChannelItem, 0, len(channels)) for key := range channels { key = strings.TrimSpace(key) if key == "" { continue } - channelConf, _ := channels[key].(map[string]interface{}) + accountIDs := extractRoleChannelAccountIDs(conf, key) + availableAccountIDs := filterAvailableChannelAccountIDs(boundBindings, key, accountIDs) result = append(result, dto.AgentRoleChannelItem{ Name: key, - Bound: boundChannels[key], - AccountIDs: extractChannelAccountIDs(channelConf), + Bound: isRoleChannelFullyBound(boundBindings, key, accountIDs, availableAccountIDs), + AccountIDs: availableAccountIDs, }) } sort.Slice(result, func(i, j int) bool { @@ -146,6 +147,50 @@ func (a AgentService) DeleteRole(req dto.AgentRoleDeleteReq) error { return nil } +func (a AgentService) BindRole(req dto.AgentRoleBindReq) error { + return a.operateRoleBinding(req, "bind") +} + +func (a AgentService) UnbindRole(req dto.AgentRoleBindReq) error { + return a.operateRoleBinding(req, "unbind") +} + +func (a AgentService) operateRoleBinding(req dto.AgentRoleBindReq, action string) error { + agent, install, conf, err := a.loadAgentConfig(req.AgentID) + if err != nil { + return err + } + + baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data") + roleID := req.ID + if roleID == "" { + return buserr.New("ErrRecordNotFound") + } + if _, ok := findConfiguredAgentByID(baseDir, conf, roleID); !ok { + return buserr.New("ErrRecordNotFound") + } + + binding := formatRoleBinding(req.Channel, req.AccountID) + if binding == "" { + return buserr.New("ErrInvalidParams") + } + args := []string{ + "exec", + install.ContainerName, + "openclaw", + "agents", + action, + "--agent", + roleID, + "--bind", + binding, + } + args = append(args, "--json") + mgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute)) + _, err = mgr.RunWithStdout("docker", args...) + return err +} + func (a AgentService) GetRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesReq) ([]dto.AgentRoleMarkdownFileItem, error) { agent, err := loadOpenclawAgentByID(req.AgentID) if err != nil { @@ -250,6 +295,16 @@ func findConfiguredAgentByID(baseDir string, conf map[string]interface{}, id str return dto.AgentConfiguredAgentItem{}, false } +func formatRoleBinding(channel, accountID string) string { + if channel == "" { + return "" + } + if accountID == "" { + return channel + } + return channel + ":" + accountID +} + func applyConfiguredAgentBindings(agents []dto.AgentConfiguredAgentItem, value interface{}) { bindings, ok := value.([]interface{}) if !ok || len(agents) == 0 { @@ -305,8 +360,8 @@ func applyConfiguredAgentBindings(agents []dto.AgentConfiguredAgentItem, value i } } -func loadBoundChannelSet(value interface{}) map[string]bool { - result := make(map[string]bool) +func loadBoundChannelBindings(value interface{}) map[string]map[string]struct{} { + result := make(map[string]map[string]struct{}) bindings, ok := value.([]interface{}) if !ok { return result @@ -328,12 +383,112 @@ func loadBoundChannelSet(value interface{}) map[string]bool { if channel == "" { continue } - result[channel] = true + accountID, _ := match["accountId"].(string) + accountID = strings.TrimSpace(accountID) + if accountID == "" { + accountID, _ = record["accountId"].(string) + accountID = strings.TrimSpace(accountID) + } + if _, ok := result[channel]; !ok { + result[channel] = make(map[string]struct{}) + } + result[channel][accountID] = struct{}{} } return result } -func extractChannelAccountIDs(channel map[string]interface{}) []string { +func extractRoleChannelAccountIDs(conf map[string]interface{}, channel string) []string { + switch channel { + case "feishu": + config := extractFeishuConfig(conf) + accountIDs := make([]string, 0, len(config.Bots)) + for _, item := range config.Bots { + if accountID := item.AccountID; accountID != "" { + accountIDs = append(accountIDs, accountID) + } + } + sort.Strings(accountIDs) + return accountIDs + case "telegram": + config := extractTelegramConfig(conf) + accountIDs := make([]string, 0, len(config.Bots)) + for _, item := range config.Bots { + if accountID := item.AccountID; accountID != "" { + accountIDs = append(accountIDs, accountID) + } + } + sort.Strings(accountIDs) + return accountIDs + case "discord": + config := extractDiscordConfig(conf) + accountIDs := make([]string, 0, len(config.Bots)) + for _, item := range config.Bots { + if accountID := item.AccountID; accountID != "" { + accountIDs = append(accountIDs, accountID) + } + } + sort.Strings(accountIDs) + return accountIDs + case "qqbot": + config := extractQQBotConfig(conf) + accountIDs := make([]string, 0, len(config.Bots)) + for _, item := range config.Bots { + if accountID := item.AccountID; accountID != "" { + accountIDs = append(accountIDs, accountID) + } + } + sort.Strings(accountIDs) + return accountIDs + case "dingtalk-connector": + config := extractDingTalkConfig(conf) + accountIDs := make([]string, 0, len(config.Bots)) + for _, item := range config.Bots { + if accountID := item.AccountID; accountID != "" { + accountIDs = append(accountIDs, accountID) + } + } + sort.Strings(accountIDs) + return accountIDs + case "wecom": + return []string{} + default: + return extractRawChannelAccountIDs(getChannelConfig(conf, channel)) + } +} + +func filterAvailableChannelAccountIDs(bindings map[string]map[string]struct{}, channel string, accountIDs []string) []string { + channelBindings, ok := bindings[strings.TrimSpace(channel)] + if !ok || len(channelBindings) == 0 { + return append([]string(nil), accountIDs...) + } + if _, ok := channelBindings[""]; ok { + return []string{} + } + result := make([]string, 0, len(accountIDs)) + for _, accountID := range accountIDs { + if _, ok := channelBindings[accountID]; ok { + continue + } + result = append(result, accountID) + } + return result +} + +func isRoleChannelFullyBound(bindings map[string]map[string]struct{}, channel string, allAccountIDs, availableAccountIDs []string) bool { + channelBindings, ok := bindings[strings.TrimSpace(channel)] + if !ok || len(channelBindings) == 0 { + return false + } + if _, ok := channelBindings[""]; ok { + return true + } + if len(allAccountIDs) == 0 { + return true + } + return len(availableAccountIDs) == 0 +} + +func extractRawChannelAccountIDs(channel map[string]interface{}) []string { if len(channel) == 0 { return []string{} } diff --git a/agent/router/ro_ai.go b/agent/router/ro_ai.go index 6bb4e47d3..e692f5c6b 100644 --- a/agent/router/ro_ai.go +++ b/agent/router/ro_ai.go @@ -59,6 +59,8 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) { aiToolsRouter.POST("/agents/accounts/delete", baseApi.DeleteAgentAccount) aiToolsRouter.POST("/agents/agent/create", baseApi.CreateAgentRole) aiToolsRouter.POST("/agents/agent/delete", baseApi.DeleteAgentRole) + aiToolsRouter.POST("/agents/agent/bind", baseApi.BindAgentRole) + aiToolsRouter.POST("/agents/agent/unbind", baseApi.UnbindAgentRole) aiToolsRouter.POST("/agents/agent/list", baseApi.GetConfiguredAgentRoles) aiToolsRouter.POST("/agents/agent/channels", baseApi.GetAgentRoleChannels) aiToolsRouter.POST("/agents/agent/md/list", baseApi.GetAgentRoleMarkdownFiles) diff --git a/frontend/src/api/interface/ai.ts b/frontend/src/api/interface/ai.ts index b75412216..a3f1c498d 100644 --- a/frontend/src/api/interface/ai.ts +++ b/frontend/src/api/interface/ai.ts @@ -335,6 +335,13 @@ export namespace AI { id: string; } + export interface AgentRoleBindReq { + agentId: number; + id: string; + channel: string; + accountId: string; + } + export interface AgentConfiguredAgentsReq { agentId: number; } diff --git a/frontend/src/api/modules/ai.ts b/frontend/src/api/modules/ai.ts index f9cbb13c1..77326a442 100644 --- a/frontend/src/api/modules/ai.ts +++ b/frontend/src/api/modules/ai.ts @@ -129,6 +129,14 @@ export const deleteAgentRole = (req: AI.AgentRoleDeleteReq) => { return http.post(`/ai/agents/agent/delete`, req); }; +export const bindAgentRole = (req: AI.AgentRoleBindReq) => { + return http.post(`/ai/agents/agent/bind`, req); +}; + +export const unbindAgentRole = (req: AI.AgentRoleBindReq) => { + return http.post(`/ai/agents/agent/unbind`, req); +}; + export const getConfiguredAgentRoles = (req: AI.AgentConfiguredAgentsReq) => { return http.post(`/ai/agents/agent/list`, req); }; diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 6581d4ea2..54d7d1fd8 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -752,6 +752,7 @@ const message = { ], }, bindings: 'Bindings', + duplicateBinding: 'The channel and account ID combination must be unique', accountIdOptional: 'Account ID (Optional)', saveAllMd: 'Save All', roleMarkdownRestartHelper: diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index ae60018e9..6d91eeb48 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -703,6 +703,7 @@ const message = { 'BOOTSTRAP.md': ['首次运行引导流程', '只会在全新的工作区中创建。'], }, bindings: '绑定', + duplicateBinding: '频道和账号 ID 不能重复', accountIdOptional: '账号 ID(可选)', saveAllMd: '保存全部', roleMarkdownRestartHelper: '保存当前全部 MD 文件后,需要重启容器才能生效。请选择立即重启或稍后重启。', diff --git a/frontend/src/styles/element.scss b/frontend/src/styles/element.scss index 93ad7eae5..ff56dd51f 100644 --- a/frontend/src/styles/element.scss +++ b/frontend/src/styles/element.scss @@ -234,10 +234,6 @@ html { padding: 5px; } -.el-card { - border: none !important; -} - .el-input-group__append { button.el-button { span { diff --git a/frontend/src/views/ai/agents/agent/config/tabs/agents/binding/index.vue b/frontend/src/views/ai/agents/agent/config/tabs/agents/binding/index.vue new file mode 100644 index 000000000..9aefac0e6 --- /dev/null +++ b/frontend/src/views/ai/agents/agent/config/tabs/agents/binding/index.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/frontend/src/views/ai/agents/agent/config/tabs/agents/create/index.vue b/frontend/src/views/ai/agents/agent/config/tabs/agents/create/index.vue index 3d6bdc577..a5d94537f 100644 --- a/frontend/src/views/ai/agents/agent/config/tabs/agents/create/index.vue +++ b/frontend/src/views/ai/agents/agent/config/tabs/agents/create/index.vue @@ -3,7 +3,6 @@
-
{{ $t('commons.table.name') }}
@@ -90,7 +89,7 @@ import { createAgentRole, getAgentRoleChannels, pageAgentAccounts } from '@/api/ import { AI } from '@/api/interface/ai'; import { Rules } from '@/global/form-rules'; import i18n from '@/lang'; -import { MsgSuccess } from '@/utils/message'; +import { MsgError, MsgSuccess } from '@/utils/message'; import { useGlobalStore } from '@/composables/useGlobalStore'; interface SelectOption { @@ -151,6 +150,10 @@ const handleBindingChannelChange = (index: number) => { return; } binding.accountId = ''; + const options = getAccountIdOptions(binding.channel); + if (options.length === 1) { + binding.accountId = options[0]; + } }; const getAccountIdOptions = (channel: string) => { @@ -161,6 +164,9 @@ const isChannelDisabled = (option: SelectOption, index: number) => { if (option.bound) { return true; } + if ((option.accountIds || []).length > 0) { + return false; + } return form.bindings.some((item, bindingIndex) => bindingIndex !== index && item.channel === option.value); }; @@ -209,18 +215,27 @@ const submit = async () => { return; } await formRef.value.validate(); + const bindings = form.bindings.filter((item) => item.channel); + const hasDuplicate = bindings.some((item, index) => + bindings.some( + (current, currentIndex) => + currentIndex !== index && current.channel === item.channel && current.accountId === item.accountId, + ), + ); + if (hasDuplicate) { + MsgError(i18n.global.t('aiTools.agents.duplicateBinding')); + return; + } loading.value = true; try { await createAgentRole({ agentId: agentId.value, name: form.name.trim(), model: form.model.trim(), - bindings: form.bindings - .filter((item) => item.channel) - .map((item) => ({ - channel: item.channel, - accountId: item.accountId.trim(), - })), + bindings: bindings.map((item) => ({ + channel: item.channel, + accountId: item.accountId.trim(), + })), } as AI.AgentRoleCreateReq); MsgSuccess(i18n.global.t('commons.msg.operationSuccess')); emit('success'); diff --git a/frontend/src/views/ai/agents/agent/config/tabs/agents/index.vue b/frontend/src/views/ai/agents/agent/config/tabs/agents/index.vue index c04dc6aed..341d8bba9 100644 --- a/frontend/src/views/ai/agents/agent/config/tabs/agents/index.vue +++ b/frontend/src/views/ai/agents/agent/config/tabs/agents/index.vue @@ -12,49 +12,79 @@ {{ $t('commons.button.add') }}
- - - - - - - - - - - - - - - - - - +
+ +
+
+ +
+ {{ $t('aiTools.agents.channelsTab') }} +
+
+ + {{ item.accountId ? `${item.channel}:${item.accountId}` : item.channel }} + + + + {{ $t('commons.button.add') }} + +
+
+ + + {{ $t('commons.button.add') }} + +
+
+
+ + + @@ -63,11 +93,13 @@