feat: Add an Agent Management Page (#11801)

This commit is contained in:
CityFun
2026-02-05 08:54:55 +00:00
committed by GitHub
parent 8e4e1dc749
commit e1e2b97e88
43 changed files with 2949 additions and 12 deletions
+191
View File
@@ -0,0 +1,191 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
// @Tags AI
// @Summary Create Agent
// @Accept json
// @Param request body dto.AgentCreateReq true "request"
// @Success 200 {object} dto.AgentItem
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents [post]
func (b *BaseApi) CreateAgent(c *gin.Context) {
var req dto.AgentCreateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := agentService.Create(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags AI
// @Summary Page Agents
// @Accept json
// @Param request body dto.SearchWithPage true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/search [post]
func (b *BaseApi) PageAgents(c *gin.Context) {
var req dto.SearchWithPage
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := agentService.Page(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Items: list,
Total: total,
})
}
// @Tags AI
// @Summary Delete Agent
// @Accept json
// @Param request body dto.AgentDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/delete [post]
func (b *BaseApi) DeleteAgent(c *gin.Context) {
var req dto.AgentDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.Delete(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get Providers
// @Success 200 {object} []dto.ProviderInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/providers [get]
func (b *BaseApi) GetAgentProviders(c *gin.Context) {
list, err := agentService.GetProviders()
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags AI
// @Summary Create Agent account
// @Accept json
// @Param request body dto.AgentAccountCreateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/accounts [post]
func (b *BaseApi) CreateAgentAccount(c *gin.Context) {
var req dto.AgentAccountCreateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.CreateAccount(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Update Agent account
// @Accept json
// @Param request body dto.AgentAccountUpdateReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/accounts/update [post]
func (b *BaseApi) UpdateAgentAccount(c *gin.Context) {
var req dto.AgentAccountUpdateReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.UpdateAccount(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Page Agent accounts
// @Accept json
// @Param request body dto.AgentAccountSearch true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/accounts/search [post]
func (b *BaseApi) PageAgentAccounts(c *gin.Context) {
var req dto.AgentAccountSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := agentService.PageAccounts(req)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Items: list,
Total: total,
})
}
// @Tags AI
// @Summary Verify Agent account
// @Accept json
// @Param request body dto.AgentAccountVerifyReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/accounts/verify [post]
func (b *BaseApi) VerifyAgentAccount(c *gin.Context) {
var req dto.AgentAccountVerifyReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.VerifyAccount(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Delete Agent account
// @Accept json
// @Param request body dto.AgentAccountDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/accounts/delete [post]
func (b *BaseApi) DeleteAgentAccount(c *gin.Context) {
var req dto.AgentAccountDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.DeleteAccount(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
+1 -1
View File
@@ -164,7 +164,7 @@ func (b *BaseApi) InstallApp(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
install, err := appService.Install(req)
install, err := appService.Install(req, true)
if err != nil {
helper.InternalServer(c, err)
return
+1
View File
@@ -20,6 +20,7 @@ var (
aiToolService = service.NewIAIToolService()
mcpServerService = service.NewIMcpServerService()
tensorrtLLMService = service.NewITensorRTLLMService()
agentService = service.NewIAgentService()
containerService = service.NewIContainerService()
composeTemplateService = service.NewIComposeTemplateService()
+109
View File
@@ -0,0 +1,109 @@
package dto
import "time"
type AgentCreateReq struct {
Name string `json:"name" validate:"required"`
AppVersion string `json:"appVersion" validate:"required"`
WebUIPort int `json:"webUIPort" validate:"required"`
BridgePort int `json:"bridgePort" validate:"required"`
Provider string `json:"provider" validate:"required"`
Model string `json:"model" validate:"required"`
AccountID uint `json:"accountId"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseURL"`
Token string `json:"token"`
TaskID string `json:"taskID"`
Advanced bool `json:"advanced"`
ContainerName string `json:"containerName"`
AllowPort bool `json:"allowPort"`
SpecifyIP string `json:"specifyIP"`
RestartPolicy string `json:"restartPolicy"`
CpuQuota float64 `json:"cpuQuota"`
MemoryLimit float64 `json:"memoryLimit"`
MemoryUnit string `json:"memoryUnit"`
PullImage bool `json:"pullImage"`
EditCompose bool `json:"editCompose"`
DockerCompose string `json:"dockerCompose"`
}
type AgentItem struct {
ID uint `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Model string `json:"model"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
AppVersion string `json:"appVersion"`
Container string `json:"containerName"`
WebUIPort int `json:"webUIPort"`
BridgePort int `json:"bridgePort"`
Path string `json:"path"`
ConfigPath string `json:"configPath"`
CreatedAt time.Time `json:"createdAt"`
}
type AgentDeleteReq struct {
ID uint `json:"id" validate:"required"`
TaskID string `json:"taskID"`
ForceDelete bool `json:"forceDelete"`
}
type AgentAccountCreateReq struct {
Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
Remark string `json:"remark"`
}
type AgentAccountUpdateReq struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
}
type AgentAccountVerifyReq struct {
Provider string `json:"provider" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
}
type AgentAccountDeleteReq struct {
ID uint `json:"id" validate:"required"`
}
type AgentAccountSearch struct {
PageInfo
Provider string `json:"provider"`
Name string `json:"name"`
}
type AgentAccountInfo struct {
ID uint `json:"id"`
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"`
}
type ProviderModelInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ProviderInfo struct {
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
Models []ProviderModelInfo `json:"models"`
}
+16
View File
@@ -0,0 +1,16 @@
package model
type Agent struct {
BaseModel
Name string `json:"name" gorm:"not null;unique"`
Provider string `json:"provider"`
Model string `json:"model"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
AccountID uint `json:"accountId"`
ConfigPath string `json:"configPath"`
}
+15
View File
@@ -0,0 +1,15 @@
package model
type AgentAccount struct {
BaseModel
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
}
func (AgentAccount) TableName() string {
return "agent_provider_accounts"
}
+75
View File
@@ -0,0 +1,75 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
)
type AgentRepo struct{}
type IAgentRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.Agent, error)
GetFirst(opts ...DBOption) (*model.Agent, error)
Create(agent *model.Agent) error
Save(agent *model.Agent) error
DeleteByID(id uint) error
DeleteByAppInstallID(appInstallID uint) error
DeleteByAppInstallIDWithCtx(ctx context.Context, appInstallID uint) error
List(opts ...DBOption) ([]model.Agent, error)
}
func NewIAgentRepo() IAgentRepo {
return &AgentRepo{}
}
func (a AgentRepo) Page(page, size int, opts ...DBOption) (int64, []model.Agent, error) {
var agents []model.Agent
db := getDb(opts...).Model(&model.Agent{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&agents).Error
return count, agents, err
}
func (a AgentRepo) GetFirst(opts ...DBOption) (*model.Agent, error) {
var agent model.Agent
if err := getDb(opts...).First(&agent).Error; err != nil {
return nil, err
}
return &agent, nil
}
func (a AgentRepo) Create(agent *model.Agent) error {
return getDb().Create(agent).Error
}
func (a AgentRepo) Save(agent *model.Agent) error {
return getDb().Save(agent).Error
}
func (a AgentRepo) DeleteByID(id uint) error {
return getDb().Delete(&model.Agent{}, id).Error
}
func (a AgentRepo) DeleteByAppInstallID(appInstallID uint) error {
if appInstallID == 0 {
return nil
}
return getDb().Where("app_install_id = ?", appInstallID).Delete(&model.Agent{}).Error
}
func (a AgentRepo) DeleteByAppInstallIDWithCtx(ctx context.Context, appInstallID uint) error {
if appInstallID == 0 {
return nil
}
return getTx(ctx).Where("app_install_id = ?", appInstallID).Delete(&model.Agent{}).Error
}
func (a AgentRepo) List(opts ...DBOption) ([]model.Agent, error) {
var agents []model.Agent
if err := getDb(opts...).Find(&agents).Error; err != nil {
return nil, err
}
return agents, nil
}
+55
View File
@@ -0,0 +1,55 @@
package repo
import "github.com/1Panel-dev/1Panel/agent/app/model"
type AgentAccountRepo struct{}
type IAgentAccountRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.AgentAccount, error)
GetFirst(opts ...DBOption) (*model.AgentAccount, error)
Create(account *model.AgentAccount) error
Save(account *model.AgentAccount) error
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
}
func NewIAgentAccountRepo() IAgentAccountRepo {
return &AgentAccountRepo{}
}
func (a AgentAccountRepo) Page(page, size int, opts ...DBOption) (int64, []model.AgentAccount, error) {
var accounts []model.AgentAccount
db := getDb(opts...).Model(&model.AgentAccount{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&accounts).Error
return count, accounts, err
}
func (a AgentAccountRepo) GetFirst(opts ...DBOption) (*model.AgentAccount, error) {
var account model.AgentAccount
if err := getDb(opts...).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
func (a AgentAccountRepo) Create(account *model.AgentAccount) error {
return getDb().Create(account).Error
}
func (a AgentAccountRepo) Save(account *model.AgentAccount) error {
return getDb().Save(account).Error
}
func (a AgentAccountRepo) DeleteByID(id uint) error {
return getDb().Delete(&model.AgentAccount{}, id).Error
}
func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
var accounts []model.AgentAccount
if err := getDb(opts...).Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}
+27
View File
@@ -72,6 +72,33 @@ func WithByDetailName(detailName string) DBOption {
}
}
func WithByProvider(provider string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(provider) == 0 {
return g
}
return g.Where("provider = ?", provider)
}
}
func WithByModel(model string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(model) == 0 {
return g
}
return g.Where("model = ?", model)
}
}
func WithByAccountID(accountID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if accountID == 0 {
return g
}
return g.Where("account_id = ?", accountID)
}
}
func WithByType(tp string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("`type` = ?", tp)
+820
View File
@@ -0,0 +1,820 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"path"
"strconv"
"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/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"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/files"
)
type AgentService struct{}
type IAgentService interface {
Create(req dto.AgentCreateReq) (*dto.AgentItem, error)
Page(req dto.SearchWithPage) (int64, []dto.AgentItem, error)
Delete(req dto.AgentDeleteReq) error
GetProviders() ([]dto.ProviderInfo, error)
CreateAccount(req dto.AgentAccountCreateReq) error
UpdateAccount(req dto.AgentAccountUpdateReq) error
PageAccounts(req dto.AgentAccountSearch) (int64, []dto.AgentAccountInfo, error)
VerifyAccount(req dto.AgentAccountVerifyReq) error
DeleteAccount(req dto.AgentAccountDeleteReq) error
}
func NewIAgentService() IAgentService {
return &AgentService{}
}
func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
provider := strings.ToLower(strings.TrimSpace(req.Provider))
if !isSupportedAgentProvider(provider) {
return nil, buserr.WithDetail("ErrInvalidParams", "provider", nil)
}
if req.AccountID == 0 {
return nil, buserr.WithDetail("ErrInvalidParams", "accountId", nil)
}
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.AccountID))
if err != nil {
return nil, err
}
if !account.Verified {
return nil, buserr.WithDetail("ErrInvalidParams", "account", nil)
}
if account.Provider != "" && provider != "" && account.Provider != provider {
return nil, buserr.WithDetail("ErrInvalidParams", "provider", nil)
}
provider = strings.ToLower(strings.TrimSpace(account.Provider))
baseURL := strings.TrimSpace(account.BaseURL)
if baseURL == "" {
if defaultURL, ok := providerDefaultBaseURL(provider); ok {
baseURL = defaultURL
}
}
if provider == "ollama" && baseURL == "" {
return nil, buserr.WithDetail("ErrInvalidParams", "baseURL", nil)
}
if provider != "ollama" && strings.TrimSpace(account.APIKey) == "" {
return nil, buserr.WithDetail("ErrInvalidParams", "apiKey", nil)
}
if err := checkPortExist(req.WebUIPort); err != nil {
return nil, err
}
if err := checkPortExist(req.BridgePort); err != nil {
return nil, err
}
if exist, _ := agentRepo.GetFirst(repo.WithByLowerName(req.Name)); exist != nil && exist.ID > 0 {
return nil, buserr.New("ErrNameIsExist")
}
if installs, _ := appInstallRepo.ListBy(context.Background(), repo.WithByLowerName(req.Name)); len(installs) > 0 {
return nil, buserr.New("ErrNameIsExist")
}
app, err := appRepo.GetFirst(appRepo.WithKey(constant.AppOpenclaw))
if err != nil || app.ID == 0 {
return nil, buserr.New("ErrRecordNotFound")
}
detail, err := appDetailRepo.GetFirst(appDetailRepo.WithAppId(app.ID), appDetailRepo.WithVersion(req.AppVersion))
if err != nil || detail.ID == 0 {
return nil, buserr.New("ErrRecordNotFound")
}
token := strings.TrimSpace(req.Token)
if token == "" {
token = randomToken()
}
params := map[string]interface{}{
"PROVIDER": provider,
"MODEL": req.Model,
"BASE_URL": baseURL,
"API_KEY": account.APIKey,
"OPENCLAW_GATEWAY_TOKEN": token,
"PANEL_APP_PORT_HTTP": req.WebUIPort,
"PANEL_APP_PORT_BRIDGE": req.BridgePort,
constant.CPUS: "0",
constant.MemoryLimit: "0",
constant.HostIP: "",
}
if req.EditCompose && strings.TrimSpace(req.DockerCompose) == "" {
return nil, buserr.WithDetail("ErrInvalidParams", "dockerCompose", nil)
}
installReq := request.AppInstallCreate{
AppDetailId: detail.ID,
Name: req.Name,
Params: params,
TaskID: req.TaskID,
AppContainerConfig: request.AppContainerConfig{
Advanced: req.Advanced,
ContainerName: req.ContainerName,
AllowPort: req.AllowPort,
SpecifyIP: req.SpecifyIP,
RestartPolicy: req.RestartPolicy,
CpuQuota: req.CpuQuota,
MemoryLimit: req.MemoryLimit,
MemoryUnit: req.MemoryUnit,
PullImage: req.PullImage,
EditCompose: req.EditCompose,
DockerCompose: req.DockerCompose,
},
}
appInstall, err := NewIAppService().Install(installReq, false)
if err != nil {
return nil, err
}
configPath := path.Join(appInstall.GetPath(), "data", "conf", "openclaw.json")
agent := &model.Agent{
Name: req.Name,
Provider: provider,
Model: req.Model,
BaseURL: baseURL,
APIKey: account.APIKey,
Token: token,
Status: appInstall.Status,
Message: appInstall.Message,
AppInstallID: appInstall.ID,
AccountID: account.ID,
ConfigPath: configPath,
}
if err := agentRepo.Create(agent); err != nil {
return nil, err
}
go a.writeConfigWithRetry(appInstall, provider, req.Model, baseURL, req.APIKey, token, agent.ID)
item := buildAgentItem(agent, appInstall, nil)
return &item, nil
}
func (a AgentService) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, error) {
var opts []repo.DBOption
if strings.TrimSpace(req.Info) != "" {
opts = append(opts, repo.WithByLikeName(req.Info))
}
count, list, err := agentRepo.Page(req.Page, req.PageSize, opts...)
if err != nil {
return 0, nil, err
}
items := make([]dto.AgentItem, 0, len(list))
for _, item := range list {
appInstall, _ := appInstallRepo.GetFirst(repo.WithByID(item.AppInstallID))
envMap := readInstallEnv(appInstall.Env)
items = append(items, buildAgentItem(&item, &appInstall, envMap))
}
return count, items, nil
}
func (a AgentService) Delete(req dto.AgentDeleteReq) error {
if req.ID == 0 {
return buserr.WithDetail("ErrInvalidParams", "id", nil)
}
agent, err := agentRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
if agent.AppInstallID == 0 {
return agentRepo.DeleteByID(agent.ID)
}
operate := request.AppInstalledOperate{
InstallId: agent.AppInstallID,
Operate: constant.Delete,
TaskID: req.TaskID,
ForceDelete: req.ForceDelete,
}
if err := NewIAppInstalledService().Operate(operate); err != nil {
return err
}
go a.waitAndDeleteAgent(agent.ID, agent.AppInstallID)
return nil
}
func (a AgentService) GetProviders() ([]dto.ProviderInfo, error) {
definitions := providerDefinitions()
providers := make([]dto.ProviderInfo, 0, len(definitions))
for key, def := range definitions {
providers = append(providers, dto.ProviderInfo{
Provider: key,
BaseURL: def.BaseURL,
Models: def.Models,
})
}
return providers, nil
}
func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
provider := strings.ToLower(strings.TrimSpace(req.Provider))
if !isSupportedAgentProvider(provider) {
return buserr.WithDetail("ErrInvalidParams", "provider", nil)
}
apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" {
return buserr.WithDetail("ErrInvalidParams", "apiKey", nil)
}
baseURL := strings.TrimSpace(req.BaseURL)
if provider != "ollama" {
if defaultURL, ok := providerDefaultBaseURL(provider); ok {
baseURL = defaultURL
}
}
if provider == "ollama" && baseURL == "" {
return buserr.WithDetail("ErrInvalidParams", "baseURL", nil)
}
if exist, _ := agentAccountRepo.GetFirst(repo.WithByProvider(provider), repo.WithByName(req.Name)); exist != nil && exist.ID > 0 {
return buserr.New("ErrRecordExist")
}
if err := a.VerifyAccount(dto.AgentAccountVerifyReq{Provider: provider, BaseURL: baseURL, APIKey: apiKey}); err != nil {
return err
}
account := &model.AgentAccount{
Provider: provider,
Name: req.Name,
APIKey: apiKey,
BaseURL: baseURL,
Verified: true,
Remark: req.Remark,
}
return agentAccountRepo.Create(account)
}
func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
provider := strings.ToLower(strings.TrimSpace(account.Provider))
baseURL := strings.TrimSpace(req.BaseURL)
if provider != "ollama" {
if defaultURL, ok := providerDefaultBaseURL(provider); ok {
baseURL = defaultURL
}
}
if provider == "ollama" && baseURL == "" {
return buserr.WithDetail("ErrInvalidParams", "baseURL", nil)
}
if err := a.VerifyAccount(dto.AgentAccountVerifyReq{Provider: provider, BaseURL: baseURL, APIKey: req.APIKey}); err != nil {
return err
}
account.Name = req.Name
account.APIKey = req.APIKey
account.BaseURL = baseURL
account.Remark = req.Remark
account.Verified = true
if err := agentAccountRepo.Save(account); err != nil {
return err
}
if req.SyncAgents {
if err := a.syncAgentsByAccount(account); err != nil {
return err
}
}
return nil
}
func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.AgentAccountInfo, error) {
var opts []repo.DBOption
if strings.TrimSpace(req.Provider) != "" {
opts = append(opts, repo.WithByProvider(req.Provider))
}
if strings.TrimSpace(req.Name) != "" {
opts = append(opts, repo.WithByLikeName(req.Name))
}
count, list, err := agentAccountRepo.Page(req.Page, req.PageSize, opts...)
if err != nil {
return 0, nil, err
}
items := make([]dto.AgentAccountInfo, 0, len(list))
for _, item := range list {
items = append(items, dto.AgentAccountInfo{
ID: item.ID,
Provider: item.Provider,
Name: item.Name,
APIKey: item.APIKey,
BaseURL: item.BaseURL,
Verified: item.Verified,
Remark: item.Remark,
CreatedAt: item.CreatedAt,
})
}
return count, items, nil
}
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
provider := strings.ToLower(strings.TrimSpace(req.Provider))
if !isSupportedAgentProvider(provider) {
return buserr.WithDetail("ErrInvalidParams", "provider", nil)
}
apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" {
return buserr.WithDetail("ErrInvalidParams", "apiKey", nil)
}
baseURL := strings.TrimSpace(req.BaseURL)
if baseURL == "" {
if defaultURL, ok := providerDefaultBaseURL(provider); ok {
baseURL = defaultURL
}
}
if provider == "ollama" && baseURL == "" {
return buserr.WithDetail("ErrInvalidParams", "baseURL", nil)
}
if provider == "ollama" {
return nil
}
return verifyProvider(provider, baseURL, apiKey)
}
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
if req.ID == 0 {
return buserr.WithDetail("ErrInvalidParams", "id", nil)
}
if exists, _ := agentRepo.GetFirst(repo.WithByAccountID(req.ID)); exists != nil && exists.ID > 0 {
return buserr.New("ErrRecordExist")
}
return agentAccountRepo.DeleteByID(req.ID)
}
func (a AgentService) syncAgentsByAccount(account *model.AgentAccount) error {
agents, err := agentRepo.List(repo.WithByAccountID(account.ID))
if err != nil {
return err
}
baseURL := account.BaseURL
if account.Provider != "ollama" {
if defaultURL, ok := providerDefaultBaseURL(account.Provider); ok {
baseURL = defaultURL
}
}
for _, agent := range agents {
confDir := ""
if agent.ConfigPath != "" {
confDir = path.Dir(agent.ConfigPath)
} else if agent.AppInstallID > 0 {
install, err := appInstallRepo.GetFirst(repo.WithByID(agent.AppInstallID))
if err == nil {
confDir = path.Join(install.GetPath(), "data", "conf")
}
}
if confDir == "" {
continue
}
if err := writeOpenclawConfig(confDir, account.Provider, agent.Model, baseURL, account.APIKey, agent.Token); err != nil {
return err
}
agent.BaseURL = baseURL
agent.APIKey = account.APIKey
agent.Provider = account.Provider
_ = agentRepo.Save(&agent)
}
return nil
}
func verifyProvider(provider, baseURL, apiKey string) error {
client := &http.Client{Timeout: 10 * time.Second}
reqURL, headers := buildVerifyRequest(provider, baseURL, apiKey)
request, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return err
}
for key, value := range headers {
request.Header.Set(key, value)
}
resp, err := client.Do(request)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return buserr.WithDetail("ErrInvalidParams", fmt.Sprintf("verify failed: %d", resp.StatusCode), nil)
}
return nil
}
func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map[string]interface{}) dto.AgentItem {
item := dto.AgentItem{
ID: agent.ID,
Name: agent.Name,
Provider: agent.Provider,
Model: agent.Model,
BaseURL: agent.BaseURL,
APIKey: maskKey(agent.APIKey),
Token: agent.Token,
Status: agent.Status,
Message: agent.Message,
AppInstallID: agent.AppInstallID,
ConfigPath: agent.ConfigPath,
CreatedAt: agent.CreatedAt,
}
if appInstall != nil && appInstall.ID > 0 {
item.Container = appInstall.ContainerName
item.AppVersion = appInstall.Version
item.WebUIPort = appInstall.HttpPort
item.Path = appInstall.GetPath()
item.Status = appInstall.Status
item.Message = appInstall.Message
if envMap != nil {
if bridge, ok := envMap["PANEL_APP_PORT_BRIDGE"]; ok {
item.BridgePort = toInt(bridge)
}
}
}
return item
}
func (a AgentService) waitAndDeleteAgent(agentID uint, appInstallID uint) {
if appInstallID == 0 {
_ = agentRepo.DeleteByID(agentID)
return
}
for i := 0; i < 180; i++ {
_, err := appInstallRepo.GetFirst(repo.WithByID(appInstallID))
if err != nil {
_ = agentRepo.DeleteByID(agentID)
return
}
time.Sleep(2 * time.Second)
}
}
func (a AgentService) writeConfigWithRetry(appInstall *model.AppInstall, provider, modelName, baseURL, apiKey, token string, agentID uint) {
if appInstall == nil {
return
}
fileOp := files.NewFileOp()
composePath := appInstall.GetComposePath()
for i := 0; i < 60; i++ {
if fileOp.Stat(composePath) {
break
}
time.Sleep(time.Second)
}
confDir := path.Join(appInstall.GetPath(), "data", "conf")
if err := writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token); err != nil {
global.LOG.Errorf("write openclaw config failed: %v", err)
agent, errGet := agentRepo.GetFirst(repo.WithByID(agentID))
if errGet == nil && agent != nil {
agent.Message = err.Error()
agent.Status = constant.StatusError
_ = agentRepo.Save(agent)
}
return
}
dataDir := path.Join(appInstall.GetPath(), "data")
for i := 0; i < 60; i++ {
if fileOp.Stat(dataDir) {
if err := fileOp.ChownR(dataDir, "1000", "1000", true); err != nil {
global.LOG.Errorf("chown data dir failed: %v", err)
agent, errGet := agentRepo.GetFirst(repo.WithByID(agentID))
if errGet == nil && agent != nil {
agent.Message = err.Error()
agent.Status = constant.StatusError
_ = agentRepo.Save(agent)
}
}
break
}
time.Sleep(time.Second)
}
}
type openclawConfig struct {
Gateway gatewayConfig `json:"gateway"`
Agents agentsConfig `json:"agents"`
Models *modelsConfig `json:"models,omitempty"`
}
type gatewayConfig struct {
Mode string `json:"mode"`
Bind string `json:"bind"`
Port int `json:"port"`
Auth gatewayAuth `json:"auth"`
ControlUi gatewayControlUi `json:"controlUi"`
}
type gatewayControlUi struct {
AllowInsecureAuth bool `json:"allowInsecureAuth"`
}
type gatewayAuth struct {
Mode string `json:"mode"`
Token string `json:"token"`
}
type agentsConfig struct {
Defaults agentDefaults `json:"defaults"`
}
type agentDefaults struct {
Model modelRef `json:"model"`
}
type modelRef struct {
Primary string `json:"primary"`
}
type modelsConfig struct {
Mode string `json:"mode,omitempty"`
Providers map[string]modelProvider `json:"providers,omitempty"`
}
type modelProvider struct {
ApiKey string `json:"apiKey,omitempty"`
BaseUrl string `json:"baseUrl,omitempty"`
Api string `json:"api,omitempty"`
Models []modelEntry `json:"models,omitempty"`
}
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Reasoning bool `json:"reasoning"`
Input []string `json:"input"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
}
func writeOpenclawConfig(confDir, provider, modelName, baseURL, apiKey, token string) error {
if strings.TrimSpace(confDir) == "" {
return fmt.Errorf("config dir is required")
}
if strings.TrimSpace(modelName) == "" {
return fmt.Errorf("model is required")
}
if strings.TrimSpace(token) == "" {
return fmt.Errorf("gateway token is required")
}
fileOp := files.NewFileOp()
if !fileOp.Stat(confDir) {
if err := fileOp.CreateDir(confDir, constant.DirPerm); err != nil {
return err
}
}
cfg := openclawConfig{
Gateway: gatewayConfig{
Mode: "local",
Bind: "lan",
Port: 18789,
Auth: gatewayAuth{
Mode: "token",
Token: token,
},
ControlUi: gatewayControlUi{
AllowInsecureAuth: true,
},
},
Agents: agentsConfig{
Defaults: agentDefaults{
Model: modelRef{Primary: modelName},
},
},
}
provider = strings.ToLower(strings.TrimSpace(provider))
if provider == "deepseek" {
base := baseURL
if base == "" {
base = "https://api.deepseek.com/v1"
}
cfg.Models = &modelsConfig{
Mode: "merge",
Providers: map[string]modelProvider{
"deepseek": {
ApiKey: "${DEEPSEEK_API_KEY}",
BaseUrl: base,
Api: "openai-completions",
Models: []modelEntry{
{
ID: "deepseek-chat",
Name: "DeepSeek Chat",
Reasoning: false,
Input: []string{"text"},
ContextWindow: 128000,
MaxTokens: 8192,
},
},
},
},
}
} else if provider == "ollama" {
modelID := modelName
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
modelID = parts[1]
}
cfg.Models = &modelsConfig{
Mode: "merge",
Providers: map[string]modelProvider{
"ollama": {
ApiKey: "ollama",
BaseUrl: baseURL,
Api: "openai-responses",
Models: []modelEntry{
{
ID: modelID,
Name: modelID,
Reasoning: true,
Input: []string{"text"},
ContextWindow: 160000,
MaxTokens: 8192,
},
},
},
},
}
}
payload, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
configPath := path.Join(confDir, "openclaw.json")
if err := fileOp.SaveFile(configPath, string(payload), 0600); err != nil {
return err
}
envPath := path.Join(confDir, ".env")
lines := []string{fmt.Sprintf("OPENCLAW_GATEWAY_TOKEN=%s", token)}
if envKey := providerEnvKey(provider); envKey != "" && strings.TrimSpace(apiKey) != "" {
lines = append(lines, fmt.Sprintf("%s=%s", envKey, apiKey))
}
content := strings.Join(lines, "\n") + "\n"
return fileOp.SaveFile(envPath, content, 0600)
}
func providerEnvKey(provider string) string {
switch provider {
case "openai":
return "OPENAI_API_KEY"
case "anthropic":
return "ANTHROPIC_API_KEY"
case "gemini":
return "GEMINI_API_KEY"
case "minimax":
return "MINIMAX_API_KEY"
case "deepseek":
return "DEEPSEEK_API_KEY"
case "qwen":
return "QWEN_API_KEY"
case "ollama":
return ""
default:
return ""
}
}
type providerDefinition struct {
BaseURL string
Models []dto.ProviderModelInfo
}
func providerDefinitions() map[string]providerDefinition {
return map[string]providerDefinition{
"openai": {
BaseURL: "https://api.openai.com/v1",
Models: []dto.ProviderModelInfo{
{ID: "openai/codex-mini-latest", Name: "Codex Mini"},
{ID: "openai/gpt-4.1", Name: "GPT-4.1"},
{ID: "openai/gpt-4o", Name: "GPT-4o"},
{ID: "openai/gpt-4o-mini", Name: "GPT-4o Mini"},
{ID: "openai/gpt-5", Name: "GPT-5"},
{ID: "openai/gpt-5-mini", Name: "GPT-5 Mini"},
},
},
"anthropic": {
BaseURL: "https://api.anthropic.com",
Models: []dto.ProviderModelInfo{
{ID: "anthropic/claude-3-haiku-20240307", Name: "Claude 3 Haiku"},
{ID: "anthropic/claude-3-5-haiku-latest", Name: "Claude 3.5 Haiku"},
{ID: "anthropic/claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"},
{ID: "anthropic/claude-3-7-sonnet-20250219", Name: "Claude 3.7 Sonnet"},
{ID: "anthropic/claude-opus-4-1", Name: "Claude Opus 4.1"},
},
},
"gemini": {
BaseURL: "https://generativelanguage.googleapis.com",
Models: []dto.ProviderModelInfo{
{ID: "google/gemini-1.5-flash", Name: "Gemini 1.5 Flash"},
{ID: "google/gemini-1.5-pro", Name: "Gemini 1.5 Pro"},
{ID: "google/gemini-2.0-flash", Name: "Gemini 2.0 Flash"},
{ID: "google/gemini-2.5-flash", Name: "Gemini 2.5 Flash"},
{ID: "google/gemini-2.5-pro", Name: "Gemini 2.5 Pro"},
{ID: "google/gemini-3-flash-preview", Name: "Gemini 3 Flash Preview"},
},
},
"minimax": {
BaseURL: "https://api.minimax.chat/v1",
Models: []dto.ProviderModelInfo{
{ID: "minimax/Minimax-M2.1", Name: "Minimax M2.1"},
},
},
"deepseek": {
BaseURL: "https://api.deepseek.com/v1",
Models: []dto.ProviderModelInfo{
{ID: "deepseek/deepseek-chat", Name: "DeepSeek Chat"},
{ID: "deepseek/deepseek-reasoner", Name: "DeepSeek Reasoner"},
{ID: "deepseek/deepseek-r1:1.5b", Name: "DeepSeek R1 1.5B"},
},
},
"ollama": {
BaseURL: "",
Models: []dto.ProviderModelInfo{},
},
}
}
func providerDefaultBaseURL(provider string) (string, bool) {
defs := providerDefinitions()
if def, ok := defs[provider]; ok {
if def.BaseURL == "" {
return "", false
}
return def.BaseURL, true
}
return "", false
}
func isSupportedAgentProvider(provider string) bool {
_, ok := providerDefinitions()[provider]
return ok
}
func buildVerifyRequest(provider, baseURL, apiKey string) (string, map[string]string) {
headers := map[string]string{}
base := strings.TrimRight(baseURL, "/")
switch provider {
case "anthropic":
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
if strings.Contains(base, "/v1") {
return base + "/models", headers
}
return base + "/v1/models", headers
case "gemini":
if strings.Contains(base, "/v1beta") {
return fmt.Sprintf("%s/models?key=%s", base, apiKey), headers
}
return fmt.Sprintf("%s/v1beta/models?key=%s", base, apiKey), headers
default:
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
if strings.Contains(base, "/v1") {
return base + "/models", headers
}
return base + "/v1/models", headers
}
}
func readInstallEnv(envStr string) map[string]interface{} {
if strings.TrimSpace(envStr) == "" {
return nil
}
data := map[string]interface{}{}
if err := json.Unmarshal([]byte(envStr), &data); err != nil {
return nil
}
return data
}
func maskKey(value string) string {
trim := strings.TrimSpace(value)
if len(trim) <= 6 {
return trim
}
return fmt.Sprintf("%s****%s", trim[:3], trim[len(trim)-3:])
}
func toInt(value interface{}) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
if v == "" {
return 0
}
parsed, _ := strconv.Atoi(v)
return parsed
default:
return 0
}
}
func randomToken() string {
bytes := make([]byte, 24)
if _, err := rand.Read(bytes); err != nil {
return ""
}
return hex.EncodeToString(bytes)
}
+6 -4
View File
@@ -48,7 +48,7 @@ type IAppService interface {
GetAppTags(ctx *gin.Context) ([]response.TagDTO, error)
GetApp(ctx *gin.Context, key string) (*response.AppDTO, error)
GetAppDetail(appId uint, version, appType string) (response.AppDetailDTO, error)
Install(req request.AppInstallCreate) (*model.AppInstall, error)
Install(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error)
SyncAppListFromRemote(taskID string) error
GetAppUpdate() (*response.AppUpdateRes, error)
GetAppDetailByID(id uint) (*response.AppDetailDTO, error)
@@ -339,7 +339,7 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
return res, nil
}
func (a AppService) Install(req request.AppInstallCreate) (appInstall *model.AppInstall, err error) {
func (a AppService) Install(req request.AppInstallCreate, executeScript bool) (appInstall *model.AppInstall, err error) {
if err = docker.CreateDefaultDockerNetwork(); err != nil {
err = buserr.WithDetail("Err1PanelNetworkFailed", err.Error(), nil)
return
@@ -533,8 +533,10 @@ func (a AppService) Install(req request.AppInstallCreate) (appInstall *model.App
if err = copyData(t, app, appDetail, appInstall, req); err != nil {
return err
}
if err = runScript(t, appInstall, "init"); err != nil {
return err
if executeScript {
if err = runScript(t, appInstall, "init"); err != nil {
return err
}
}
if app.Key == "openresty" {
if err = handleSiteDir(app, appDetail, req, t); err != nil {
+4
View File
@@ -392,6 +392,10 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err = appInstallRepo.Delete(ctx, install); err != nil {
return err
}
appKey := install.App.Key
if appKey == constant.AppOpenclaw {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
}
resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID))
if len(resources) > 0 {
+5 -3
View File
@@ -12,9 +12,11 @@ var (
appInstallResourceRepo = repo.NewIAppInstallResourceRpo()
appIgnoreUpgradeRepo = repo.NewIAppIgnoreUpgradeRepo()
aiRepo = repo.NewIAiRepo()
mcpServerRepo = repo.NewIMcpServerRepo()
tensorrtLLMRepo = repo.NewITensorRTLLMRepo()
aiRepo = repo.NewIAiRepo()
mcpServerRepo = repo.NewIMcpServerRepo()
tensorrtLLMRepo = repo.NewITensorRTLLMRepo()
agentRepo = repo.NewIAgentRepo()
agentAccountRepo = repo.NewIAgentAccountRepo()
mysqlRepo = repo.NewIMysqlRepo()
postgresqlRepo = repo.NewIPostgresqlRepo()
+1 -1
View File
@@ -383,7 +383,7 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
req.AppDetailId = create.AppInstall.AppDetailId
req.Params = create.AppInstall.Params
req.AppContainerConfig = create.AppInstall.AppContainerConfig
install, err = NewIAppService().Install(req)
install, err = NewIAppService().Install(req, true)
if err != nil {
return err
}
+1
View File
@@ -7,6 +7,7 @@ const (
AppTakeDown = "TakeDown"
AppOpenresty = "openresty"
AppOpenclaw = "openclaw"
AppMysql = "mysql"
AppMariaDB = "mariadb"
AppPostgresql = "postgresql"
+2
View File
@@ -63,6 +63,8 @@ func InitAgentDB() {
migrations.UpdateApp,
migrations.AddCronjobArgs,
migrations.AddWebsiteAcmeAccountColumn,
migrations.AddAgentTables,
migrations.MigrateOpenclawAgents,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)
+133
View File
@@ -2,6 +2,7 @@ package migrations
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/user"
@@ -821,3 +822,135 @@ var AddWebsiteAcmeAccountColumn = &gormigrate.Migration{
return tx.AutoMigrate(&model.WebsiteAcmeAccount{})
},
}
var AddAgentTables = &gormigrate.Migration{
ID: "20260205-add-agent-tables",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&model.Agent{},
&model.AgentAccount{},
)
},
}
var MigrateOpenclawAgents = &gormigrate.Migration{
ID: "20260207-migrate-openclaw-agents",
Migrate: func(tx *gorm.DB) error {
var installs []model.AppInstall
if err := tx.Preload("App").Find(&installs).Error; err != nil {
return err
}
for _, install := range installs {
appKey := install.App.Key
if appKey == "" || install.App.Resource == "" {
var app model.App
if err := tx.First(&app, install.AppId).Error; err == nil {
install.App = app
appKey = app.Key
}
}
if appKey != constant.AppOpenclaw {
continue
}
var count int64
if err := tx.Model(&model.Agent{}).Where("app_install_id = ?", install.ID).Count(&count).Error; err != nil {
return err
}
if count > 0 {
continue
}
envMap := map[string]interface{}{}
if strings.TrimSpace(install.Env) != "" {
_ = json.Unmarshal([]byte(install.Env), &envMap)
}
provider := strings.ToLower(getEnvStr(envMap, "PROVIDER"))
if provider == "" {
continue
}
modelName := getEnvStr(envMap, "MODEL")
baseURL := getEnvStr(envMap, "BASE_URL")
apiKey := getEnvStr(envMap, "API_KEY")
token := getEnvStr(envMap, "OPENCLAW_GATEWAY_TOKEN")
if provider != "ollama" {
if baseURL == "" {
if defaultURL, ok := defaultBaseURL(provider); ok {
baseURL = defaultURL
}
}
}
if provider == "ollama" && baseURL == "" {
continue
}
var account model.AgentAccount
err := tx.Where("provider = ? AND api_key = ? AND base_url = ?", provider, apiKey, baseURL).First(&account).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
account = model.AgentAccount{
Provider: provider,
Name: install.Name,
APIKey: apiKey,
BaseURL: baseURL,
Verified: apiKey != "" || provider == "ollama",
}
if err := tx.Create(&account).Error; err != nil {
return err
}
} else {
return err
}
}
configPath := path.Join(install.GetPath(), "data", "conf", "openclaw.json")
agent := model.Agent{
Name: install.Name,
Provider: provider,
Model: modelName,
BaseURL: baseURL,
APIKey: apiKey,
Token: token,
Status: install.Status,
Message: install.Message,
AppInstallID: install.ID,
AccountID: account.ID,
ConfigPath: configPath,
}
if err := tx.Create(&agent).Error; err != nil {
return err
}
}
return nil
},
}
func getEnvStr(envMap map[string]interface{}, key string) string {
if envMap == nil {
return ""
}
if value, ok := envMap[key]; ok {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
}
return ""
}
func defaultBaseURL(provider string) (string, bool) {
switch provider {
case "openai":
return "https://api.openai.com/v1", true
case "anthropic":
return "https://api.anthropic.com", true
case "gemini":
return "https://generativelanguage.googleapis.com", true
case "minimax":
return "https://api.minimax.chat/v1", true
case "deepseek":
return "https://api.deepseek.com/v1", true
case "qwen":
return "https://dashscope.aliyuncs.com/compatible-mode/v1", true
default:
return "", false
}
}
+10
View File
@@ -39,5 +39,15 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/tensorrt/update", baseApi.UpdateTensorRTLLM)
aiToolsRouter.POST("/tensorrt/delete", baseApi.DeleteTensorRTLLM)
aiToolsRouter.POST("/tensorrt/operate", baseApi.OperateTensorRTLLM)
aiToolsRouter.POST("/agents", baseApi.CreateAgent)
aiToolsRouter.POST("/agents/search", baseApi.PageAgents)
aiToolsRouter.POST("/agents/delete", baseApi.DeleteAgent)
aiToolsRouter.GET("/agents/providers", baseApi.GetAgentProviders)
aiToolsRouter.POST("/agents/accounts", baseApi.CreateAgentAccount)
aiToolsRouter.POST("/agents/accounts/update", baseApi.UpdateAgentAccount)
aiToolsRouter.POST("/agents/accounts/search", baseApi.PageAgentAccounts)
aiToolsRouter.POST("/agents/accounts/verify", baseApi.VerifyAgentAccount)
aiToolsRouter.POST("/agents/accounts/delete", baseApi.DeleteAgentAccount)
}
}
+2
View File
@@ -22,6 +22,7 @@ func LoadMenus() string {
}},
{ID: "4", Disabled: false, Title: "menu.aiTools", IsShow: true, Label: "AI-Menu", Path: "/ai/model", Sort: 400,
Children: []dto.ShowMenu{
{ID: "44", Disabled: false, Title: "aiTools.agents.agents", IsShow: true, Label: "Agents", Path: "/ai/agents/agent", Sort: 50},
{ID: "41", Disabled: false, Title: "aiTools.model.model", IsShow: true, Label: "OllamaModel", Path: "/ai/model", Sort: 100},
{ID: "42", Disabled: false, Title: "menu.mcp", IsShow: true, Label: "MCPServer", Path: "/ai/mcp", Sort: 200},
{ID: "43", Disabled: false, Title: "aiTools.gpu.gpu", IsShow: true, Label: "GPU", Path: "/ai/gpu", Sort: 300},
@@ -68,6 +69,7 @@ func MenuSort() []dto.MenuLabelSort {
{Label: "SSL", Sort: 200},
{Label: "PHP", Sort: 300},
{Label: "AI-Menu", Sort: 400},
{Label: "Agents", Sort: 50},
{Label: "OllamaModel", Sort: 100},
{Label: "MCPServer", Sort: 200},
{Label: "GPU", Sort: 300},
+1
View File
@@ -23,6 +23,7 @@ func Init() {
migrations.DeleteXpackHideMenu,
migrations.AddCronjobGroup,
migrations.AddDiskMenu,
migrations.AddAgentsMenu,
migrations.AddSimpleNodeGroup,
migrations.AddUpgradeBackupCopies,
migrations.AddScriptSync,
+14
View File
@@ -556,6 +556,20 @@ var AddDiskMenu = &gormigrate.Migration{
},
}
var AddAgentsMenu = &gormigrate.Migration{
ID: "20260204-add-agents-menu",
Migrate: func(tx *gorm.DB) error {
return helper.AddMenu(dto.ShowMenu{
ID: "44",
Disabled: false,
Title: "aiTools.agents.agents",
IsShow: true,
Label: "Agents",
Path: "/ai/agents/agent",
}, "4", tx)
},
}
var AddSimpleNodeGroup = &gormigrate.Migration{
ID: "20250916-add-simple-node-group",
Migrate: func(tx *gorm.DB) error {
+108
View File
@@ -235,4 +235,112 @@ export namespace AI {
id: number;
operate: string;
}
export interface AgentCreateReq {
name: string;
appVersion: string;
webUIPort: number;
bridgePort: number;
provider: string;
model: string;
accountId: number;
apiKey: string;
baseURL: string;
token: string;
taskID: string;
advanced: boolean;
containerName: string;
allowPort: boolean;
specifyIP: string;
restartPolicy: string;
cpuQuota: number;
memoryLimit: number;
memoryUnit: string;
pullImage: boolean;
editCompose: boolean;
dockerCompose: string;
}
export interface AgentItem {
id: number;
name: string;
provider: string;
model: string;
baseUrl: string;
apiKey: string;
token: string;
status: string;
message: string;
appInstallId: number;
appVersion: string;
containerName: string;
webUIPort: number;
bridgePort: number;
path: string;
configPath: string;
createdAt: string;
}
export interface AgentDeleteReq {
id: number;
taskID: string;
forceDelete: boolean;
}
export interface ProviderModelInfo {
id: string;
name: string;
}
export interface ProviderInfo {
provider: string;
baseUrl: string;
models: ProviderModelInfo[];
}
export interface AgentAccountCreateReq {
provider: string;
name: string;
apiKey: string;
baseURL: string;
remark: string;
}
export interface AgentAccountUpdateReq {
id: number;
name: string;
apiKey: string;
baseURL: string;
remark: string;
syncAgents: boolean;
}
export interface AgentAccountSearch {
page: number;
pageSize: number;
provider: string;
name: string;
}
export interface AgentAccountItem {
id: number;
provider: string;
name: string;
apiKey: string;
baseUrl: string;
verified: boolean;
remark: string;
createdAt: string;
}
export interface AgentAccountVerifyReq {
provider: string;
apiKey: string;
baseURL: string;
}
export interface AgentAccountDeleteReq {
id: number;
}
}
+37 -1
View File
@@ -1,6 +1,6 @@
import { AI } from '@/api/interface/ai';
import http from '@/api';
import { ResPage } from '../interface';
import { ResPage, SearchWithPage } from '../interface';
export const createOllamaModel = (name: string, taskID: string) => {
return http.post(`/ai/ollama/model`, { name: name, taskID: taskID });
@@ -91,3 +91,39 @@ export const deleteTensorRTLLM = (req: AI.TensorRTLLMDelete) => {
export const operateTensorRTLLM = (req: AI.TensorRTLLMOperate) => {
return http.post(`/ai/tensorrt/operate`, req);
};
export const createAgent = (req: AI.AgentCreateReq) => {
return http.post<AI.AgentItem>(`/ai/agents`, req);
};
export const pageAgents = (req: SearchWithPage) => {
return http.post<ResPage<AI.AgentItem>>(`/ai/agents/search`, req);
};
export const deleteAgent = (req: AI.AgentDeleteReq) => {
return http.post(`/ai/agents/delete`, req);
};
export const getAgentProviders = () => {
return http.get<AI.ProviderInfo[]>(`/ai/agents/providers`);
};
export const createAgentAccount = (req: AI.AgentAccountCreateReq) => {
return http.post(`/ai/agents/accounts`, req);
};
export const updateAgentAccount = (req: AI.AgentAccountUpdateReq) => {
return http.post(`/ai/agents/accounts/update`, req);
};
export const pageAgentAccounts = (req: AI.AgentAccountSearch) => {
return http.post<ResPage<AI.AgentAccountItem>>(`/ai/agents/accounts/search`, req);
};
export const verifyAgentAccount = (req: AI.AgentAccountVerifyReq) => {
return http.post(`/ai/agents/accounts/verify`, req);
};
export const deleteAgentAccount = (req: AI.AgentAccountDeleteReq) => {
return http.post(`/ai/agents/accounts/delete`, req);
};
@@ -0,0 +1,128 @@
<template>
<el-form-item prop="advanced">
<el-checkbox v-model="form.advanced" :label="$t('app.advanced')" size="large" />
</el-form-item>
<div v-if="form.advanced">
<el-form-item :label="$t('app.containerName')" prop="containerName">
<el-input v-model.trim="form.containerName" :placeholder="$t('app.containerNameHelper')" />
</el-form-item>
<el-form-item v-if="showAllowPort" prop="allowPort">
<el-checkbox v-model="form.allowPort" :label="$t('app.allowPort')" size="large" />
<span class="input-help">{{ $t('app.allowPortHelper') }}</span>
</el-form-item>
<el-form-item v-if="showSpecifyIP && form.allowPort" :label="$t('app.specifyIP')" prop="specifyIP">
<el-input v-model="form.specifyIP" />
<span class="input-help">{{ $t('app.specifyIPHelper') }}</span>
</el-form-item>
<el-form-item v-if="showRestartPolicy" :label="$t('container.restartPolicy')" prop="restartPolicy">
<el-select v-model="form.restartPolicy" class="p-w-300">
<el-option :label="$t('container.no')" value="no" />
<el-option :label="$t('container.always')" value="always" />
<el-option :label="$t('container.onFailure')" value="on-failure" />
<el-option :label="$t('container.unlessStopped')" value="unless-stopped" />
</el-select>
</el-form-item>
<el-form-item :label="$t('container.cpuQuota')" prop="cpuQuota" :rules="checkNumberRange(0, limits.cpu)">
<el-input type="number" class="!w-2/5" v-model.number="form.cpuQuota" maxlength="5">
<template #append>{{ $t('app.cpuCore') }}</template>
</el-input>
<span class="input-help">
{{ $t('container.limitHelper', [limits.cpu]) }}{{ $t('commons.units.core') }}
</span>
</el-form-item>
<el-form-item
:label="$t('container.memoryLimit')"
prop="memoryLimit"
:rules="checkNumberRange(0, limits.memory)"
>
<el-input class="!w-2/5" v-model.number="form.memoryLimit" maxlength="10">
<template #append>
<el-select v-model="form.memoryUnit" class="p-w-100" @change="changeUnit">
<el-option label="MB" value="M" />
<el-option label="GB" value="G" />
</el-select>
</template>
</el-input>
<span class="input-help">{{ $t('container.limitHelper', [limits.memory]) }}{{ form.memoryUnit }}B</span>
</el-form-item>
<el-form-item v-if="showPullImage" prop="pullImage">
<el-checkbox v-model="form.pullImage" :label="$t('app.pullImage')" />
<span class="input-help">{{ $t('app.pullImageHelper') }}</span>
</el-form-item>
<el-form-item v-if="showCompose" prop="editCompose">
<el-checkbox v-model="form.editCompose" :label="$t('app.editCompose')" />
<span class="input-help">{{ $t('app.editComposeHelper') }}</span>
</el-form-item>
<div v-if="showCompose && form.editCompose">
<CodemirrorPro v-model="form.dockerCompose" mode="yaml" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, toRef } from 'vue';
import CodemirrorPro from '@/components/codemirror-pro/index.vue';
import { checkNumberRange } from '@/global/form-rules';
import { loadResourceLimit } from '@/api/modules/container';
import { Container } from '@/api/interface/container';
const props = withDefaults(
defineProps<{
form: {
advanced: boolean;
containerName: string;
allowPort: boolean;
specifyIP: string;
restartPolicy: string;
cpuQuota: number;
memoryLimit: number;
memoryUnit: string;
pullImage: boolean;
editCompose: boolean;
dockerCompose: string;
};
showAllowPort?: boolean;
showSpecifyIP?: boolean;
showRestartPolicy?: boolean;
showPullImage?: boolean;
showCompose?: boolean;
autoLoadLimit?: boolean;
}>(),
{
showAllowPort: true,
showSpecifyIP: true,
showRestartPolicy: true,
showPullImage: true,
showCompose: true,
autoLoadLimit: true,
},
);
const form = toRef(props, 'form');
const limits = ref<Container.ResourceLimit>({
cpu: null as number,
memory: null as number,
});
const oldMemory = ref(0);
const changeUnit = () => {
if (form.value.memoryUnit === 'M') {
limits.value.memory = oldMemory.value;
} else {
limits.value.memory = Number((oldMemory.value / 1024).toFixed(2));
}
};
const loadLimit = async () => {
const res = await loadResourceLimit();
limits.value = res.data;
limits.value.memory = Number((limits.value.memory / 1024 / 1024).toFixed(2));
oldMemory.value = limits.value.memory;
};
onMounted(() => {
if (props.autoLoadLimit) {
loadLimit();
}
});
</script>
+14 -2
View File
@@ -31,6 +31,8 @@ interface DialogProps {
port: any;
ip: string;
protocol: string;
path?: string;
query?: string;
}
const acceptParams = async (params: DialogProps): Promise<void> => {
@@ -48,18 +50,28 @@ const acceptParams = async (params: DialogProps): Promise<void> => {
return;
}
}
const buildUrl = (host: string) => {
let url = `${protocol}://${host}:${params.port}`;
if (params.path) {
url += params.path.startsWith('/') ? params.path : `/${params.path}`;
}
if (params.query) {
url += params.query.startsWith('?') ? params.query : `?${params.query}`;
}
return url;
};
if (res.data.systemIP.indexOf(':') === -1) {
if (params.ip && params.ip === 'ipv6') {
MsgWarning(i18n.global.t('setting.systemIPWarning1', ['IPv4']));
return;
}
window.open(`${protocol}://${res.data.systemIP}:${params.port}`, '_blank');
window.open(buildUrl(res.data.systemIP), '_blank');
} else {
if (params.ip && params.ip === 'ipv4') {
MsgWarning(i18n.global.t('setting.systemIPWarning1', ['IPv6']));
return;
}
window.open(`${protocol}://[${res.data.systemIP}]:${params.port}`, '_blank');
window.open(buildUrl(`[${res.data.systemIP}]`), '_blank');
}
};
+24
View File
@@ -676,6 +676,30 @@ const message = {
localIP: 'Local IP',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: 'Model',
create: 'Add Model',
+24
View File
@@ -672,6 +672,30 @@ const message = {
localIP: 'IP local',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: 'Modelo',
create: 'Agregar modelo',
+24
View File
@@ -661,6 +661,30 @@ const message = {
localIP: 'ローカルIP',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: 'モデル',
create: 'モデルを追加',
+24
View File
@@ -658,6 +658,30 @@ const message = {
localIP: '로컬 IP',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: '모델',
create: '모델 추가',
+24
View File
@@ -673,6 +673,30 @@ const message = {
localIP: 'IP Tempatan',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: 'Model',
create: 'Tambah Model',
+24
View File
@@ -670,6 +670,30 @@ const message = {
localIP: 'IP local',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: 'Modelo',
create: 'Adicionar Modelo',
+24
View File
@@ -666,6 +666,30 @@ const message = {
localIP: 'Локальный IP',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: 'Модель',
create: 'Добавить модель',
+24
View File
@@ -680,6 +680,30 @@ const message = {
localIP: 'Yerel IP',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: 'Model',
create: 'Model Ekle',
+24
View File
@@ -648,6 +648,30 @@ const message = {
localIP: '本機 IP',
},
aiTools: {
agents: {
agents: 'Agents',
agent: 'Agent',
createAgent: 'Create Agent',
createModel: 'Create Model',
agentList: 'Agent List',
modelList: 'Model List',
account: 'Account',
accountName: 'Account Name',
accountList: 'Account List',
createAccount: 'Create Account',
syncAgents: 'Sync related agents',
syncAgentsHelper: 'Update openclaw.json for agents using this account',
appVersion: 'App Version',
webuiPort: 'WebUI Port',
bridgePort: 'Bridge Port',
provider: 'Provider',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: 'Model Name',
manualModel: 'Manual input',
verified: 'Verified',
},
model: {
model: '模型',
create: '新增模型',
+24
View File
@@ -650,6 +650,30 @@ const message = {
localIP: '本机 IP',
},
aiTools: {
agents: {
agents: '智能体',
agent: '智能体',
createAgent: '创建智能体',
createModel: '创建模型',
agentList: '智能体列表',
modelList: '模型列表',
account: '账号',
accountName: '账号名称',
accountList: '账号列表',
createAccount: '创建账号',
syncAgents: '同步关联智能体',
syncAgentsHelper: '更新使用该账号的智能体 openclaw.json',
appVersion: '应用版本',
webuiPort: 'WebUI 端口',
bridgePort: 'Bridge 端口',
provider: '模型供应商',
apiKey: 'API Key',
baseUrl: 'Base URL',
token: 'Token',
modelName: '模型名称',
manualModel: '手动输入模型',
verified: '验证状态',
},
model: {
model: '模型',
create: '添加模型',
+20
View File
@@ -11,6 +11,26 @@ const databaseRouter = {
title: 'menu.aiTools',
},
children: [
{
path: '/ai/agents/agent',
name: 'Agents',
component: () => import('@/views/ai/agents/agent/index.vue'),
meta: {
icon: 'p-jiqiren2',
title: 'aiTools.agents.agents',
requiresAuth: true,
},
},
{
path: '/ai/agents/model',
name: 'AgentsModel',
component: () => import('@/views/ai/agents/model/index.vue'),
meta: {
title: 'aiTools.agents.account',
activeMenu: '/ai/agents/agent',
requiresAuth: true,
},
},
{
path: '/ai/model/ollama',
name: 'OllamaModel',
+3
View File
@@ -13,6 +13,9 @@ export const jumpToInstall = (type: string, key: string) => {
return true;
}
switch (key) {
case 'openclaw':
jumpToPath(router, '/ai/agents/agent');
return true;
case 'mysql-cluster':
jumpToPath(router, '/xpack/cluster/mysql');
return true;
@@ -0,0 +1,308 @@
<template>
<DrawerPro v-model="open" :header="$t('aiTools.agents.createAgent')" size="large" @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item :label="$t('commons.table.name')" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="$t('aiTools.agents.appVersion')" prop="appVersion">
<el-select v-model="form.appVersion" filterable>
<el-option v-for="item in versions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item :label="$t('aiTools.agents.webuiPort')" prop="webUIPort">
<el-input-number v-model="form.webUIPort" :min="1" :max="65535" />
</el-form-item>
<el-form-item :label="$t('aiTools.agents.bridgePort')" prop="bridgePort">
<el-input-number v-model="form.bridgePort" :min="1" :max="65535" />
</el-form-item>
<el-form-item :label="$t('aiTools.agents.provider')" prop="provider">
<el-select v-model="form.provider" @change="handleProviderChange">
<el-option v-for="item in providerOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item v-if="accountOptions.length > 0" :label="$t('aiTools.agents.account')" prop="accountId">
<el-select v-model="form.accountId" @change="handleAccountChange">
<el-option v-for="item in accountOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item v-else :label="$t('aiTools.agents.account')">
<el-button type="text" @click="openAccountCreate">
{{ $t('aiTools.agents.createAccount') }}
</el-button>
</el-form-item>
<el-form-item>
<el-checkbox v-model="manualModel">{{ $t('aiTools.agents.manualModel') }}</el-checkbox>
</el-form-item>
<el-form-item :label="$t('aiTools.model.model')" prop="model">
<el-input v-if="manualModel" v-model="form.model" />
<el-select v-else v-model="form.model" filterable @change="handleModelChange">
<el-option v-for="item in filteredModels" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item :label="$t('aiTools.agents.apiKey')" prop="apiKey">
<el-input v-model="form.apiKey" type="password" show-password readonly />
</el-form-item>
<el-form-item :label="$t('aiTools.agents.baseUrl')" prop="baseURL">
<el-input v-model="form.baseURL" readonly />
</el-form-item>
<el-form-item :label="$t('aiTools.agents.token')">
<el-input v-model="form.token" readonly>
<template #append>
<CopyButton :content="form.token" />
</template>
</el-input>
</el-form-item>
<AdvancedSetting :form="form" />
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="open = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" @click="submit">{{ $t('commons.button.confirm') }}</el-button>
</span>
</template>
</DrawerPro>
<AccountAddDialog ref="accountAddRef" @search="handleAccountCreated" />
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { FormInstance } from 'element-plus';
import { checkNumberRange, Rules } from '@/global/form-rules';
import { createAgent, getAgentProviders, pageAgentAccounts } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
import { getAppByKey, getAppDetail } from '@/api/modules/app';
import { getRandomStr, newUUID } from '@/utils/util';
import { App } from '@/api/interface/app';
import AdvancedSetting from '@/components/advanced-setting/index.vue';
import AccountAddDialog from '@/views/ai/agents/model/add/index.vue';
const emit = defineEmits(['search', 'task']);
const open = ref(false);
const formRef = ref<FormInstance>();
const versions = ref<string[]>([]);
const accountOptions = ref<AI.AgentAccountItem[]>([]);
const providerOptions = ref<string[]>([]);
const providerModels = ref<Record<string, AI.ProviderModelInfo[]>>({});
const manualModel = ref(false);
const appInfo = ref<App.AppDTO>();
const accountAddRef = ref();
const form = reactive({
name: '',
appVersion: '',
webUIPort: 18789,
bridgePort: 18790,
provider: 'deepseek',
accountId: 0,
model: '',
apiKey: '',
baseURL: '',
token: '',
advanced: true,
containerName: '',
allowPort: true,
specifyIP: '',
restartPolicy: 'unless-stopped',
cpuQuota: 0,
memoryLimit: 0,
memoryUnit: 'M',
pullImage: true,
editCompose: false,
dockerCompose: '',
});
const rules = reactive({
name: [Rules.requiredInput],
appVersion: [Rules.requiredSelect],
webUIPort: [Rules.requiredInput],
bridgePort: [Rules.requiredInput],
provider: [Rules.requiredSelect],
accountId: [Rules.requiredSelect],
model: [Rules.requiredSelect],
containerName: [Rules.containerName],
restartPolicy: [Rules.requiredSelect],
cpuQuota: [checkNumberRange(0, 99999)],
memoryLimit: [checkNumberRange(0, 9999999999)],
specifyIP: [Rules.ipv4orV6],
});
const filteredModels = computed(() => providerModels.value[form.provider] || []);
const loadVersions = async () => {
const res = await getAppByKey('openclaw');
appInfo.value = res.data;
versions.value = res.data.versions || [];
if (!form.appVersion && versions.value.length > 0) {
form.appVersion = versions.value[0];
}
};
const loadCompose = async () => {
if (!appInfo.value || !form.appVersion) {
return;
}
const res = await getAppDetail(appInfo.value.id, form.appVersion, appInfo.value.type);
form.dockerCompose = res.data.dockerCompose || '';
};
const loadProviders = async () => {
const res = await getAgentProviders();
const data = res.data || [];
providerOptions.value = data.map((item) => item.provider);
providerModels.value = data.reduce((acc, item) => {
acc[item.provider] = item.models || [];
return acc;
}, {} as Record<string, AI.ProviderModelInfo[]>);
if (!providerOptions.value.includes(form.provider) && providerOptions.value.length > 0) {
form.provider = providerOptions.value[0];
}
setDefaultModel();
};
const loadAccounts = async () => {
if (!form.provider) {
accountOptions.value = [];
return;
}
const req: AI.AgentAccountSearch = {
page: 1,
pageSize: 200,
provider: form.provider,
name: '',
};
const res = await pageAgentAccounts(req);
accountOptions.value = res.data.items || [];
if (accountOptions.value.length > 0) {
form.accountId = accountOptions.value[0].id;
handleAccountChange();
} else {
form.accountId = 0;
form.apiKey = '';
form.baseURL = '';
}
};
const handleProviderChange = () => {
form.model = '';
form.apiKey = '';
form.baseURL = '';
form.accountId = 0;
loadAccounts();
setDefaultModel();
};
const handleModelChange = () => {
if (manualModel.value) {
return;
}
};
const handleAccountChange = () => {
const selected = accountOptions.value.find((item) => item.id === form.accountId);
if (selected) {
form.baseURL = selected.baseUrl || '';
form.apiKey = selected.apiKey || '';
}
setDefaultModel();
};
const setDefaultModel = () => {
if (manualModel.value) {
return;
}
const models = filteredModels.value;
if (models.length > 0 && !form.model) {
form.model = models[0].id;
}
};
const submit = async () => {
if (!formRef.value) {
return;
}
await formRef.value.validate();
const taskID = newUUID();
if (!form.token) {
form.token = getRandomStr(32).toLowerCase();
}
const res = await createAgent({
name: form.name,
appVersion: form.appVersion,
webUIPort: form.webUIPort,
bridgePort: form.bridgePort,
provider: form.provider,
model: form.model,
accountId: form.accountId,
apiKey: form.apiKey,
baseURL: form.baseURL,
token: form.token,
taskID: taskID,
advanced: form.advanced,
containerName: form.containerName,
allowPort: form.allowPort,
specifyIP: form.specifyIP,
restartPolicy: form.restartPolicy,
cpuQuota: form.cpuQuota,
memoryLimit: form.memoryLimit,
memoryUnit: form.memoryUnit,
pullImage: form.pullImage,
editCompose: form.editCompose,
dockerCompose: form.dockerCompose,
});
form.token = res.data.token || form.token;
emit('search');
emit('task', taskID);
open.value = false;
};
const handleClose = () => {
formRef.value?.resetFields();
form.token = '';
form.dockerCompose = '';
};
const openDrawer = async () => {
open.value = true;
manualModel.value = false;
form.token = getRandomStr(32).toLowerCase();
await loadVersions();
await loadProviders();
await loadAccounts();
};
const openAccountCreate = () => {
if (accountAddRef.value?.open) {
accountAddRef.value.open({ provider: form.provider });
}
};
const handleAccountCreated = async () => {
await loadAccounts();
};
watch(
() => form.editCompose,
async (value) => {
if (value && !form.dockerCompose) {
await loadCompose();
}
},
);
watch(
() => form.appVersion,
async (value, oldValue) => {
if (!value || value === oldValue) {
return;
}
if (form.editCompose) {
await loadCompose();
}
},
);
defineExpose({
open: openDrawer,
});
</script>
@@ -0,0 +1,77 @@
<template>
<DialogPro
v-model="open"
:title="$t('commons.button.delete') + ' - ' + agentName"
size="small"
@close="handleClose"
>
<el-form ref="deleteForm" label-position="left" v-loading="loading">
<el-form-item>
<el-checkbox v-model="deleteReq.forceDelete" :label="$t('website.forceDelete')" />
<span class="input-help">
{{ $t('website.forceDeleteHelper') }}
</span>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose" :disabled="loading">{{ $t('commons.button.cancel') }}</el-button>
<el-button type="primary" @click="submit" :loading="loading">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</DialogPro>
<TaskLog ref="taskLogRef" @close="handleClose" />
</template>
<script setup lang="ts">
import { FormInstance } from 'element-plus';
import { ref } from 'vue';
import { deleteAgent } from '@/api/modules/ai';
import { newUUID } from '@/utils/util';
import TaskLog from '@/components/log/task/index.vue';
const open = ref(false);
const loading = ref(false);
const taskLogRef = ref();
const agentName = ref('');
const deleteForm = ref<FormInstance>();
const deleteReq = ref({
id: 0,
taskID: '',
forceDelete: false,
});
const emit = defineEmits(['close']);
const acceptParams = (id: number, name: string) => {
deleteReq.value = {
id: id,
taskID: newUUID(),
forceDelete: false,
};
agentName.value = name;
open.value = true;
};
const handleClose = () => {
open.value = false;
emit('close');
};
const submit = async () => {
loading.value = true;
try {
await deleteAgent(deleteReq.value);
handleClose();
taskLogRef.value?.openWithTaskID(deleteReq.value.taskID);
} finally {
loading.value = false;
}
};
defineExpose({
acceptParams,
});
</script>
@@ -0,0 +1,195 @@
<template>
<div>
<RouterMenu />
<LayoutContent :title="$t('aiTools.agents.agentList')">
<template #leftToolBar>
<el-button type="primary" @click="openCreate">{{ $t('aiTools.agents.createAgent') }}</el-button>
</template>
<template #rightToolBar>
<TableSearch v-model:searchName="searchName" @search="search" />
<TableRefresh @search="search" />
</template>
<template #main>
<ComplexTable :data="items" :pagination-config="paginationConfig" @search="search">
<el-table-column :label="$t('commons.table.name')" prop="name" min-width="160" />
<el-table-column :label="$t('aiTools.agents.appVersion')" prop="appVersion" width="120" />
<el-table-column :label="$t('aiTools.agents.webuiPort')" prop="webUIPort" width="140">
<template #default="{ row }">
<el-button icon="Position" plain size="small" @click="jumpWebUI(row)">
{{ row.webUIPort }}
</el-button>
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.agents.bridgePort')" prop="bridgePort" width="120" />
<el-table-column :label="$t('aiTools.agents.provider')" prop="provider" width="120" />
<el-table-column :label="$t('aiTools.model.model')" prop="model" min-width="180" />
<el-table-column :label="$t('aiTools.agents.token')" width="120">
<template #default="{ row }">
<CopyButton :content="row.token" />
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" prop="status" width="120">
<template #default="{ row }">
<Status :status="row.status" />
</template>
</el-table-column>
<el-table-column
:label="$t('commons.table.date')"
prop="createdAt"
width="180"
:formatter="dateFormat"
show-overflow-tooltip
/>
<fu-table-operations
:buttons="buttons"
min-width="300"
:label="$t('commons.table.operate')"
fixed="right"
:ellipsis="5"
/>
</ComplexTable>
</template>
</LayoutContent>
<AddDialog ref="addRef" @search="search" @task="openTaskLog" />
<TaskLog ref="taskLogRef" @close="search" />
<DeleteDialog ref="deleteRef" @close="search" />
<ComposeLogs ref="composeLogRef" />
<TerminalDialog ref="dialogTerminalRef" />
<PortJumpDialog ref="dialogPortJumpRef" />
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { pageAgents } from '@/api/modules/ai';
import { installedOp } from '@/api/modules/app';
import { AI } from '@/api/interface/ai';
import { SearchWithPage } from '@/api/interface';
import { dateFormat, newUUID } from '@/utils/util';
import RouterMenu from '@/views/ai/agents/index.vue';
import AddDialog from '@/views/ai/agents/agent/add/index.vue';
import DeleteDialog from '@/views/ai/agents/agent/delete/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import ComposeLogs from '@/components/log/compose/index.vue';
import TerminalDialog from '@/views/container/container/terminal/index.vue';
import i18n from '@/lang';
import PortJumpDialog from '@/components/port-jump/index.vue';
const items = ref<AI.AgentItem[]>([]);
const addRef = ref();
const taskLogRef = ref();
const deleteRef = ref();
const composeLogRef = ref();
const dialogTerminalRef = ref();
const dialogPortJumpRef = ref();
const searchName = ref('');
const buttons = [
{
label: i18n.global.t('commons.operate.start'),
click: (row: AI.AgentItem) => onOperate(row, 'start'),
disabled: (row: AI.AgentItem) => row.status === 'Running',
},
{
label: i18n.global.t('commons.operate.stop'),
click: (row: AI.AgentItem) => onOperate(row, 'stop'),
disabled: (row: AI.AgentItem) => row.status !== 'Running',
},
{
label: i18n.global.t('commons.operate.restart'),
click: (row: AI.AgentItem) => onOperate(row, 'restart'),
},
{
label: i18n.global.t('commons.button.log'),
click: (row: AI.AgentItem) => openLog(row),
},
{
label: i18n.global.t('menu.terminal'),
click: (row: AI.AgentItem) => openTerminal(row),
},
{
label: i18n.global.t('commons.button.delete'),
click: (row: AI.AgentItem) => onDelete(row),
},
];
const paginationConfig = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
});
const search = async () => {
const req: SearchWithPage = {
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
info: searchName.value || '',
};
const res = await pageAgents(req);
items.value = res.data.items || [];
paginationConfig.total = res.data.total || 0;
};
const openCreate = () => {
if (addRef.value?.open) {
addRef.value.open();
}
};
const openTaskLog = (taskID: string) => {
if (taskLogRef.value?.openWithTaskID) {
taskLogRef.value.openWithTaskID(taskID);
}
};
const onOperate = async (row: AI.AgentItem, operate: string) => {
await ElMessageBox.confirm(
i18n.global.t('app.operatorHelper', [i18n.global.t('commons.operate.' + operate)]),
i18n.global.t('commons.operate.' + operate),
{
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
type: 'info',
},
);
const taskID = newUUID();
await installedOp({ installId: row.appInstallId, operate, taskID });
await search();
};
const openLog = (row: AI.AgentItem) => {
if (row.status === 'Installing') {
taskLogRef.value?.openWithResourceID('App', 'TaskInstall', row.appInstallId);
return;
}
composeLogRef.value?.acceptParams({
compose: `${row.path}/docker-compose.yml`,
resource: row.name,
container: row.containerName,
});
};
const openTerminal = (row: AI.AgentItem) => {
const title = i18n.global.t('aiTools.agents.agent') + ' ' + row.name;
dialogTerminalRef.value?.acceptParams({ containerID: row.containerName, title });
};
const jumpWebUI = (row: AI.AgentItem) => {
if (dialogPortJumpRef.value?.acceptParams) {
dialogPortJumpRef.value.acceptParams({
port: row.webUIPort,
protocol: 'http',
query: `token=${row.token}`,
});
}
};
const onDelete = (row: AI.AgentItem) => {
deleteRef.value?.acceptParams(row.id, row.name);
};
onMounted(async () => {
await search();
});
</script>
+23
View File
@@ -0,0 +1,23 @@
<template>
<div>
<RouterButton :buttons="buttons" />
<LayoutContent>
<router-view></router-view>
</LayoutContent>
</div>
</template>
<script setup lang="ts">
import i18n from '@/lang';
const buttons = [
{
label: i18n.global.t('aiTools.agents.agent'),
path: '/ai/agents/agent',
},
{
label: i18n.global.t('aiTools.agents.account'),
path: '/ai/agents/model',
},
];
</script>
@@ -0,0 +1,176 @@
<template>
<DrawerPro v-model="open" :header="headerTitle" size="large" @close="handleClose">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" v-loading="loading">
<el-form-item :label="$t('aiTools.agents.provider')" prop="provider">
<el-select v-model="form.provider" @change="handleProviderChange" :disabled="form.id > 0">
<el-option v-for="item in providerOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
<el-form-item :label="$t('aiTools.agents.accountName')" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="$t('aiTools.agents.baseUrl')" prop="baseURL">
<el-input v-model="form.baseURL" :disabled="form.provider !== 'ollama'" />
</el-form-item>
<el-form-item :label="$t('aiTools.agents.apiKey')" prop="apiKey">
<el-input v-model="form.apiKey" type="password" show-password />
</el-form-item>
<el-form-item :label="$t('website.remark')" prop="remark">
<el-input v-model="form.remark" />
</el-form-item>
<el-form-item v-if="form.id" prop="syncAgents">
<el-checkbox v-model="form.syncAgents" :label="$t('aiTools.agents.syncAgents')" />
<span class="input-help">{{ $t('aiTools.agents.syncAgentsHelper') }}</span>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="loading" @click="open = false">{{ $t('commons.button.cancel') }}</el-button>
<el-button :disabled="loading" type="primary" @click="submit">
{{ $t('commons.button.confirm') }}
</el-button>
</span>
</template>
</DrawerPro>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { FormInstance } from 'element-plus';
import { Rules } from '@/global/form-rules';
import { createAgentAccount, getAgentProviders, updateAgentAccount } from '@/api/modules/ai';
import { MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
const emit = defineEmits(['search']);
const open = ref(false);
const formRef = ref<FormInstance>();
const providerOptions = ref<string[]>([]);
const providerBaseURL = ref<Record<string, string>>({});
const loading = ref(false);
const form = reactive({
id: 0,
provider: '',
name: '',
baseURL: '',
apiKey: '',
remark: '',
syncAgents: false,
});
const headerTitle = computed(() =>
form.id ? i18n.global.t('commons.button.edit') : i18n.global.t('aiTools.agents.createAccount'),
);
const rules = reactive({
provider: [Rules.requiredSelect],
name: [Rules.requiredInput],
apiKey: [Rules.requiredInput],
});
const submit = async () => {
if (!formRef.value) {
return;
}
await formRef.value.validate();
loading.value = true;
try {
if (form.id) {
await updateAgentAccount({
id: form.id,
name: form.name,
baseURL: form.baseURL,
apiKey: form.apiKey,
remark: form.remark,
syncAgents: form.syncAgents,
});
} else {
await createAgentAccount({
provider: form.provider,
name: form.name,
baseURL: form.baseURL,
apiKey: form.apiKey,
remark: form.remark,
});
}
MsgSuccess();
emit('search');
open.value = false;
} finally {
loading.value = false;
}
};
const handleClose = () => {
formRef.value?.resetFields();
loading.value = false;
form.id = 0;
form.syncAgents = false;
};
interface OpenParams {
id?: number;
provider?: string;
name?: string;
baseURL?: string;
apiKey?: string;
remark?: string;
}
const openDrawer = (params?: OpenParams) => {
open.value = true;
loading.value = false;
if (params?.id) {
form.id = params.id;
form.provider = params.provider || '';
form.name = params.name || '';
form.baseURL = params.baseURL || '';
form.apiKey = params.apiKey || '';
form.remark = params.remark || '';
form.syncAgents = false;
return;
}
form.id = 0;
form.name = '';
form.baseURL = '';
form.apiKey = '';
form.remark = '';
form.syncAgents = false;
if (params?.provider) {
form.provider = params.provider;
handleProviderChange();
}
};
const loadProviders = async () => {
const res = await getAgentProviders();
const data = res.data || [];
providerOptions.value = data.map((item) => item.provider);
providerBaseURL.value = data.reduce((acc, item) => {
acc[item.provider] = item.baseUrl || '';
return acc;
}, {} as Record<string, string>);
if (!form.provider && providerOptions.value.length > 0) {
form.provider = providerOptions.value[0];
handleProviderChange();
}
};
const handleProviderChange = () => {
if (form.provider !== 'ollama') {
form.baseURL = providerBaseURL.value[form.provider] || '';
} else {
form.baseURL = '';
}
};
onMounted(async () => {
await loadProviders();
});
defineExpose({
open: openDrawer,
});
</script>
@@ -0,0 +1,131 @@
<template>
<div>
<RouterMenu />
<LayoutContent :title="$t('aiTools.agents.accountList')">
<template #leftToolBar>
<el-button type="primary" @click="openCreate">{{ $t('aiTools.agents.createAccount') }}</el-button>
</template>
<template #rightToolBar>
<TableSearch v-model:searchName="searchName" @search="search" />
<TableRefresh @search="search" />
</template>
<template #main>
<ComplexTable :data="items" :pagination-config="paginationConfig" @search="search">
<el-table-column :label="$t('aiTools.agents.provider')" prop="provider" width="120" />
<el-table-column :label="$t('aiTools.agents.accountName')" prop="name" min-width="200" />
<el-table-column :label="$t('aiTools.agents.baseUrl')" prop="baseUrl" min-width="200" />
<el-table-column :label="$t('aiTools.agents.apiKey')" prop="apiKey" min-width="160">
<template #default="{ row }">
{{ maskKey(row.apiKey) }}
</template>
</el-table-column>
<el-table-column :label="$t('aiTools.agents.verified')" prop="verified" width="120">
<template #default="{ row }">
<el-tag :type="row.verified ? 'success' : 'info'">
{{ row.verified ? 'OK' : 'N/A' }}
</el-tag>
</template>
</el-table-column>
<el-table-column
:label="$t('commons.table.date')"
prop="createdAt"
width="180"
:formatter="dateFormat"
/>
<fu-table-operations :buttons="buttons" :label="$t('commons.table.operate')" fixed="right" />
</ComplexTable>
</template>
</LayoutContent>
<AddDialog ref="addRef" @search="search" />
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { deleteAgentAccount, pageAgentAccounts } from '@/api/modules/ai';
import { AI } from '@/api/interface/ai';
import RouterMenu from '@/views/ai/agents/index.vue';
import AddDialog from '@/views/ai/agents/model/add/index.vue';
import { ElMessageBox } from 'element-plus';
import i18n from '@/lang';
import { dateFormat } from '@/utils/util';
const items = ref<AI.AgentAccountItem[]>([]);
const addRef = ref();
const searchName = ref('');
const buttons = [
{
label: i18n.global.t('commons.button.edit'),
click: (row: AI.AgentAccountItem) => onEdit(row),
},
{
label: i18n.global.t('commons.button.delete'),
click: (row: AI.AgentAccountItem) => onDelete(row),
},
];
const paginationConfig = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
});
const search = async () => {
const req: AI.AgentAccountSearch = {
page: paginationConfig.currentPage,
pageSize: paginationConfig.pageSize,
provider: '',
name: searchName.value || '',
};
const res = await pageAgentAccounts(req);
items.value = res.data.items || [];
paginationConfig.total = res.data.total || 0;
};
const openCreate = () => {
if (addRef.value?.open) {
addRef.value.open();
}
};
const onEdit = (row: AI.AgentAccountItem) => {
if (addRef.value?.open) {
addRef.value.open({
id: row.id,
provider: row.provider,
name: row.name,
baseURL: row.baseUrl,
apiKey: row.apiKey,
remark: row.remark,
});
}
};
const maskKey = (value: string) => {
if (!value) {
return '';
}
if (value.length <= 6) {
return value;
}
return `${value.slice(0, 3)}****${value.slice(-3)}`;
};
const onDelete = async (row: AI.AgentAccountItem) => {
await ElMessageBox.confirm(
i18n.global.t('commons.msg.confirmDelete', [row.name]),
i18n.global.t('commons.button.delete'),
{
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
},
);
await deleteAgentAccount({ id: row.id });
await search();
};
onMounted(async () => {
await search();
});
</script>