feat: add agent config role management (#12364)

This commit is contained in:
ssongliu
2026-03-30 08:59:06 +00:00
committed by GitHub
parent e6dc44d135
commit 0ba7e98e8d
21 changed files with 1701 additions and 0 deletions
+124
View File
@@ -352,6 +352,130 @@ func (b *BaseApi) DeleteAgentAccount(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Create Agent role
// @Accept json
// @Param request body dto.AgentRoleCreateReq true "request"
// @Success 200 {object} dto.AgentRoleCreateResp
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/create [post]
func (b *BaseApi) CreateAgentRole(c *gin.Context) {
var req dto.AgentRoleCreateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.CreateRole(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Delete Agent role
// @Accept json
// @Param request body dto.AgentRoleDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/delete [post]
func (b *BaseApi) DeleteAgentRole(c *gin.Context) {
var req dto.AgentRoleDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.DeleteRole(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get configured Agent roles from config file
// @Accept json
// @Param request body dto.AgentConfiguredAgentsReq true "request"
// @Success 200 {array} dto.AgentConfiguredAgentItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/list [post]
func (b *BaseApi) GetConfiguredAgentRoles(c *gin.Context) {
var req dto.AgentConfiguredAgentsReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetConfiguredAgents(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Get Agent role channels from config file
// @Accept json
// @Param request body dto.AgentRoleChannelsReq true "request"
// @Success 200 {array} dto.AgentRoleChannelItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/channels [post]
func (b *BaseApi) GetAgentRoleChannels(c *gin.Context) {
var req dto.AgentRoleChannelsReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetRoleChannels(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Get Agent role markdown files
// @Accept json
// @Param request body dto.AgentRoleMarkdownFilesReq true "request"
// @Success 200 {array} dto.AgentRoleMarkdownFileItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/md/list [post]
func (b *BaseApi) GetAgentRoleMarkdownFiles(c *gin.Context) {
var req dto.AgentRoleMarkdownFilesReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := agentService.GetRoleMarkdownFiles(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, data)
}
// @Tags AI
// @Summary Update Agent role markdown file
// @Accept json
// @Param request body dto.AgentRoleMarkdownFilesUpdateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/agent/md/update [post]
func (b *BaseApi) UpdateAgentRoleMarkdownFile(c *gin.Context) {
var req dto.AgentRoleMarkdownFilesUpdateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UpdateRoleMarkdownFiles(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent Feishu channel config
// @Accept json
+66
View File
@@ -88,6 +88,72 @@ type AgentOverview struct {
Snapshot AgentOverviewSnapshot `json:"snapshot"`
}
type AgentRoleBinding struct {
Channel string `json:"channel" validate:"required"`
AccountID string `json:"accountId"`
}
type AgentRoleCreateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Name string `json:"name" validate:"required"`
Model string `json:"model"`
Bindings []AgentRoleBinding `json:"bindings"`
}
type AgentRoleCreateResp struct {
Output string `json:"output"`
}
type AgentRoleDeleteReq struct {
AgentID uint `json:"agentId" validate:"required"`
ID string `json:"id" validate:"required"`
}
type AgentConfiguredAgentsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentRoleChannelsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentRoleChannelItem struct {
Name string `json:"name"`
Bound bool `json:"bound"`
AccountIDs []string `json:"accountIds"`
}
type AgentRoleMarkdownFilesReq struct {
AgentID uint `json:"agentId" validate:"required"`
Workspace string `json:"workspace" validate:"required"`
}
type AgentConfiguredAgentItem struct {
ID string `json:"id"`
Name string `json:"name"`
Workspace string `json:"workspace"`
Model string `json:"model"`
AgentDir string `json:"agentDir"`
Bindings []AgentRoleBinding `json:"bindings"`
}
type AgentRoleMarkdownFileItem struct {
Name string `json:"name"`
Content string `json:"content"`
}
type AgentRoleMarkdownFileUpdateItem struct {
Name string `json:"name" validate:"required,oneof=AGENTS.md SOUL.md USER.md IDENTITY.md TOOLS.md HEARTBEAT.md BOOT.md BOOTSTRAP.md"`
Content string `json:"content"`
}
type AgentRoleMarkdownFilesUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Workspace string `json:"workspace" validate:"required"`
Restart bool `json:"restart"`
Files []AgentRoleMarkdownFileUpdateItem `json:"files" validate:"required"`
}
type AgentOverviewSnapshot struct {
ContainerStatus string `json:"containerStatus"`
AppVersion string `json:"appVersion"`
+7
View File
@@ -44,6 +44,13 @@ type IAgentService interface {
UpdateSkill(req dto.AgentSkillUpdateReq) error
InstallSkill(req dto.AgentSkillInstallReq) error
CreateRole(req dto.AgentRoleCreateReq) (*dto.AgentRoleCreateResp, error)
DeleteRole(req dto.AgentRoleDeleteReq) error
GetConfiguredAgents(req dto.AgentConfiguredAgentsReq) ([]dto.AgentConfiguredAgentItem, error)
GetRoleChannels(req dto.AgentRoleChannelsReq) ([]dto.AgentRoleChannelItem, error)
GetRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesReq) ([]dto.AgentRoleMarkdownFileItem, error)
UpdateRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesUpdateReq) error
CreateAccount(req dto.AgentAccountCreateReq) error
UpdateAccount(req dto.AgentAccountUpdateReq) error
SyncAgentsByAccount(account *model.AgentAccount) error
+390
View File
@@ -0,0 +1,390 @@
package service
import (
"os"
"path"
"sort"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
var agentMarkdownFileNames = []string{"AGENTS.md", "SOUL.md", "USER.md", "IDENTITY.md", "TOOLS.md", "HEARTBEAT.md", "BOOT.md", "BOOTSTRAP.md"}
func (a AgentService) CreateRole(req dto.AgentRoleCreateReq) (*dto.AgentRoleCreateResp, error) {
_, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
name := strings.TrimSpace(req.Name)
args := []string{"exec", install.ContainerName, "openclaw", "agents", "add", name}
workspace := "/home/node/.openclaw/workspace-agent_" + name
agentDir := "/home/node/.openclaw/agents/" + name
args = append(args, "--workspace", workspace)
if model := strings.TrimSpace(req.Model); model != "" {
args = append(args, "--model", model)
}
for _, binding := range req.Bindings {
channel := strings.TrimSpace(binding.Channel)
if channel == "" {
continue
}
if accountID := strings.TrimSpace(binding.AccountID); accountID != "" {
channel = channel + ":" + accountID
}
args = append(args, "--bind", channel)
}
args = append(args, "--agent-dir", agentDir)
args = append(args, "--non-interactive", "--json")
mgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute))
output, err := mgr.RunWithStdout("docker", args...)
if err != nil {
return nil, err
}
return &dto.AgentRoleCreateResp{Output: strings.TrimSpace(output)}, nil
}
func (a AgentService) GetConfiguredAgents(req dto.AgentConfiguredAgentsReq) ([]dto.AgentConfiguredAgentItem, error) {
agent, _, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
agents, ok := conf["agents"].(map[string]interface{})
if !ok {
return []dto.AgentConfiguredAgentItem{}, nil
}
rawList, ok := agents["list"].([]interface{})
if !ok {
return []dto.AgentConfiguredAgentItem{}, nil
}
result := make([]dto.AgentConfiguredAgentItem, 0, len(rawList))
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
for _, item := range rawList {
record, ok := item.(map[string]interface{})
if !ok {
continue
}
configured := extractConfiguredAgentItem(baseDir, record)
if strings.EqualFold(configured.ID, "main") {
continue
}
result = append(result, configured)
}
applyConfiguredAgentBindings(result, conf["bindings"])
return result, nil
}
func (a AgentService) GetRoleChannels(req dto.AgentRoleChannelsReq) ([]dto.AgentRoleChannelItem, error) {
_, _, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
channels, ok := conf["channels"].(map[string]interface{})
if !ok || len(channels) == 0 {
return []dto.AgentRoleChannelItem{}, nil
}
boundChannels := loadBoundChannelSet(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{})
result = append(result, dto.AgentRoleChannelItem{
Name: key,
Bound: boundChannels[key],
AccountIDs: extractChannelAccountIDs(channelConf),
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result, nil
}
func (a AgentService) DeleteRole(req dto.AgentRoleDeleteReq) 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 := strings.TrimSpace(req.ID)
if roleID == "" {
return buserr.New("ErrRecordNotFound")
}
target, ok := findConfiguredAgentByID(baseDir, conf, roleID)
if !ok {
return buserr.New("ErrRecordNotFound")
}
args := []string{"exec", install.ContainerName, "openclaw", "agents", "delete", roleID, "--force"}
mgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute))
if _, err = mgr.RunWithStdout("docker", args...); err != nil {
return err
}
if target.Workspace != "" {
if err := os.RemoveAll(target.Workspace); err != nil {
return err
}
}
if target.AgentDir != "" {
if err := os.RemoveAll(target.AgentDir); err != nil {
return err
}
}
return nil
}
func (a AgentService) GetRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesReq) ([]dto.AgentRoleMarkdownFileItem, error) {
agent, err := loadOpenclawAgentByID(req.AgentID)
if err != nil {
return nil, err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
workspaceDir, err := resolveMarkdownWorkspaceDir(baseDir, req.Workspace)
if err != nil {
return nil, err
}
items := make([]dto.AgentRoleMarkdownFileItem, 0, len(agentMarkdownFileNames))
for _, name := range agentMarkdownFileNames {
item := dto.AgentRoleMarkdownFileItem{
Name: name,
}
content, readErr := os.ReadFile(path.Join(workspaceDir, name))
if readErr == nil {
item.Content = string(content)
} else if !os.IsNotExist(readErr) {
return nil, readErr
}
items = append(items, item)
}
return items, nil
}
func (a AgentService) UpdateRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesUpdateReq) error {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
dirPath, err := resolveMarkdownWorkspaceDir(baseDir, req.Workspace)
if err != nil {
return err
}
if err = os.MkdirAll(dirPath, 0755); err != nil {
return err
}
for _, item := range req.Files {
file := path.Join(dirPath, item.Name)
if err = os.WriteFile(file, []byte(item.Content), 0644); err != nil {
return err
}
}
if req.Restart {
return NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID,
Operate: constant.Restart,
})
}
return nil
}
func extractConfiguredAgentItem(installDir string, record map[string]interface{}) dto.AgentConfiguredAgentItem {
item := dto.AgentConfiguredAgentItem{Bindings: []dto.AgentRoleBinding{}}
if id, ok := record["id"].(string); ok {
item.ID = strings.TrimSpace(id)
} else if id, ok := record["agentId"].(string); ok {
item.ID = strings.TrimSpace(id)
}
if name, ok := record["name"].(string); ok {
item.Name = strings.TrimSpace(name)
}
if workspace, ok := record["workspace"].(string); ok {
item.Workspace = resolveRoleDir(installDir, strings.TrimSpace(workspace))
}
if model, ok := record["model"].(string); ok {
item.Model = strings.TrimSpace(model)
}
if agentDir, ok := record["agentDir"].(string); ok {
item.AgentDir = strings.TrimSpace(agentDir)
} else if agentDir, ok := record["agent_dir"].(string); ok {
item.AgentDir = strings.TrimSpace(agentDir)
}
item.AgentDir = resolveRoleDir(installDir, item.AgentDir)
return item
}
func findConfiguredAgentByID(baseDir string, conf map[string]interface{}, id string) (dto.AgentConfiguredAgentItem, bool) {
agents, ok := conf["agents"].(map[string]interface{})
if !ok {
return dto.AgentConfiguredAgentItem{}, false
}
rawList, ok := agents["list"].([]interface{})
if !ok {
return dto.AgentConfiguredAgentItem{}, false
}
for _, item := range rawList {
record, ok := item.(map[string]interface{})
if !ok {
continue
}
configured := extractConfiguredAgentItem(baseDir, record)
if strings.EqualFold(strings.TrimSpace(configured.ID), strings.TrimSpace(id)) {
return configured, true
}
}
return dto.AgentConfiguredAgentItem{}, false
}
func applyConfiguredAgentBindings(agents []dto.AgentConfiguredAgentItem, value interface{}) {
bindings, ok := value.([]interface{})
if !ok || len(agents) == 0 {
return
}
indexByID := make(map[string]int, len(agents))
indexByName := make(map[string]int, len(agents))
for i, agent := range agents {
if agent.ID != "" {
indexByID[agent.ID] = i
}
if agent.Name != "" {
indexByName[agent.Name] = i
}
}
for _, binding := range bindings {
record, ok := binding.(map[string]interface{})
if !ok {
continue
}
if bindingType, _ := record["type"].(string); !strings.EqualFold(strings.TrimSpace(bindingType), "route") {
continue
}
targetID, _ := record["agentId"].(string)
targetID = strings.TrimSpace(targetID)
if targetID == "" {
continue
}
match, ok := record["match"].(map[string]interface{})
if !ok {
continue
}
channel, _ := match["channel"].(string)
channel = strings.TrimSpace(channel)
if channel == "" {
continue
}
index, ok := indexByID[targetID]
if !ok {
index, ok = indexByName[targetID]
}
if !ok {
continue
}
accountID, _ := match["accountId"].(string)
if strings.TrimSpace(accountID) == "" {
accountID, _ = record["accountId"].(string)
}
agents[index].Bindings = append(agents[index].Bindings, dto.AgentRoleBinding{
Channel: channel,
AccountID: strings.TrimSpace(accountID),
})
}
}
func loadBoundChannelSet(value interface{}) map[string]bool {
result := make(map[string]bool)
bindings, ok := value.([]interface{})
if !ok {
return result
}
for _, binding := range bindings {
record, ok := binding.(map[string]interface{})
if !ok {
continue
}
if bindingType, _ := record["type"].(string); !strings.EqualFold(strings.TrimSpace(bindingType), "route") {
continue
}
match, ok := record["match"].(map[string]interface{})
if !ok {
continue
}
channel, _ := match["channel"].(string)
channel = strings.TrimSpace(channel)
if channel == "" {
continue
}
result[channel] = true
}
return result
}
func extractChannelAccountIDs(channel map[string]interface{}) []string {
if len(channel) == 0 {
return []string{}
}
accounts, ok := channel["accounts"].(map[string]interface{})
if !ok || len(accounts) == 0 {
return []string{}
}
result := make([]string, 0, len(accounts))
for key := range accounts {
key = strings.TrimSpace(key)
if key == "" {
continue
}
result = append(result, key)
}
sort.Strings(result)
return result
}
func resolveRoleDir(installDir, workspace string) string {
workspace = strings.TrimSpace(workspace)
if workspace == "" {
return ""
}
if strings.HasPrefix(workspace, "/home/node/.openclaw/workspace/") {
return strings.ReplaceAll(workspace, "/home/node/.openclaw", installDir)
}
return strings.ReplaceAll(workspace, "/home/node/.openclaw", path.Join(installDir, "conf"))
}
func resolveMarkdownWorkspaceDir(installDir, workspace string) (string, error) {
workspace = strings.TrimSpace(workspace)
if workspace == "" {
return "", buserr.New("ErrRecordNotFound")
}
confDir := path.Join(installDir, "conf")
allowedOpenclawPrefixes := []string{
"/home/node/.openclaw/workspace/",
"/home/node/.openclaw/workspace-agent_",
}
for _, prefix := range allowedOpenclawPrefixes {
if strings.HasPrefix(workspace, prefix) {
return resolveRoleDir(installDir, workspace), nil
}
}
cleanWorkspace := path.Clean(workspace)
cleanConfDir := path.Clean(confDir)
if cleanWorkspace == cleanConfDir || strings.HasPrefix(cleanWorkspace, cleanConfDir+"/") {
return cleanWorkspace, nil
}
return "", buserr.New("ErrRecordNotFound")
}
+6
View File
@@ -57,6 +57,12 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/accounts/models/delete", baseApi.DeleteAgentAccountModel)
aiToolsRouter.POST("/agents/accounts/verify", baseApi.VerifyAgentAccount)
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/list", baseApi.GetConfiguredAgentRoles)
aiToolsRouter.POST("/agents/agent/channels", baseApi.GetAgentRoleChannels)
aiToolsRouter.POST("/agents/agent/md/list", baseApi.GetAgentRoleMarkdownFiles)
aiToolsRouter.POST("/agents/agent/md/update", baseApi.UpdateAgentRoleMarkdownFile)
aiToolsRouter.POST("/agents/channel/feishu/get", baseApi.GetAgentFeishuConfig)
aiToolsRouter.POST("/agents/channel/feishu/update", baseApi.UpdateAgentFeishuConfig)
aiToolsRouter.POST("/agents/channel/telegram/get", baseApi.GetAgentTelegramConfig)
+66
View File
@@ -314,6 +314,72 @@ export namespace AI {
agentId: number;
}
export interface AgentRoleCreateReq {
agentId: number;
name: string;
model: string;
bindings: AgentRoleBinding[];
}
export interface AgentRoleBinding {
channel: string;
accountId: string;
}
export interface AgentRoleCreateResp {
output: string;
}
export interface AgentRoleDeleteReq {
agentId: number;
id: string;
}
export interface AgentConfiguredAgentsReq {
agentId: number;
}
export interface AgentRoleChannelsReq {
agentId: number;
}
export interface AgentRoleChannelItem {
name: string;
bound: boolean;
accountIds: string[];
}
export interface AgentRoleMarkdownFilesReq {
agentId: number;
workspace: string;
}
export interface AgentConfiguredAgentItem {
id: string;
name: string;
workspace: string;
model: string;
agentDir: string;
bindings: AgentRoleBinding[];
}
export interface AgentRoleMarkdownFileItem {
name: string;
content: string;
}
export interface AgentRoleMarkdownFileUpdateItem {
name: string;
content: string;
}
export interface AgentRoleMarkdownFilesUpdateReq {
agentId: number;
workspace: string;
restart: boolean;
files: AgentRoleMarkdownFileUpdateItem[];
}
export interface AgentOverviewSnapshot {
containerStatus: string;
appVersion: string;
+24
View File
@@ -121,6 +121,30 @@ export const getAgentOverview = (req: AI.AgentOverviewReq) => {
return http.post<AI.AgentOverview>(`/ai/agents/overview`, req);
};
export const createAgentRole = (req: AI.AgentRoleCreateReq) => {
return http.post<AI.AgentRoleCreateResp>(`/ai/agents/agent/create`, req);
};
export const deleteAgentRole = (req: AI.AgentRoleDeleteReq) => {
return http.post(`/ai/agents/agent/delete`, req);
};
export const getConfiguredAgentRoles = (req: AI.AgentConfiguredAgentsReq) => {
return http.post<AI.AgentConfiguredAgentItem[]>(`/ai/agents/agent/list`, req);
};
export const getAgentRoleChannels = (req: AI.AgentRoleChannelsReq) => {
return http.post<AI.AgentRoleChannelItem[]>(`/ai/agents/agent/channels`, req);
};
export const getAgentRoleMarkdownFiles = (req: AI.AgentRoleMarkdownFilesReq) => {
return http.post<AI.AgentRoleMarkdownFileItem[]>(`/ai/agents/agent/md/list`, req);
};
export const updateAgentRoleMarkdownFile = (req: AI.AgentRoleMarkdownFilesUpdateReq) => {
return http.post(`/ai/agents/agent/md/update`, req);
};
export const getAgentProviders = () => {
return http.get<AI.ProviderInfo[]>(`/ai/agents/providers`);
};
+36
View File
@@ -720,6 +720,42 @@ const message = {
skillsGroupWorkspace: 'Workspace',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
agentRoleTab: 'Agents',
agentRoleUnsupported: 'Role management is currently supported only for OpenClaw.',
workspace: 'Workspace Directory',
agentDir: 'Agent Directory',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Operating instructions for the agent and how it should use memory.',
'Loaded at the start of every session.',
'Good place for rules, priorities, and "how to behave" details.',
],
'SOUL.md': ['Persona, tone, and boundaries.', 'Loaded every session.'],
'USER.md': ['Who the user is and how to address them.', 'Loaded every session.'],
'IDENTITY.md': [
"The agent's name, vibe, and emoji.",
'Created or updated during the bootstrap ritual.',
],
'TOOLS.md': [
'Notes about your local tools and conventions.',
'Does not control tool availability; it is only guidance.',
],
'HEARTBEAT.md': ['Optional tiny checklist for heartbeat runs.', 'Keep it short to avoid token burn.'],
'BOOT.md': [
'Optional startup checklist executed on gateway restart when internal hooks are enabled.',
'Keep it short; use the message tool for outbound sends.',
],
'BOOTSTRAP.md': [
'One-time first-run ritual.',
'Only created for a brand-new workspace.',
'Delete it after the ritual is complete.',
],
},
bindings: 'Bindings',
accountIdOptional: 'Account ID (Optional)',
saveAllMd: 'Save All',
roleMarkdownRestartHelper:
'Saving all current markdown files requires a container restart to take effect. Choose whether to restart now or later.',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
+36
View File
@@ -728,6 +728,42 @@ const message = {
skillsGroupWorkspace: 'Workspace',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
agentRoleTab: 'Agents',
agentRoleUnsupported: 'Role management is currently supported only for OpenClaw.',
workspace: 'Workspace Directory',
agentDir: 'Agent Directory',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Operating instructions for the agent and how it should use memory.',
'Loaded at the start of every session.',
'Good place for rules, priorities, and "how to behave" details.',
],
'SOUL.md': ['Persona, tone, and boundaries.', 'Loaded every session.'],
'USER.md': ['Who the user is and how to address them.', 'Loaded every session.'],
'IDENTITY.md': [
"The agent's name, vibe, and emoji.",
'Created or updated during the bootstrap ritual.',
],
'TOOLS.md': [
'Notes about your local tools and conventions.',
'Does not control tool availability; it is only guidance.',
],
'HEARTBEAT.md': ['Optional tiny checklist for heartbeat runs.', 'Keep it short to avoid token burn.'],
'BOOT.md': [
'Optional startup checklist executed on gateway restart when internal hooks are enabled.',
'Keep it short; use the message tool for outbound sends.',
],
'BOOTSTRAP.md': [
'One-time first-run ritual.',
'Only created for a brand-new workspace.',
'Delete it after the ritual is complete.',
],
},
bindings: 'Bindings',
accountIdOptional: 'Account ID (Optional)',
saveAllMd: 'Save All',
roleMarkdownRestartHelper:
'Saving all current markdown files requires a container restart to take effect. Choose whether to restart now or later.',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
+36
View File
@@ -721,6 +721,42 @@ const message = {
skillsGroupWorkspace: 'Workspace',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
agentRoleTab: 'Agents',
agentRoleUnsupported: 'Role management is currently supported only for OpenClaw.',
workspace: 'Workspace Directory',
agentDir: 'Agent Directory',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Operating instructions for the agent and how it should use memory.',
'Loaded at the start of every session.',
'Good place for rules, priorities, and "how to behave" details.',
],
'SOUL.md': ['Persona, tone, and boundaries.', 'Loaded every session.'],
'USER.md': ['Who the user is and how to address them.', 'Loaded every session.'],
'IDENTITY.md': [
"The agent's name, vibe, and emoji.",
'Created or updated during the bootstrap ritual.',
],
'TOOLS.md': [
'Notes about your local tools and conventions.',
'Does not control tool availability; it is only guidance.',
],
'HEARTBEAT.md': ['Optional tiny checklist for heartbeat runs.', 'Keep it short to avoid token burn.'],
'BOOT.md': [
'Optional startup checklist executed on gateway restart when internal hooks are enabled.',
'Keep it short; use the message tool for outbound sends.',
],
'BOOTSTRAP.md': [
'One-time first-run ritual.',
'Only created for a brand-new workspace.',
'Delete it after the ritual is complete.',
],
},
bindings: 'Bindings',
accountIdOptional: 'Account ID (Optional)',
saveAllMd: 'Save All',
roleMarkdownRestartHelper:
'Saving all current markdown files requires a container restart to take effect. Choose whether to restart now or later.',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
+36
View File
@@ -713,6 +713,42 @@ const message = {
skillsGroupWorkspace: 'Workspace',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
agentRoleTab: 'Agents',
agentRoleUnsupported: 'Role management is currently supported only for OpenClaw.',
workspace: 'Workspace Directory',
agentDir: 'Agent Directory',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Operating instructions for the agent and how it should use memory.',
'Loaded at the start of every session.',
'Good place for rules, priorities, and "how to behave" details.',
],
'SOUL.md': ['Persona, tone, and boundaries.', 'Loaded every session.'],
'USER.md': ['Who the user is and how to address them.', 'Loaded every session.'],
'IDENTITY.md': [
"The agent's name, vibe, and emoji.",
'Created or updated during the bootstrap ritual.',
],
'TOOLS.md': [
'Notes about your local tools and conventions.',
'Does not control tool availability; it is only guidance.',
],
'HEARTBEAT.md': ['Optional tiny checklist for heartbeat runs.', 'Keep it short to avoid token burn.'],
'BOOT.md': [
'Optional startup checklist executed on gateway restart when internal hooks are enabled.',
'Keep it short; use the message tool for outbound sends.',
],
'BOOTSTRAP.md': [
'One-time first-run ritual.',
'Only created for a brand-new workspace.',
'Delete it after the ritual is complete.',
],
},
bindings: 'Bindings',
accountIdOptional: 'Account ID (Optional)',
saveAllMd: 'Save All',
roleMarkdownRestartHelper:
'Saving all current markdown files requires a container restart to take effect. Choose whether to restart now or later.',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
+36
View File
@@ -728,6 +728,42 @@ const message = {
skillsGroupWorkspace: 'Workspace',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
agentRoleTab: 'Agents',
agentRoleUnsupported: 'Role management is currently supported only for OpenClaw.',
workspace: 'Workspace Directory',
agentDir: 'Agent Directory',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Operating instructions for the agent and how it should use memory.',
'Loaded at the start of every session.',
'Good place for rules, priorities, and "how to behave" details.',
],
'SOUL.md': ['Persona, tone, and boundaries.', 'Loaded every session.'],
'USER.md': ['Who the user is and how to address them.', 'Loaded every session.'],
'IDENTITY.md': [
"The agent's name, vibe, and emoji.",
'Created or updated during the bootstrap ritual.',
],
'TOOLS.md': [
'Notes about your local tools and conventions.',
'Does not control tool availability; it is only guidance.',
],
'HEARTBEAT.md': ['Optional tiny checklist for heartbeat runs.', 'Keep it short to avoid token burn.'],
'BOOT.md': [
'Optional startup checklist executed on gateway restart when internal hooks are enabled.',
'Keep it short; use the message tool for outbound sends.',
],
'BOOTSTRAP.md': [
'One-time first-run ritual.',
'Only created for a brand-new workspace.',
'Delete it after the ritual is complete.',
],
},
bindings: 'Bindings',
accountIdOptional: 'Account ID (Optional)',
saveAllMd: 'Save All',
roleMarkdownRestartHelper:
'Saving all current markdown files requires a container restart to take effect. Choose whether to restart now or later.',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
+36
View File
@@ -723,6 +723,42 @@ const message = {
skillsGroupWorkspace: 'Workspace',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
agentRoleTab: 'Agents',
agentRoleUnsupported: 'Role management is currently supported only for OpenClaw.',
workspace: 'Workspace Directory',
agentDir: 'Agent Directory',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Operating instructions for the agent and how it should use memory.',
'Loaded at the start of every session.',
'Good place for rules, priorities, and "how to behave" details.',
],
'SOUL.md': ['Persona, tone, and boundaries.', 'Loaded every session.'],
'USER.md': ['Who the user is and how to address them.', 'Loaded every session.'],
'IDENTITY.md': [
"The agent's name, vibe, and emoji.",
'Created or updated during the bootstrap ritual.',
],
'TOOLS.md': [
'Notes about your local tools and conventions.',
'Does not control tool availability; it is only guidance.',
],
'HEARTBEAT.md': ['Optional tiny checklist for heartbeat runs.', 'Keep it short to avoid token burn.'],
'BOOT.md': [
'Optional startup checklist executed on gateway restart when internal hooks are enabled.',
'Keep it short; use the message tool for outbound sends.',
],
'BOOTSTRAP.md': [
'One-time first-run ritual.',
'Only created for a brand-new workspace.',
'Delete it after the ritual is complete.',
],
},
bindings: 'Bindings',
accountIdOptional: 'Account ID (Optional)',
saveAllMd: 'Save All',
roleMarkdownRestartHelper:
'Saving all current markdown files requires a container restart to take effect. Choose whether to restart now or later.',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
+36
View File
@@ -720,6 +720,42 @@ const message = {
skillsGroupWorkspace: 'Workspace',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
agentRoleTab: 'Agents',
agentRoleUnsupported: 'Role management is currently supported only for OpenClaw.',
workspace: 'Workspace Directory',
agentDir: 'Agent Directory',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Operating instructions for the agent and how it should use memory.',
'Loaded at the start of every session.',
'Good place for rules, priorities, and "how to behave" details.',
],
'SOUL.md': ['Persona, tone, and boundaries.', 'Loaded every session.'],
'USER.md': ['Who the user is and how to address them.', 'Loaded every session.'],
'IDENTITY.md': [
"The agent's name, vibe, and emoji.",
'Created or updated during the bootstrap ritual.',
],
'TOOLS.md': [
'Notes about your local tools and conventions.',
'Does not control tool availability; it is only guidance.',
],
'HEARTBEAT.md': ['Optional tiny checklist for heartbeat runs.', 'Keep it short to avoid token burn.'],
'BOOT.md': [
'Optional startup checklist executed on gateway restart when internal hooks are enabled.',
'Keep it short; use the message tool for outbound sends.',
],
'BOOTSTRAP.md': [
'One-time first-run ritual.',
'Only created for a brand-new workspace.',
'Delete it after the ritual is complete.',
],
},
bindings: 'Bindings',
accountIdOptional: 'Account ID (Optional)',
saveAllMd: 'Save All',
roleMarkdownRestartHelper:
'Saving all current markdown files requires a container restart to take effect. Choose whether to restart now or later.',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
+36
View File
@@ -724,6 +724,42 @@ const message = {
skillsGroupWorkspace: 'Workspace',
switchModelSuccess: 'Model switched successfully',
channelsTab: 'Channels',
agentRoleTab: 'Agents',
agentRoleUnsupported: 'Role management is currently supported only for OpenClaw.',
workspace: 'Workspace Directory',
agentDir: 'Agent Directory',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Operating instructions for the agent and how it should use memory.',
'Loaded at the start of every session.',
'Good place for rules, priorities, and "how to behave" details.',
],
'SOUL.md': ['Persona, tone, and boundaries.', 'Loaded every session.'],
'USER.md': ['Who the user is and how to address them.', 'Loaded every session.'],
'IDENTITY.md': [
"The agent's name, vibe, and emoji.",
'Created or updated during the bootstrap ritual.',
],
'TOOLS.md': [
'Notes about your local tools and conventions.',
'Does not control tool availability; it is only guidance.',
],
'HEARTBEAT.md': ['Optional tiny checklist for heartbeat runs.', 'Keep it short to avoid token burn.'],
'BOOT.md': [
'Optional startup checklist executed on gateway restart when internal hooks are enabled.',
'Keep it short; use the message tool for outbound sends.',
],
'BOOTSTRAP.md': [
'One-time first-run ritual.',
'Only created for a brand-new workspace.',
'Delete it after the ritual is complete.',
],
},
bindings: 'Bindings',
accountIdOptional: 'Account ID (Optional)',
saveAllMd: 'Save All',
roleMarkdownRestartHelper:
'Saving all current markdown files requires a container restart to take effect. Choose whether to restart now or later.',
configFileRestartHelper:
'Saving the config file requires immediately restarting the container to take effect.',
overviewSnapshot: 'Snapshot',
+25
View File
@@ -686,6 +686,31 @@ const message = {
skillsGroupWorkspace: '工作區',
switchModelSuccess: '模型切換成功',
channelsTab: '頻道',
agentRoleTab: '角色',
agentRoleUnsupported: '目前僅 OpenClaw 支援角色管理',
workspace: '工作區目錄',
agentDir: 'Agent 目錄',
roleMarkdownDescriptions: {
'AGENTS.md': [
'Agent 的操作說明以及如何使用記憶',
'每次會話開始時都會載入',
'適合放規則優先級和行為方式等內容',
],
'SOUL.md': ['人格語氣和邊界', '每次會話都會載入'],
'USER.md': ['使用者是誰以及應該如何稱呼和回應使用者', '每次會話都會載入'],
'IDENTITY.md': ['Agent 的名字氣質和 emoji', '會在 bootstrap 儀式中建立或更新'],
'TOOLS.md': ['關於本地工具和約定的說明', '不會控制工具可用性僅作為使用指引'],
'HEARTBEAT.md': ['可選的心跳執行檢查清單', '盡量保持簡短避免消耗過多 token'],
'BOOT.md': [
'可選的啟動檢查清單會在 gateway 重啟且啟用內部 hooks 時執行',
'盡量保持簡短如需對外發送訊息請使用 message 工具',
],
'BOOTSTRAP.md': ['一次性的首次執行儀式', '只會在全新的 workspace 中建立', '儀式完成後請刪除它'],
},
bindings: '綁定',
accountIdOptional: '帳號 ID可選',
saveAllMd: '保存全部',
roleMarkdownRestartHelper: '保存當前全部 MD 檔案後需要重新啟動容器才能生效請選擇立即重啟或稍後重啟',
configFileRestartHelper: '保存配置檔後需要立即重新啟動容器才能生效',
overviewSnapshot: '狀態概覽',
defaultModel: '預設模型',
+21
View File
@@ -685,6 +685,27 @@ const message = {
skillsGroupWorkspace: '工作区',
switchModelSuccess: '模型切换成功',
channelsTab: '频道',
agentRoleTab: '角色',
agentRoleUnsupported: '当前仅 OpenClaw 支持角色管理',
workspace: '工作区目录',
agentDir: 'Agent 目录',
roleMarkdownDescriptions: {
'AGENTS.md': ['Agent 的操作说明以及如何使用记忆', '适合放规则优先级和行为方式等内容'],
'SOUL.md': ['人格语气和边界'],
'USER.md': ['用户是谁以及应该如何称呼和回应用户'],
'IDENTITY.md': ['Agent 的名字气质和 emoji'],
'TOOLS.md': ['关于本地工具和约定的说明'],
'HEARTBEAT.md': ['可选的心跳运行检查清单', '尽量保持简短避免消耗过多 token'],
'BOOT.md': [
'可选的启动检查清单会在 gateway 重启且启用内部 hooks 时执行',
'尽量保持简短如需对外发送消息请使用 message 工具',
],
'BOOTSTRAP.md': ['首次运行引导流程', '只会在全新的工作区中创建'],
},
bindings: '绑定',
accountIdOptional: '账号 ID可选',
saveAllMd: '保存全部',
roleMarkdownRestartHelper: '保存当前全部 MD 文件后需要重启容器才能生效请选择立即重启或稍后重启',
configFileRestartHelper: '保存配置文件后需要重启容器才能生效',
overviewSnapshot: '状态概览',
defaultModel: '默认模型',
@@ -8,6 +8,9 @@
<el-tab-pane :label="t('aiTools.model.model')" name="model">
<ModelTab ref="modelRef" @updated="handleModelUpdated" />
</el-tab-pane>
<el-tab-pane :label="t('aiTools.agents.agentRoleTab')" name="agent">
<AgentTab ref="agentRef" />
</el-tab-pane>
<el-tab-pane :label="t('aiTools.agents.skillsTab')" name="skills">
<SkillsTab ref="skillsRef" :app-version="appVersion" />
</el-tab-pane>
@@ -26,6 +29,7 @@ import { useI18n } from 'vue-i18n';
import { AI } from '@/api/interface/ai';
import ChannelsTab from './tabs/channels.vue';
import ModelTab from './tabs/model.vue';
import AgentTab from './tabs/agents/index.vue';
import SkillsTab from './tabs/skills.vue';
import SettingsTab from './tabs/settings.vue';
@@ -38,8 +42,11 @@ const agentId = ref(0);
const accountId = ref(0);
const model = ref('');
const appVersion = ref('');
const configPath = ref('');
const agentType = ref<'openclaw' | 'copaw'>('openclaw');
const channelsRef = ref();
const modelRef = ref();
const agentRef = ref();
const skillsRef = ref();
const settingsRef = ref();
@@ -74,6 +81,20 @@ const loadChannels = async () => {
await channelsRef.value?.load(agentId.value);
};
const loadAgent = async () => {
if (agentId.value <= 0) {
return;
}
await nextTick();
await agentRef.value?.load({
agentId: agentId.value,
agentType: agentType.value,
accountId: accountId.value,
model: model.value,
configPath: configPath.value,
});
};
const loadSkills = async () => {
if (agentId.value <= 0) {
return;
@@ -96,6 +117,9 @@ const handleTabClick = async (pane: TabsPaneContext) => {
if (pane.paneName === 'skills') {
await loadSkills();
}
if (pane.paneName === 'agent') {
await loadAgent();
}
if (pane.paneName === 'channels' && agentId.value > 0) {
await loadChannels();
}
@@ -110,6 +134,8 @@ const openDrawer = async (agent: AI.AgentItem) => {
accountId.value = agent.accountId;
model.value = agent.model;
appVersion.value = agent.appVersion;
configPath.value = agent.configPath;
agentType.value = agent.agentType;
header.value = `${agent.name} - ${t('menu.config')}`;
activeTab.value = 'channels';
open.value = true;
@@ -0,0 +1,276 @@
<template>
<DialogPro v-model="open" :title="`${$t('commons.button.add')}`" size="large" @close="handleClose">
<div v-loading="loading" class="create-role-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<div class="create-role-section">
<div class="create-role-section__title">{{ $t('commons.table.name') }}</div>
<el-form-item :label="$t('commons.table.name')" prop="name">
<el-input v-model.trim="form.name" />
</el-form-item>
<el-form-item :label="$t('aiTools.model.model')">
<el-select v-model="form.model" clearable filterable class="w-full">
<el-option
v-for="item in modelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<div class="bindings-divider"></div>
<el-table v-if="form.bindings.length" :data="form.bindings" class="bindings-table" size="small">
<el-table-column :label="$t('aiTools.agents.channelsTab')" min-width="180">
<template #default="{ row, $index }">
<el-select
v-model="row.channel"
clearable
filterable
class="w-full"
@change="handleBindingChannelChange($index)"
>
<el-option
v-for="item in channelOptions"
:key="item.value"
:label="item.label"
:value="item.value"
:disabled="isChannelDisabled(item, $index)"
/>
</el-select>
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.agents.accountIdOptional')" min-width="180">
<template #default="{ row }">
<el-select
v-model="row.accountId"
clearable
filterable
allow-create
default-first-option
class="w-full"
>
<el-option
v-for="item in getAccountIdOptions(row.channel)"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" width="90" align="center">
<template #default="{ $index }">
<el-button link @click="removeBinding($index)">
{{ $t('commons.button.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else :description="$t('commons.msg.noneData')" :image-size="60" />
<div class="bindings-footer">
<el-button type="primary" link @click="addBinding">
{{ $t('commons.button.add') }}
</el-button>
</div>
</div>
</el-form>
</div>
<template #footer>
<el-button :disabled="loading" @click="handleClose">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" :loading="loading" @click="submit">
{{ $t('commons.button.confirm') }}
</el-button>
</template>
</DialogPro>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import type { FormInstance } from 'element-plus';
import { createAgentRole, getAgentRoleChannels, pageAgentAccounts } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
import { useGlobalStore } from '@/composables/useGlobalStore';
interface SelectOption {
label: string;
value: string;
bound?: boolean;
accountIds?: string[];
}
interface DialogParams {
agentId: number;
accountId: number;
model: string;
}
const emit = defineEmits(['success']);
const { isIntl } = useGlobalStore();
const blockedProviders = new Set(['ark-coding-plan', 'bailian-coding-plan']);
const open = ref(false);
const loading = ref(false);
const formRef = ref<FormInstance>();
const agentId = ref(0);
const accountId = ref(0);
const modelOptions = ref<SelectOption[]>([]);
const channelOptions = ref<SelectOption[]>([]);
const form = reactive({
name: '',
bindings: [] as AI.AgentRoleBinding[],
model: '',
});
const rules = reactive({
name: [Rules.simpleName],
});
const resetForm = (currentModel?: string) => {
form.name = '';
form.bindings = [];
form.model = currentModel || '';
};
const addBinding = () => {
form.bindings.push({
channel: '',
accountId: '',
});
};
const removeBinding = (index: number) => {
form.bindings.splice(index, 1);
};
const handleBindingChannelChange = (index: number) => {
const binding = form.bindings[index];
if (!binding) {
return;
}
binding.accountId = '';
};
const getAccountIdOptions = (channel: string) => {
return channelOptions.value.find((item) => item.value === channel)?.accountIds || [];
};
const isChannelDisabled = (option: SelectOption, index: number) => {
if (option.bound) {
return true;
}
return form.bindings.some((item, bindingIndex) => bindingIndex !== index && item.channel === option.value);
};
const loadAccounts = async () => {
const res = await pageAgentAccounts({
page: 1,
pageSize: 200,
provider: '',
name: '',
});
const items = res.data.items || [];
const accountOptions = isIntl.value ? items.filter((item) => !blockedProviders.has(item.provider)) : items;
const selected = accountOptions.find((item) => item.id === accountId.value);
modelOptions.value = (selected?.models || []).map((item) => ({
label: item.name,
value: item.id,
}));
};
const loadChannels = async () => {
const res = await getAgentRoleChannels({ agentId: agentId.value });
channelOptions.value = (res.data || []).map((item) => ({
label: item.name,
value: item.name,
bound: item.bound,
accountIds: item.accountIds || [],
}));
};
const acceptParams = async (params: DialogParams) => {
agentId.value = params.agentId;
accountId.value = params.accountId;
resetForm(params.model);
loading.value = true;
try {
await Promise.all([loadAccounts(), loadChannels()]);
addBinding();
} finally {
loading.value = false;
}
open.value = true;
};
const submit = async () => {
if (!formRef.value || !agentId.value) {
return;
}
await formRef.value.validate();
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(),
})),
} as AI.AgentRoleCreateReq);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
emit('success');
handleClose();
} finally {
loading.value = false;
}
};
const handleClose = () => {
open.value = false;
};
defineExpose({
acceptParams,
});
</script>
<style scoped lang="scss">
.create-role-dialog {
padding-top: 4px;
}
.create-role-section {
padding: 14px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
background: var(--el-fill-color-blank);
}
.bindings-table {
width: 100%;
}
.bindings-table :deep(.el-select) {
width: 100%;
}
.bindings-divider {
margin: 4px 0 12px;
border-top: 1px dashed var(--el-border-color);
}
.bindings-footer {
display: flex;
justify-content: flex-start;
margin-top: 8px;
}
.w-full {
width: 100%;
}
</style>
@@ -0,0 +1,199 @@
<template>
<DialogPro v-model="open" :title="header" size="w-60" @close="handleClose">
<div v-loading="loading" class="agent-file-drawer">
<el-alert v-if="!agentId || !workspace" :title="$t('commons.msg.noneData')" type="info" :closable="false" />
<template v-else>
<el-tabs v-model="activeFile">
<el-tab-pane v-for="item in fileItems" :key="item.name" :name="item.name">
<template #label>{{ item.name }}</template>
<div class="agent-file-status" v-if="item.error">{{ item.error }}</div>
<CodemirrorPro
v-model="item.content"
:height-diff="470"
:line-wrapping="true"
:placeholder="item.name"
/>
<ul v-if="getFileDescriptionItems(item.name).length" class="agent-file-description">
<li v-for="description in getFileDescriptionItems(item.name)" :key="description">
{{ description }}
</li>
</ul>
</el-tab-pane>
</el-tabs>
</template>
</div>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="saving" @click="handleClose">{{ $t('commons.button.cancel') }}</el-button>
<el-button :loading="loading" :disabled="saving || !agentId || !workspace" @click="reloadFiles">
{{ $t('commons.button.refresh') }}
</el-button>
<el-button type="primary" :loading="saving" :disabled="!currentFile" @click="saveAllFiles">
{{ $t('aiTools.agents.saveAllMd') }}
</el-button>
</span>
</template>
</DialogPro>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import { AI } from '@/api/interface/ai';
import { getAgentRoleMarkdownFiles, updateAgentRoleMarkdownFile } from '@/api/modules/ai';
import i18n from '@/lang';
import { MsgSuccess } from '@/utils/message';
interface DialogParams {
name: string;
agentId: number;
workspace: string;
}
interface AgentFileItem {
name: string;
content: string;
error: string;
}
const FILE_NAMES = [
'AGENTS.md',
'SOUL.md',
'USER.md',
'IDENTITY.md',
'TOOLS.md',
'HEARTBEAT.md',
'BOOT.md',
'BOOTSTRAP.md',
];
const open = ref(false);
const loading = ref(false);
const saving = ref(false);
const roleName = ref('');
const agentId = ref(0);
const workspace = ref('');
const activeFile = ref(FILE_NAMES[0]);
const fileItems = ref<AgentFileItem[]>([]);
const header = computed(() => `${roleName.value || '-'}`);
const currentFile = computed(() => fileItems.value.find((item) => item.name === activeFile.value));
const getFileDescriptionItems = (name: string): string[] => {
const descriptions = i18n.global.tm('aiTools.agents.roleMarkdownDescriptions') as Record<string, unknown>;
const items = descriptions?.[name];
return Array.isArray(items) ? items.map((item) => String(item)) : [];
};
const buildFiles = (items: AI.AgentRoleMarkdownFileItem[]) =>
FILE_NAMES.map((name) => ({
name,
content: items.find((item) => item.name === name)?.content || '',
error: '',
}));
const reloadFiles = async () => {
if (!agentId.value || !workspace.value) {
return;
}
loading.value = true;
try {
const res = await getAgentRoleMarkdownFiles({
agentId: agentId.value,
workspace: workspace.value,
});
fileItems.value = buildFiles(res.data || []);
activeFile.value = fileItems.value[0]?.name || FILE_NAMES[0];
} finally {
loading.value = false;
}
};
const acceptParams = async (params: DialogParams) => {
roleName.value = params.name;
agentId.value = params.agentId;
workspace.value = params.workspace;
open.value = true;
await reloadFiles();
};
const saveAllFiles = async () => {
if (!fileItems.value.length) {
return;
}
let restart = false;
try {
await ElMessageBox.confirm(
i18n.global.t('aiTools.agents.roleMarkdownRestartHelper'),
i18n.global.t('aiTools.agents.saveAllMd'),
{
confirmButtonText: i18n.global.t('setting.restartNow'),
cancelButtonText: i18n.global.t('setting.restartLater'),
type: 'warning',
distinguishCancelAndClose: true,
},
);
restart = true;
} catch (error) {
if (error === 'cancel') {
restart = false;
} else {
return;
}
}
saving.value = true;
try {
await updateAgentRoleMarkdownFile({
agentId: agentId.value,
workspace: workspace.value,
restart,
files: fileItems.value.map((item) => ({
name: item.name,
content: item.content,
})),
});
fileItems.value.forEach((item) => {
item.error = '';
});
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
} finally {
saving.value = false;
}
};
const handleClose = () => {
open.value = false;
};
defineExpose({
acceptParams,
});
</script>
<style scoped lang="scss">
.agent-file-drawer {
min-height: 320px;
}
.agent-file-status {
margin-bottom: 12px;
color: var(--el-color-danger);
}
.agent-file-description {
margin-top: 12px;
margin-bottom: 0;
padding-left: 18px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.6;
}
.agent-file-description li + li {
margin-top: 4px;
}
.dialog-footer {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,183 @@
<template>
<div v-loading="loading">
<el-alert
v-if="agentType !== 'openclaw'"
:title="$t('aiTools.agents.agentRoleUnsupported')"
type="info"
:closable="false"
/>
<template v-else>
<div class="role-toolbar">
<el-button type="primary" @click="openCreateDialog">
{{ $t('commons.button.add') }}
</el-button>
</div>
<ComplexTable :data="configuredAgents">
<el-table-column :label="$t('commons.table.name')" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
<el-button v-if="row.workspace" type="primary" link @click="openDetailDrawer(row)">
{{ row.name || row.id || '-' }}
</el-button>
<span v-else>{{ row.name || row.id || '-' }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.model.model')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ row.model || '-' }}
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.agents.workspace')" width="100" align="center">
<template #default="{ row }">
<el-tooltip v-if="row.workspace" :content="row.workspace" placement="top">
<el-button type="primary" link @click="routerToFileWithPath(row.workspace)">
<el-icon><FolderOpened /></el-icon>
</el-button>
</el-tooltip>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.agents.agentDir')" width="120" align="center">
<template #default="{ row }">
<el-tooltip v-if="row.agentDir" :content="row.agentDir" placement="top">
<el-button type="primary" link @click="routerToFileWithPath(row.agentDir)">
<el-icon><FolderOpened /></el-icon>
</el-button>
</el-tooltip>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.agents.bindings')" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ formatBindings(row.bindings) }}
</template>
</el-table-column>
<fu-table-operations :buttons="buttons" :label="$t('commons.table.operate')" width="90" fixed="right" />
</ComplexTable>
<CreateDialog ref="createRef" @success="handleCreated" />
<DetailDrawer ref="detailRef" />
<OpDialog ref="opRef" @search="handleCreated" />
</template>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { deleteAgentRole, getConfiguredAgentRoles } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
import i18n from '@/lang';
import { routerToFileWithPath } from '@/utils/router';
import CreateDialog from './create/index.vue';
import DetailDrawer from './detail/index.vue';
interface AgentRoleLoadParams {
agentId: number;
agentType: 'openclaw' | 'copaw';
accountId: number;
model: string;
configPath: string;
}
const loading = ref(false);
const createRef = ref<InstanceType<typeof CreateDialog> | null>(null);
const detailRef = ref<InstanceType<typeof DetailDrawer> | null>(null);
const opRef = ref();
const agentId = ref(0);
const agentType = ref<'openclaw' | 'copaw'>('openclaw');
const accountId = ref(0);
const currentModel = ref('');
const configuredAgents = ref<AI.AgentConfiguredAgentItem[]>([]);
const loadConfiguredAgents = async (id: number) => {
const res = await getConfiguredAgentRoles({ agentId: id });
configuredAgents.value = res.data || [];
};
const openCreateDialog = () => {
createRef.value?.acceptParams({
agentId: agentId.value,
accountId: accountId.value,
model: currentModel.value,
});
};
const openDetailDrawer = (row: AI.AgentConfiguredAgentItem) => {
if (!row.workspace) {
return;
}
detailRef.value?.acceptParams({
name: row.name || row.id || '-',
agentId: agentId.value,
workspace: row.workspace,
});
};
const formatBindings = (bindings: AI.AgentRoleBinding[] = []) => {
if (!bindings.length) {
return '-';
}
return bindings
.map((item) => (item.accountId ? `${item.channel}:${item.accountId}` : item.channel))
.filter(Boolean)
.join(', ');
};
const handleDelete = async (row: AI.AgentConfiguredAgentItem) => {
opRef.value?.acceptParams({
title: i18n.global.t('commons.button.delete'),
msg: i18n.global.t('commons.msg.operatorHelper', [
i18n.global.t('aiTools.agents.agentRoleTab'),
i18n.global.t('commons.button.delete'),
]),
names: [row.name],
api: deleteAgentRole,
params: {
agentId: agentId.value,
id: row.id,
},
successMsg: i18n.global.t('commons.msg.operationSuccess'),
noMsg: false,
});
};
const buttons = [
{
label: i18n.global.t('commons.button.delete'),
click: (row: AI.AgentConfiguredAgentItem) => handleDelete(row),
},
];
const handleCreated = async () => {
if (!agentId.value) {
return;
}
await loadConfiguredAgents(agentId.value);
};
const load = async (params: AgentRoleLoadParams) => {
loading.value = true;
try {
agentId.value = params.agentId;
agentType.value = params.agentType;
accountId.value = params.accountId;
currentModel.value = params.model;
configuredAgents.value = [];
if (agentType.value === 'openclaw') {
await loadConfiguredAgents(params.agentId);
}
} finally {
loading.value = false;
}
};
defineExpose({
load,
});
</script>
<style scoped lang="scss">
.role-toolbar {
display: flex;
margin-bottom: 16px;
}
</style>