feat: Add website binding support for the agent (#12413)

This commit is contained in:
CityFun
2026-04-03 09:15:54 +00:00
committed by GitHub
parent 61ef725892
commit 368193a777
30 changed files with 759 additions and 29 deletions
+20
View File
@@ -111,6 +111,26 @@ func (b *BaseApi) UpdateAgentRemark(c *gin.Context) {
helper.Success(c)
}
// @Tags AI
// @Summary Bind Agent website
// @Accept json
// @Param request body dto.AgentWebsiteBindReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/website/bind [post]
func (b *BaseApi) BindAgentWebsite(c *gin.Context) {
var req dto.AgentWebsiteBindReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.BindWebsite(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI
// @Summary Get Agent model config
// @Accept json
+33 -25
View File
@@ -28,31 +28,34 @@ type AgentCreateReq struct {
}
type AgentItem struct {
ID uint `json:"id"`
Name string `json:"name"`
Remark string `json:"remark"`
AgentType string `json:"agentType"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
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"`
AppVersion string `json:"appVersion"`
Container string `json:"containerName"`
WebUIPort int `json:"webUIPort"`
BridgePort int `json:"bridgePort"`
Path string `json:"path"`
ConfigPath string `json:"configPath"`
Upgradable bool `json:"upgradable"`
CreatedAt time.Time `json:"createdAt"`
ID uint `json:"id"`
Name string `json:"name"`
Remark string `json:"remark"`
AgentType string `json:"agentType"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
WebsiteID uint `json:"websiteId"`
WebsitePrimaryDomain string `json:"websitePrimaryDomain"`
WebsiteProtocol string `json:"websiteProtocol"`
AccountID uint `json:"accountId"`
AppVersion string `json:"appVersion"`
Container string `json:"containerName"`
WebUIPort int `json:"webUIPort"`
BridgePort int `json:"bridgePort"`
Path string `json:"path"`
ConfigPath string `json:"configPath"`
Upgradable bool `json:"upgradable"`
CreatedAt time.Time `json:"createdAt"`
}
type AgentDeleteReq struct {
@@ -70,6 +73,11 @@ type AgentRemarkUpdateReq struct {
Remark string `json:"remark"`
}
type AgentWebsiteBindReq struct {
AgentID uint `json:"agentId" validate:"required"`
WebsiteID uint `json:"websiteId" validate:"required"`
}
type AgentModelConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"`
+1
View File
@@ -16,6 +16,7 @@ type Agent struct {
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
WebsiteID uint `json:"websiteId"`
AccountID uint `json:"accountId"`
ConfigPath string `json:"configPath"`
}
+8
View File
@@ -16,6 +16,7 @@ type IAgentRepo interface {
DeleteByID(id uint) error
DeleteByAppInstallID(appInstallID uint) error
DeleteByAppInstallIDWithCtx(ctx context.Context, appInstallID uint) error
ClearWebsiteIDByWebsiteIDWithCtx(ctx context.Context, websiteID uint) error
List(opts ...DBOption) ([]model.Agent, error)
}
@@ -66,6 +67,13 @@ func (a AgentRepo) DeleteByAppInstallIDWithCtx(ctx context.Context, appInstallID
return getTx(ctx).Where("app_install_id = ?", appInstallID).Delete(&model.Agent{}).Error
}
func (a AgentRepo) ClearWebsiteIDByWebsiteIDWithCtx(ctx context.Context, websiteID uint) error {
if websiteID == 0 {
return nil
}
return getTx(ctx).Model(&model.Agent{}).Where("website_id = ?", websiteID).Update("website_id", 0).Error
}
func (a AgentRepo) List(opts ...DBOption) ([]model.Agent, error) {
var agents []model.Agent
if err := getDb(opts...).Find(&agents).Error; err != nil {
+18
View File
@@ -112,6 +112,24 @@ func WithByAccountID(accountID uint) DBOption {
}
}
func WithByWebsiteID(websiteID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if websiteID == 0 {
return g
}
return g.Where("website_id = ?", websiteID)
}
}
func WithByAppInstallID(appInstallID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if appInstallID == 0 {
return g
}
return g.Where("app_install_id = ?", appInstallID)
}
}
func WithByType(tp string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("`type` = ?", tp)
+10
View File
@@ -10,6 +10,7 @@ import (
type IWebsiteRepo interface {
WithAppInstallId(appInstallId uint) DBOption
WithAppInstallIds(appInstallIds []uint) DBOption
WithDomain(domain string) DBOption
WithAlias(alias string) DBOption
WithWebsiteSSLID(sslId uint) DBOption
@@ -48,6 +49,15 @@ func (w *WebsiteRepo) WithAppInstallId(appInstallID uint) DBOption {
}
}
func (w *WebsiteRepo) WithAppInstallIds(appInstallIDs []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
if len(appInstallIDs) == 0 {
return db
}
return db.Where("app_install_id in (?)", appInstallIDs)
}
}
func (w *WebsiteRepo) WithRuntimeID(runtimeID uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("runtime_id = ?", runtimeID)
+10
View File
@@ -13,6 +13,7 @@ type WebsiteDomainRepo struct {
type IWebsiteDomainRepo interface {
WithWebsiteId(websiteId uint) DBOption
WithWebsiteIds(websiteIds []uint) DBOption
WithPort(port int) DBOption
WithDomain(domain string) DBOption
WithDomainLike(domain string) DBOption
@@ -36,6 +37,15 @@ func (w WebsiteDomainRepo) WithWebsiteId(websiteId uint) DBOption {
}
}
func (w WebsiteDomainRepo) WithWebsiteIds(websiteIds []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
if len(websiteIds) == 0 {
return db
}
return db.Where("website_id in (?)", websiteIds)
}
}
func (w WebsiteDomainRepo) WithPort(port int) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("port = ?", port)
+4
View File
@@ -31,6 +31,7 @@ type IAgentService interface {
Delete(req dto.AgentDeleteReq) error
ResetToken(req dto.AgentTokenResetReq) error
UpdateRemark(req dto.AgentRemarkUpdateReq) error
BindWebsite(req dto.AgentWebsiteBindReq) error
GetModelConfig(req dto.AgentIDReq) (*dto.AgentModelConfig, error)
UpdateModelConfig(req dto.AgentModelConfigUpdateReq) error
GetOverview(req dto.AgentOverviewReq) (*dto.AgentOverview, error)
@@ -289,6 +290,9 @@ func (a AgentService) Page(req dto.SearchWithPage) (int64, []dto.AgentItem, erro
agentItem.Upgradable = checkAgentUpgradable(appInstall)
items = append(items, agentItem)
}
if err := hydrateAgentWebsiteItems(items); err != nil {
return 0, nil, err
}
return count, items, nil
}
+1
View File
@@ -357,6 +357,7 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
Status: agent.Status,
Message: agent.Message,
AppInstallID: agent.AppInstallID,
WebsiteID: agent.WebsiteID,
AccountID: agent.AccountID,
ConfigPath: agent.ConfigPath,
CreatedAt: agent.CreatedAt,
+301
View File
@@ -0,0 +1,301 @@
package service
import (
"context"
"errors"
"fmt"
"net"
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"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"
"gorm.io/gorm"
)
func (a AgentService) BindWebsite(req dto.AgentWebsiteBindReq) error {
agent, err := agentRepo.GetFirst(repo.WithByID(req.AgentID))
if err != nil {
return err
}
if agent.WebsiteID != 0 {
return buserr.New("ErrAgentWebsiteBound")
}
website, err := websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
if err != nil {
return err
}
if !isBindableAgentWebsiteType(website.Type) {
return buserr.New("ErrAgentWebsiteTypeUnsupported")
}
boundAgent, err := agentRepo.GetFirst(repo.WithByWebsiteID(req.WebsiteID))
if err == nil && boundAgent.ID > 0 {
return buserr.New("ErrAgentWebsiteInUse")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
agent.WebsiteID = req.WebsiteID
if err := agentRepo.Save(agent); err != nil {
return err
}
return ensureOpenclawWebsiteAllowedOrigin(agent, &website)
}
func hydrateAgentWebsiteItems(items []dto.AgentItem) error {
explicitWebsiteMap, err := loadAgentWebsiteMapByID(items)
if err != nil {
return err
}
websiteDomainMap, err := loadAgentWebsiteDomainMapByWebsiteID(items)
if err != nil {
return err
}
fillAgentWebsiteItems(items, explicitWebsiteMap, websiteDomainMap)
return nil
}
func loadAgentWebsiteMapByID(items []dto.AgentItem) (map[uint]model.Website, error) {
websiteIDs := make([]uint, 0, len(items))
for _, item := range items {
if item.WebsiteID > 0 {
websiteIDs = append(websiteIDs, item.WebsiteID)
}
}
if len(websiteIDs) == 0 {
return map[uint]model.Website{}, nil
}
websites, err := websiteRepo.GetBy(repo.WithByIDs(uniqueUintList(websiteIDs)))
if err != nil {
return nil, err
}
websiteMap := make(map[uint]model.Website, len(websites))
for _, website := range websites {
websiteMap[website.ID] = website
}
return websiteMap, nil
}
func loadAgentWebsiteDomainMapByWebsiteID(items []dto.AgentItem) (map[uint][]model.WebsiteDomain, error) {
websiteIDs := make([]uint, 0, len(items))
for _, item := range items {
if item.WebsiteID > 0 {
websiteIDs = append(websiteIDs, item.WebsiteID)
}
}
if len(websiteIDs) == 0 {
return map[uint][]model.WebsiteDomain{}, nil
}
websiteDomains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteIds(uniqueUintList(websiteIDs)), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
websiteDomainMap := make(map[uint][]model.WebsiteDomain, len(websiteDomains))
for _, websiteDomain := range websiteDomains {
websiteDomainMap[websiteDomain.WebsiteID] = append(websiteDomainMap[websiteDomain.WebsiteID], websiteDomain)
}
return websiteDomainMap, nil
}
func fillAgentWebsiteItems(items []dto.AgentItem, explicitWebsiteMap map[uint]model.Website, websiteDomainMap map[uint][]model.WebsiteDomain) {
for index := range items {
if items[index].WebsiteID == 0 {
continue
}
website, ok := explicitWebsiteMap[items[index].WebsiteID]
if !ok {
items[index].WebsiteID = 0
items[index].WebsitePrimaryDomain = ""
items[index].WebsiteProtocol = ""
continue
}
items[index].WebsiteProtocol = website.Protocol
websiteDomains := websiteDomainMap[items[index].WebsiteID]
if len(websiteDomains) == 0 {
items[index].WebsitePrimaryDomain = ""
continue
}
items[index].WebsitePrimaryDomain = websiteDomains[0].Domain
}
}
func uniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]model.Website {
websiteMap := make(map[uint]model.Website)
duplicateAppInstallIDs := make(map[uint]struct{})
for _, website := range websites {
if website.AppInstallID == 0 {
continue
}
if _, duplicated := duplicateAppInstallIDs[website.AppInstallID]; duplicated {
continue
}
if _, exists := websiteMap[website.AppInstallID]; exists {
delete(websiteMap, website.AppInstallID)
duplicateAppInstallIDs[website.AppInstallID] = struct{}{}
continue
}
websiteMap[website.AppInstallID] = website
}
return websiteMap
}
func UniqueDeploymentWebsiteMapForMigration(websites []model.Website) map[uint]model.Website {
return uniqueDeploymentWebsiteMapByAppInstall(websites)
}
func uniqueUintList(items []uint) []uint {
itemMap := make(map[uint]struct{}, len(items))
uniq := make([]uint, 0, len(items))
for _, item := range items {
if item == 0 {
continue
}
if _, exists := itemMap[item]; exists {
continue
}
itemMap[item] = struct{}{}
uniq = append(uniq, item)
}
return uniq
}
func isBindableAgentWebsiteType(websiteType string) bool {
return websiteType == constant.Proxy || websiteType == constant.Deployment || websiteType == constant.Static
}
func bindDeploymentWebsiteToAgentByAppInstall(website *model.Website) error {
if website.ID == 0 || website.Type != constant.Deployment || website.AppInstallID == 0 {
return nil
}
appInstall, err := appInstallRepo.GetFirst(repo.WithByID(website.AppInstallID))
if err != nil {
return err
}
if appInstall.App.Key != constant.AppOpenclaw && appInstall.App.Key != constant.AppCopaw {
return nil
}
agent, err := agentRepo.GetFirst(repo.WithByAppInstallID(website.AppInstallID))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if agent.WebsiteID != 0 {
return nil
}
agent.WebsiteID = website.ID
if err := agentRepo.Save(agent); err != nil {
return err
}
return ensureOpenclawWebsiteAllowedOrigin(agent, website)
}
func ensureOpenclawWebsiteAllowedOrigin(agent *model.Agent, website *model.Website) error {
if agent == nil || website == nil || agent.AgentType == constant.AppCopaw {
return nil
}
origins, err := buildWebsiteAllowedOrigins(website)
if err != nil {
return err
}
if len(origins) == 0 {
return nil
}
install, err := appInstallRepo.GetFirst(repo.WithByID(agent.AppInstallID))
if err != nil {
return err
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
allowedOrigins := extractSecurityConfig(conf).AllowedOrigins
allowedOrigins, err = normalizeAllowedOrigins(append(allowedOrigins, origins...))
if err != nil {
return err
}
setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins})
if err := writeOpenclawConfigRaw(agent.ConfigPath, conf); err != nil {
return err
}
if err := syncOpenclawAllowedOriginEnv(&install, allowedOrigins); err != nil {
return err
}
return appInstallRepo.Save(context.Background(), &install)
}
func buildWebsiteAllowedOrigins(website *model.Website) ([]string, error) {
websiteDomains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(website.ID), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
if len(websiteDomains) == 0 {
return nil, nil
}
defaultHTTPSPort := 443
if strings.EqualFold(website.Protocol, "https") {
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err == nil && nginxInstall.HttpsPort > 0 {
defaultHTTPSPort = nginxInstall.HttpsPort
}
}
return buildWebsiteAllowedOriginsFromDomains(website, websiteDomains, defaultHTTPSPort)
}
func buildWebsiteAllowedOriginsFromDomains(website *model.Website, websiteDomains []model.WebsiteDomain, defaultHTTPSPort int) ([]string, error) {
if len(websiteDomains) == 0 {
return nil, nil
}
sort.Slice(websiteDomains, func(i, j int) bool {
return websiteDomains[i].ID < websiteDomains[j].ID
})
origins := make([]string, 0, len(websiteDomains))
for _, websiteDomain := range websiteDomains {
origin, err := buildWebsiteDomainOrigin(website.Protocol, websiteDomain, defaultHTTPSPort)
if err != nil {
return nil, err
}
origins = append(origins, origin)
}
return origins, nil
}
func buildWebsiteDomainOrigin(protocol string, websiteDomain model.WebsiteDomain, defaultHTTPSPort int) (string, error) {
origin := strings.ToLower(strings.TrimSpace(protocol)) + "://" + formatWebsiteDomainHost(websiteDomain.Domain)
switch strings.ToLower(strings.TrimSpace(protocol)) {
case "http":
if websiteDomain.Port > 0 && websiteDomain.Port != 80 {
origin = fmt.Sprintf("%s:%d", origin, websiteDomain.Port)
}
case "https":
port := websiteDomain.Port
if !websiteDomain.SSL {
port = defaultHTTPSPort
}
if port > 0 && port != 443 {
origin = fmt.Sprintf("%s:%d", origin, port)
}
}
return normalizeAllowedOrigin(origin)
}
func formatWebsiteDomainHost(domain string) string {
host := strings.Trim(strings.TrimSpace(domain), "[]")
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}
+10 -1
View File
@@ -538,7 +538,13 @@ func (w WebsiteService) CreateWebsite(create request.WebsiteCreate) (err error)
createTask.AddSubTaskWithIgnoreErr(i18n.GetWithName("ConfigFTP", create.FtpUser), createFtpUser)
}
return createTask.Execute()
if err := createTask.Execute(); err != nil {
return err
}
if err := bindDeploymentWebsiteToAgentByAppInstall(website); err != nil {
global.LOG.Errorf("bind deployment website to agent failed: %v", err)
}
return nil
}
func (w WebsiteService) OpWebsite(req request.WebsiteOp) error {
@@ -733,6 +739,9 @@ func (w WebsiteService) DeleteWebsite(req request.WebsiteDelete) error {
if err := websiteRepo.DeleteBy(ctx, repo.WithByID(req.ID)); err != nil {
return err
}
if err := agentRepo.ClearWebsiteIDByWebsiteIDWithCtx(ctx, req.ID); err != nil {
return err
}
if err := websiteDomainRepo.DeleteBy(ctx, websiteDomainRepo.WithWebsiteId(req.ID)); err != nil {
return err
}
+3
View File
@@ -59,6 +59,9 @@ ErrAgentComposeRequired: 'Compose content is required'
ErrAgentIDRequired: 'Agent ID is required'
ErrAgentAccountIDRequired: 'Account ID is required'
ErrAgentLimitReached: 'Community Edition supports up to {{ .max }} AI agents. Upgrade to Pro Edition for unlimited agents'
ErrAgentWebsiteBound: 'This agent is already bound to a website'
ErrAgentWebsiteTypeUnsupported: 'Only proxy, static, or deployment websites can be bound'
ErrAgentWebsiteInUse: 'This website is already bound to another agent'
#backup
Localhost: 'Local'
+3
View File
@@ -54,6 +54,9 @@ ErrAgentComposeRequired: 'Contenido compose requerido'
ErrAgentIDRequired: 'ID de agente requerido'
ErrAgentAccountIDRequired: 'ID de cuenta requerido'
ErrAgentLimitReached: 'La edición Community admite hasta {{ .max }} agentes de IA. Actualiza a la edición Pro para usar agentes ilimitados'
ErrAgentWebsiteBound: 'Este agente ya está vinculado a un sitio web'
ErrAgentWebsiteTypeUnsupported: 'Solo se pueden vincular sitios proxy, estáticos o deployment'
ErrAgentWebsiteInUse: 'Este sitio web ya está vinculado a otro agente'
Localhost: 'Máquina local'
ErrBackupInUsed: 'Cuenta de respaldo en uso por tarea programada'
ErrBackupCheck: 'Conexión de respaldo falló: {{ .err }}'
+3
View File
@@ -54,6 +54,9 @@ ErrAgentComposeRequired: 'Composeが必要です'
ErrAgentIDRequired: 'エージェントIDが必要です'
ErrAgentAccountIDRequired: 'アカウントIDが必要です'
ErrAgentLimitReached: 'Community Edition では AI エージェントを最大 {{ .max }} 件まで作成できます。Pro Edition にアップグレードすると無制限で利用できます'
ErrAgentWebsiteBound: 'このエージェントはすでにサイトに関連付けられています'
ErrAgentWebsiteTypeUnsupported: '関連付けできるのはプロキシサイト、静的サイト、またはデプロイメントサイトのみです'
ErrAgentWebsiteInUse: 'このサイトはすでに別のエージェントに関連付けられています'
Localhost: 'ローカルマシン'
ErrBackupInUsed: 'バックアップアカウントがスケジュールで使用中'
ErrBackupCheck: '接続テストに失敗しました: {{ .err }}'
+3
View File
@@ -54,6 +54,9 @@ ErrAgentComposeRequired: 'Compose 내용 필요'
ErrAgentIDRequired: '에이전트 ID 필요'
ErrAgentAccountIDRequired: '계정 ID 필요'
ErrAgentLimitReached: '커뮤니티 에디션에서는 AI 에이전트를 최대 {{ .max }}개까지 만들 수 있습니다. Pro Edition으로 업그레이드하면 제한 없이 사용할 수 있습니다'
ErrAgentWebsiteBound: '이 에이전트는 이미 웹사이트에 연결되어 있습니다'
ErrAgentWebsiteTypeUnsupported: '프록시, 정적 또는 배포 웹사이트만 연결할 수 있습니다'
ErrAgentWebsiteInUse: '이 웹사이트는 이미 다른 에이전트에 연결되어 있습니다'
Localhost: '로컬 머신'
ErrBackupInUsed: '백업 계정이 예약에 사용 중'
ErrBackupCheck: '연결 테스트 실패: {{ .err }}'
+3
View File
@@ -54,6 +54,9 @@ ErrAgentComposeRequired: 'Kandungan compose diperlukan'
ErrAgentIDRequired: 'ID ejen diperlukan'
ErrAgentAccountIDRequired: 'ID akaun diperlukan'
ErrAgentLimitReached: 'Edisi Community menyokong sehingga {{ .max }} ejen AI. Naik taraf ke Edisi Pro untuk penggunaan tanpa had'
ErrAgentWebsiteBound: 'Ejen ini sudah dipautkan ke laman web'
ErrAgentWebsiteTypeUnsupported: 'Hanya laman web proxy, statik atau deployment boleh dipautkan'
ErrAgentWebsiteInUse: 'Laman web ini sudah dipautkan ke ejen lain'
Localhost: 'Mesin Tempatan'
ErrBackupInUsed: 'Akaun sandaran sedang digunakan oleh tugas'
ErrBackupCheck: 'Ujian sambungan gagal: {{ .err }}'
+3
View File
@@ -54,6 +54,9 @@ ErrAgentComposeRequired: 'Conteúdo de compose obrigatório'
ErrAgentIDRequired: 'ID do agente obrigatório'
ErrAgentAccountIDRequired: 'ID da conta obrigatório'
ErrAgentLimitReached: 'A edição Community suporta até {{ .max }} agentes de IA. Faça upgrade para a edição Pro para usar agentes ilimitados'
ErrAgentWebsiteBound: 'Este agente já está vinculado a um site'
ErrAgentWebsiteTypeUnsupported: 'Somente sites proxy, estáticos ou deployment podem ser vinculados'
ErrAgentWebsiteInUse: 'Este site já está vinculado a outro agente'
Localhost: 'Máquina Local'
ErrBackupInUsed: 'Conta de backup em uso por tarefa'
ErrBackupCheck: 'Teste de conexão falhou: {{ .err }}'
+3
View File
@@ -54,6 +54,9 @@ ErrAgentComposeRequired: 'Нужен compose'
ErrAgentIDRequired: 'Нужен ID агента'
ErrAgentAccountIDRequired: 'Нужен ID аккаунта'
ErrAgentLimitReached: 'Community Edition поддерживает до {{ .max }} AI-агентов. Обновитесь до Pro Edition, чтобы использовать их без ограничений'
ErrAgentWebsiteBound: 'Этот агент уже связан с сайтом'
ErrAgentWebsiteTypeUnsupported: 'Можно связывать только proxy-, static- или deployment-сайты'
ErrAgentWebsiteInUse: 'Этот сайт уже связан с другим агентом'
Localhost: 'Локальная машина'
ErrBackupInUsed: 'Аккаунт бэкапа занят задачей'
ErrBackupCheck: 'Проверка подключения не удалась: {{ .err }}'
+3
View File
@@ -54,6 +54,9 @@ ErrAgentComposeRequired: 'Compose içeriği gerekli'
ErrAgentIDRequired: 'Ajans ID gerekli'
ErrAgentAccountIDRequired: 'Hesap ID gerekli'
ErrAgentLimitReached: 'Community sürümü en fazla {{ .max }} AI ajanını destekler. Sınırsız kullanım için Pro Sürüme yükseltin'
ErrAgentWebsiteBound: 'Bu ajan zaten bir web sitesine bağlı'
ErrAgentWebsiteTypeUnsupported: 'Yalnızca proxy, statik veya deployment web siteleri bağlanabilir'
ErrAgentWebsiteInUse: 'Bu web sitesi zaten başka bir ajana bağlı'
Localhost: 'Yerel Makine'
ErrBackupInUsed: 'Yedek hesabı görevde kullanılıyor'
ErrBackupCheck: 'Bağlantı testi başarısız: {{ .err }}'
+3
View File
@@ -54,6 +54,9 @@ ErrAgentComposeRequired: '自訂編排內容不可為空,請重試'
ErrAgentIDRequired: '智能體 ID 不可為空,請重試'
ErrAgentAccountIDRequired: '帳號 ID 不可為空,請重試'
ErrAgentLimitReached: '社群版最多支援建立 {{ .max }} 個 AI 智能體,升級至 Pro 版即可無限制使用'
ErrAgentWebsiteBound: '該智能體已關聯網站'
ErrAgentWebsiteTypeUnsupported: '只能關聯反向代理、靜態網站或一鍵部署網站'
ErrAgentWebsiteInUse: '該網站已被其他智能體關聯'
Localhost: '本機'
ErrBackupInUsed: '此備份帳號已在排程任務中使用,無法刪除'
ErrBackupCheck: '備份帳號測試連線失敗{{ .err }}'
+3
View File
@@ -59,6 +59,9 @@ ErrAgentComposeRequired: "自定义编排内容不能为空"
ErrAgentIDRequired: "智能体 ID 不能为空"
ErrAgentAccountIDRequired: "账号 ID 不能为空"
ErrAgentLimitReached: "社区版最多支持创建 {{ .max }} 个智能体,升级至专业版无数量限制"
ErrAgentWebsiteBound: "该智能体已关联网站"
ErrAgentWebsiteTypeUnsupported: "只能关联反向代理、静态网站或一键部署网站"
ErrAgentWebsiteInUse: "该网站已被其他智能体关联"
#backup
Localhost: '本机'
+1
View File
@@ -78,6 +78,7 @@ func InitAgentDB() {
migrations.UpdateAgentQuickJumpTitle,
migrations.FixOpenclaw20260323HTTPPort,
migrations.AddAgentRemarkColumn,
migrations.AddAgentWebsiteBinding,
})
if err := m.Migrate(); err != nil {
global.LOG.Error(err)
+36
View File
@@ -1158,3 +1158,39 @@ var AddAgentRemarkColumn = &gormigrate.Migration{
return tx.AutoMigrate(&model.Agent{})
},
}
var AddAgentWebsiteBinding = &gormigrate.Migration{
ID: "20260403-add-agent-website-binding",
Migrate: func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.Agent{}); err != nil {
return err
}
var agents []model.Agent
if err := tx.Find(&agents).Error; err != nil {
return err
}
if len(agents) == 0 {
return nil
}
var websites []model.Website
if err := tx.Where("type = ? AND app_install_id > 0", constant.Deployment).Find(&websites).Error; err != nil {
return err
}
websiteMap := service.UniqueDeploymentWebsiteMapForMigration(websites)
for _, agent := range agents {
if agent.WebsiteID != 0 || agent.AppInstallID == 0 {
continue
}
website, ok := websiteMap[agent.AppInstallID]
if !ok {
continue
}
if err := tx.Model(&model.Agent{}).Where("id = ?", agent.ID).Update("website_id", website.ID).Error; err != nil {
return err
}
}
return nil
},
}
+1
View File
@@ -45,6 +45,7 @@ func (a *AIToolsRouter) InitRouter(Router *gin.RouterGroup) {
aiToolsRouter.POST("/agents/delete", baseApi.DeleteAgent)
aiToolsRouter.POST("/agents/token/reset", baseApi.ResetAgentToken)
aiToolsRouter.POST("/agents/remark", baseApi.UpdateAgentRemark)
aiToolsRouter.POST("/agents/website/bind", baseApi.BindAgentWebsite)
aiToolsRouter.POST("/agents/model/get", baseApi.GetAgentModelConfig)
aiToolsRouter.POST("/agents/model/update", baseApi.UpdateAgentModelConfig)
aiToolsRouter.POST("/agents/overview", baseApi.GetAgentOverview)
+8
View File
@@ -278,6 +278,9 @@ export namespace AI {
status: string;
message: string;
appInstallId: number;
websiteId: number;
websitePrimaryDomain: string;
websiteProtocol: string;
accountId: number;
appVersion: string;
containerName: string;
@@ -304,6 +307,11 @@ export namespace AI {
remark: string;
}
export interface AgentWebsiteBindReq {
agentId: number;
websiteId: number;
}
export interface AgentModelConfigUpdateReq {
agentId: number;
accountId: number;
+6
View File
@@ -138,6 +138,12 @@ export namespace Website {
types?: string[];
}
export interface WebsiteOption {
id: number;
primaryDomain: string;
alias: string;
}
export interface WebSiteLog {
enable: boolean;
content: string;
+4
View File
@@ -113,6 +113,10 @@ export const updateAgentRemark = (req: AI.AgentRemarkUpdateReq) => {
return http.post(`/ai/agents/remark`, req);
};
export const bindAgentWebsite = (req: AI.AgentWebsiteBindReq) => {
return http.post(`/ai/agents/website/bind`, req);
};
export const getAgentModelConfig = (req: AI.AgentIDReq) => {
return http.post<AI.AgentModelConfig>(`/ai/agents/model/get`, req);
};
+1 -1
View File
@@ -41,7 +41,7 @@ export const getWebsite = (id: number) => {
};
export const getWebsiteOptions = (req: Website.OptionReq) => {
return http.post<any>(`/websites/options`, req);
return http.post<Website.WebsiteOption[]>(`/websites/options`, req);
};
export const getWebsiteConfig = (id: number, type: string) => {
+157 -2
View File
@@ -101,6 +101,43 @@
</el-button>
</template>
</el-table-column>
<el-table-column :label="$t('menu.website')" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<div v-if="row.websiteId > 0" class="website-link-cell">
<el-text type="primary" class="cursor-pointer" @click="openWebsite(row)">
{{ getWebsiteDisplayName(row) }}
</el-text>
<el-popover
placement="right"
trigger="hover"
:width="420"
@before-enter="loadWebsiteDomains(row.websiteId)"
>
<template #reference>
<el-button link icon="Promotion" class="ml-2.5"></el-button>
</template>
<table v-if="getWebsiteBaseUrls(row).length > 0">
<tbody>
<tr v-for="url in getWebsiteBaseUrls(row)" :key="url">
<td>
<el-button type="primary" link @click="openWebsiteUrl(url, row)">
{{ url }}
</el-button>
</td>
<td>
<CopyButton :content="url" />
</td>
</tr>
</tbody>
</table>
<el-empty v-else :image-size="48" :description="$t('commons.msg.noneData')" />
</el-popover>
</div>
<el-button v-else link type="primary" @click="openBindWebsite(row)">
{{ $t('commons.button.bind') }}
</el-button>
</template>
</el-table-column>
<el-table-column :label="$t('website.remark')" prop="remark" min-width="150">
<template #default="{ row }">
<fu-read-write-switch>
@@ -155,6 +192,7 @@
<DeleteDialog ref="deleteRef" @close="search" />
<ConfigDrawer ref="configRef" @updated="search" />
<OverviewDrawer ref="overviewRef" />
<BindWebsiteDialog ref="bindWebsiteRef" @success="search" />
<AppUpgrade ref="upgradeRef" @close="search" />
<ComposeLogs ref="composeLogRef" />
<AgentTerminalDialog ref="dialogTerminalRef" />
@@ -166,9 +204,10 @@
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { pageAgents, resetAgentToken, updateAgentRemark } from '@/api/modules/ai';
import { installedOp, searchApp, searchAppInstalled } from '@/api/modules/app';
import { checkAppInstalled, installedOp, searchApp, searchAppInstalled } from '@/api/modules/app';
import { AI } from '@/api/interface/ai';
import { App } from '@/api/interface/app';
import { Website } from '@/api/interface/website';
import { SearchWithPage } from '@/api/interface';
import { dateFormat, newUUID } from '@/utils/util';
import { MsgSuccess } from '@/utils/message';
@@ -177,6 +216,7 @@ import AddDialog from '@/views/ai/agents/agent/add/index.vue';
import DeleteDialog from '@/views/ai/agents/agent/delete/index.vue';
import ConfigDrawer from '@/views/ai/agents/agent/config/index.vue';
import OverviewDrawer from '@/views/ai/agents/agent/components/overview.vue';
import BindWebsiteDialog from '@/views/ai/agents/agent/website/index.vue';
import AppUpgrade from '@/views/app-store/installed/upgrade/index.vue';
import TaskLog from '@/components/log/task/index.vue';
import ComposeLogs from '@/components/log/compose/index.vue';
@@ -186,6 +226,7 @@ import PortJumpDialog from '@/components/port-jump/index.vue';
import DockerStatus from '@/views/container/docker-status/index.vue';
import { getAgentProviderDisplayName, getOpenclawAccessScheme } from '@/utils/agent';
import { routerToFileWithPath } from '@/utils/router';
import { listDomains } from '@/api/modules/website';
import NoApp from '@/views/app-store/apps/no-app/index.vue';
import openclawIcon from '@/assets/images/ai-agent-openclaw.svg';
import copawIcon from '@/assets/images/ai-agent-copaw.svg';
@@ -197,6 +238,7 @@ const taskLogRef = ref();
const deleteRef = ref();
const configRef = ref();
const overviewRef = ref();
const bindWebsiteRef = ref();
const upgradeRef = ref();
const composeLogRef = ref();
const dialogTerminalRef = ref();
@@ -207,6 +249,9 @@ const isActive = ref(false);
const isExist = ref(false);
const noApp = ref(false);
const searchName = ref('');
const defaultHttpsPort = ref(443);
const openrestyPortLoaded = ref(false);
const websiteDomainsMap = ref<Record<number, Website.Domain[]>>({});
const headerButtons = [
{
@@ -290,9 +335,10 @@ const search = async () => {
pageSize: paginationConfig.pageSize,
info: searchName.value || '',
};
const [res] = await Promise.all([pageAgents(req), checkNoApp()]);
const [res] = await Promise.all([pageAgents(req), checkNoApp(), loadOpenrestyHttpsPort()]);
items.value = res.data.items || [];
paginationConfig.total = res.data.total || 0;
await preloadWebsiteDomains(items.value);
} finally {
loading.value = false;
}
@@ -429,6 +475,109 @@ const openOverview = (row: AI.AgentItem) => {
overviewRef.value?.acceptParams(row);
};
const openBindWebsite = (row: AI.AgentItem) => {
bindWebsiteRef.value?.acceptParams(row);
};
const loadOpenrestyHttpsPort = async () => {
if (openrestyPortLoaded.value) {
return;
}
openrestyPortLoaded.value = true;
try {
const res = await checkAppInstalled('openresty', '');
defaultHttpsPort.value = res.data.httpsPort || 443;
} catch {
defaultHttpsPort.value = 443;
}
};
const sortWebsiteDomains = (domains: Website.Domain[]) => {
return [...domains].sort((a, b) => a.id - b.id);
};
const loadWebsiteDomains = async (websiteId: number) => {
if (!websiteId) {
return [];
}
if (websiteDomainsMap.value[websiteId]) {
return websiteDomainsMap.value[websiteId];
}
const res = await listDomains(websiteId);
const domains = sortWebsiteDomains(res.data || []);
websiteDomainsMap.value = {
...websiteDomainsMap.value,
[websiteId]: domains,
};
return domains;
};
const preloadWebsiteDomains = async (rows: AI.AgentItem[]) => {
const websiteIDs = [...new Set(rows.map((row) => row.websiteId).filter((websiteId) => websiteId > 0))];
await Promise.allSettled(websiteIDs.map((websiteId) => loadWebsiteDomains(websiteId)));
};
const formatWebsiteHost = (domain: string) => {
const host = String(domain || '')
.trim()
.replace(/^\[|\]$/g, '');
return host.includes(':') ? `[${host}]` : host;
};
const buildWebsiteBaseUrl = (domain: Website.Domain, row: AI.AgentItem) => {
const protocol = (row.websiteProtocol || 'http').toLowerCase();
let url = `${protocol}://${formatWebsiteHost(domain.domain)}`;
if (protocol === 'http') {
if (domain.port && domain.port !== 80) {
url = `${url}:${domain.port}`;
}
return url;
}
let port = domain.port;
if (!domain.ssl) {
port = defaultHttpsPort.value || 443;
}
if (port && port !== 443) {
url = `${url}:${port}`;
}
return url;
};
const appendWebsiteAgentToken = (url: string, row: AI.AgentItem) => {
if (row.agentType !== 'copaw' && row.token) {
const target = new URL(url);
target.hash = `token=${row.token}`;
return target.toString();
}
return url;
};
const getWebsiteBaseUrls = (row: AI.AgentItem) => {
const domains = websiteDomainsMap.value[row.websiteId] || [];
return domains.map((domain) => buildWebsiteBaseUrl(domain, row));
};
const getWebsiteDisplayName = (row: AI.AgentItem) => {
const domains = websiteDomainsMap.value[row.websiteId] || [];
return domains[0]?.domain || row.websitePrimaryDomain || '-';
};
const openUrl = (url: string) => {
window.open(url);
};
const openWebsiteUrl = (url: string, row: AI.AgentItem) => {
openUrl(appendWebsiteAgentToken(url, row));
};
const openWebsite = async (row: AI.AgentItem) => {
const domains = await loadWebsiteDomains(row.websiteId);
if (domains.length === 0) {
return;
}
openWebsiteUrl(buildWebsiteBaseUrl(domains[0], row), row);
};
const openUpgrade = async (row: AI.AgentItem) => {
const res = await searchAppInstalled({ page: 1, pageSize: 200, name: row.name, update: true });
const appInstall = (res.data.items || []).find((item: App.AppInstallDto) => item.id === row.appInstallId);
@@ -463,4 +612,10 @@ onMounted(async () => {
flex: 0 0 16px;
object-fit: contain;
}
.website-link-cell {
display: inline-flex;
align-items: center;
gap: 10px;
}
</style>
@@ -0,0 +1,99 @@
<template>
<DrawerPro
v-model="open"
:header="$t('commons.button.bind') + $t('menu.website')"
:resource="agentName"
size="736"
@close="handleClose"
>
<el-form ref="formRef" v-loading="loading" label-position="top" :model="form" :rules="rules" @submit.prevent>
<el-form-item :label="$t('menu.website')" prop="websiteId">
<el-select v-model="form.websiteId" clearable filterable>
<el-option
v-for="website in websites"
:key="website.id"
:label="website.primaryDomain"
:value="website.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="open = false" :disabled="loading">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="submit" :disabled="loading">
{{ $t('commons.button.confirm') }}
</el-button>
</template>
</DrawerPro>
</template>
<script setup lang="ts">
import { FormInstance, FormRules } from 'element-plus';
import { reactive, ref } from 'vue';
import { AI } from '@/api/interface/ai';
import { Website } from '@/api/interface/website';
import { bindAgentWebsite } from '@/api/modules/ai';
import { getWebsiteOptions } from '@/api/modules/website';
import { Rules } from '@/global/form-rules';
import { MsgSuccess } from '@/utils/message';
import i18n from '@/lang';
const open = ref(false);
const loading = ref(false);
const agentName = ref('');
const websites = ref<Website.WebsiteOption[]>([]);
const formRef = ref<FormInstance>();
const form = reactive<AI.AgentWebsiteBindReq>({
agentId: 0,
websiteId: 0,
});
const rules = reactive<FormRules>({
websiteId: [Rules.requiredSelectBusiness],
});
const emit = defineEmits<{ (e: 'success'): void }>();
const acceptParams = async (row: AI.AgentItem) => {
form.agentId = row.id;
form.websiteId = 0;
agentName.value = row.name;
open.value = true;
loading.value = true;
try {
const res = await getWebsiteOptions({ types: ['proxy', 'deployment', 'static'] });
websites.value = res.data || [];
form.websiteId = websites.value[0]?.id || 0;
} finally {
loading.value = false;
}
};
const handleClose = () => {
websites.value = [];
form.agentId = 0;
form.websiteId = 0;
agentName.value = '';
};
const submit = async () => {
if (!formRef.value) {
return;
}
await formRef.value.validate();
loading.value = true;
try {
await bindAgentWebsite(form);
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
open.value = false;
emit('success');
} finally {
loading.value = false;
}
};
defineExpose({
acceptParams,
});
</script>