diff --git a/agent/app/api/v2/entry.go b/agent/app/api/v2/entry.go index cc44b346b..a919fe6f7 100644 --- a/agent/app/api/v2/entry.go +++ b/agent/app/api/v2/entry.go @@ -70,6 +70,7 @@ var ( recycleBinService = service.NewIRecycleBinService() favoriteService = service.NewIFavoriteService() + hostService = service.NewIHostService() websiteCAService = service.NewIWebsiteCAService() taskService = service.NewITaskService() diff --git a/agent/app/api/v2/host.go b/agent/app/api/v2/host.go new file mode 100644 index 000000000..8430bbfad --- /dev/null +++ b/agent/app/api/v2/host.go @@ -0,0 +1,167 @@ +package v2 + +import ( + "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/utils/encrypt" + "github.com/gin-gonic/gin" +) + +func (b *BaseApi) CreateHost(c *gin.Context) { + var req dto.HostOperate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + host, err := hostService.Create(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, host) +} + +func (b *BaseApi) TestByInfo(c *gin.Context) { + var req dto.HostConnTest + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + helper.SuccessWithData(c, hostService.TestByInfo(req)) +} + +func (b *BaseApi) TestByID(c *gin.Context) { + var req dto.OperateByID + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + helper.SuccessWithData(c, hostService.TestLocalConn(req.ID)) +} + +func (b *BaseApi) HostTree(c *gin.Context) { + var req dto.SearchForTree + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + data, err := hostService.SearchForTree(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, data) +} + +func (b *BaseApi) SearchHost(c *gin.Context) { + var req dto.SearchPageWithGroup + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + total, list, err := hostService.SearchWithPage(req) + if err != nil { + helper.InternalServer(c, err) + return + } + + helper.SuccessWithData(c, dto.PageResult{Items: list, Total: total}) +} + +func (b *BaseApi) DeleteHost(c *gin.Context) { + var req dto.OperateByIDs + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + if err := hostService.Delete(req.IDs); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +func (b *BaseApi) UpdateHost(c *gin.Context) { + var req dto.HostOperate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + var err error + if len(req.Password) != 0 && req.AuthMode == "password" { + req.Password, err = hostService.EncryptHost(req.Password) + if err != nil { + helper.BadRequest(c, err) + return + } + req.PrivateKey = "" + req.PassPhrase = "" + } + if len(req.PrivateKey) != 0 && req.AuthMode == "key" { + req.PrivateKey, err = hostService.EncryptHost(req.PrivateKey) + if err != nil { + helper.BadRequest(c, err) + return + } + if len(req.PassPhrase) != 0 { + req.PassPhrase, err = encrypt.StringEncrypt(req.PassPhrase) + if err != nil { + helper.BadRequest(c, err) + return + } + } + req.Password = "" + } + + upMap := map[string]interface{}{ + "name": req.Name, + "group_id": req.GroupID, + "addr": req.Addr, + "port": req.Port, + "user": req.User, + "auth_mode": req.AuthMode, + "remember_password": req.RememberPassword, + "description": req.Description, + } + if req.AuthMode == "password" { + upMap["password"] = req.Password + upMap["private_key"] = "" + upMap["pass_phrase"] = "" + } else { + upMap["password"] = "" + upMap["private_key"] = req.PrivateKey + upMap["pass_phrase"] = req.PassPhrase + } + hostItem, err := hostService.Update(req.ID, upMap) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, hostItem) +} + +func (b *BaseApi) UpdateHostGroup(c *gin.Context) { + var req dto.ChangeGroup + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + if _, err := hostService.Update(req.ID, map[string]interface{}{"group_id": req.GroupID}); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + +func (b *BaseApi) GetHostByID(c *gin.Context) { + var req dto.OperateByID + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + info, err := hostService.GetHostByID(req.ID) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, info) +} diff --git a/agent/app/api/v2/setting.go b/agent/app/api/v2/setting.go index 3609a4ceb..07736102a 100644 --- a/agent/app/api/v2/setting.go +++ b/agent/app/api/v2/setting.go @@ -27,6 +27,15 @@ func (b *BaseApi) GetSettingInfo(c *gin.Context) { helper.SuccessWithData(c, setting) } +func (b *BaseApi) GetTerminalAISettingInfo(c *gin.Context) { + setting, err := settingService.GetTerminalAIInfo() + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, setting) +} + // @Tags System Setting // @Summary Load system available status // @Success 200 @@ -59,6 +68,19 @@ func (b *BaseApi) UpdateSetting(c *gin.Context) { helper.Success(c) } +func (b *BaseApi) UpdateTerminalAISetting(c *gin.Context) { + var req dto.TerminalAIInfo + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + + if err := settingService.UpdateTerminalAI(req); err != nil { + helper.InternalServer(c, err) + return + } + helper.Success(c) +} + // @Tags System Setting // @Summary Load local backup dir // @Success 200 {string} path diff --git a/agent/app/api/v2/terminal.go b/agent/app/api/v2/terminal.go index 16685edac..4e15d80dd 100644 --- a/agent/app/api/v2/terminal.go +++ b/agent/app/api/v2/terminal.go @@ -9,8 +9,10 @@ import ( "time" "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/service" "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/utils/cmd" + "github.com/1Panel-dev/1Panel/agent/utils/ssh" "github.com/1Panel-dev/1Panel/agent/utils/terminal" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" @@ -40,9 +42,33 @@ func (b *BaseApi) WsSSH(c *gin.Context) { return } - client, err := loadLocalConn() - if wshandleError(wsConn, errors.WithMessage(err, "failed to set up the connection. Please check the host information")) { - return + hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0")) + var client *ssh.SSHClient + if hostID > 0 { + host, err := service.GetHostInfo(uint(hostID)) + if wshandleError(wsConn, errors.WithMessage(err, "load host info by id failed")) { + return + } + connInfo := ssh.ConnInfo{ + Addr: host.Addr, + Port: int(host.Port), + User: host.User, + AuthMode: host.AuthMode, + Password: host.Password, + PrivateKey: []byte(host.PrivateKey), + } + if len(host.PassPhrase) != 0 { + connInfo.PassPhrase = []byte(host.PassPhrase) + } + client, err = ssh.NewClient(connInfo) + if wshandleError(wsConn, errors.WithMessage(err, "failed to set up the connection. Please check the host information")) { + return + } + } else { + client, err = loadLocalConn() + if wshandleError(wsConn, errors.WithMessage(err, "failed to set up the connection. Please check the host information")) { + return + } } defer client.Close() command := c.DefaultQuery("command", "") diff --git a/agent/app/dto/common_req.go b/agent/app/dto/common_req.go index 576bb447b..156368ea3 100644 --- a/agent/app/dto/common_req.go +++ b/agent/app/dto/common_req.go @@ -11,6 +11,12 @@ type SearchPageWithType struct { Type string `json:"type"` } +type SearchPageWithGroup struct { + PageInfo + GroupID uint `json:"groupID"` + Info string `json:"info"` +} + type PageInfo struct { Page int `json:"page" validate:"required,number"` PageSize int `json:"pageSize" validate:"required,number"` diff --git a/core/app/dto/host.go b/agent/app/dto/host.go similarity index 93% rename from core/app/dto/host.go rename to agent/app/dto/host.go index 04e3c3f61..8016c9a84 100644 --- a/core/app/dto/host.go +++ b/agent/app/dto/host.go @@ -1,8 +1,6 @@ package dto -import ( - "time" -) +import "time" type HostOperate struct { ID uint `json:"id"` @@ -34,11 +32,6 @@ type SearchForTree struct { Info string `json:"info"` } -type ChangeHostGroup struct { - ID uint `json:"id" validate:"required"` - GroupID uint `json:"groupID" validate:"required"` -} - type HostInfo struct { ID uint `json:"id"` CreatedAt time.Time `json:"createdAt"` diff --git a/agent/app/dto/setting.go b/agent/app/dto/setting.go index 090ef8f47..71f21c1e2 100644 --- a/agent/app/dto/setting.go +++ b/agent/app/dto/setting.go @@ -87,6 +87,13 @@ type SystemProxy struct { Password string `json:"password"` } +type TerminalAIInfo struct { + AIStatus string `json:"aiStatus"` + AIAccountID string `json:"aiAccountId"` + AIPrefix string `json:"aiPrefix"` + AIRiskCommands string `json:"aiRiskCommands"` +} + type CommonDescription struct { ID string `json:"id" validate:"required"` Type string `json:"type" validate:"required"` diff --git a/core/app/model/host.go b/agent/app/model/host.go similarity index 100% rename from core/app/model/host.go rename to agent/app/model/host.go diff --git a/agent/app/repo/common.go b/agent/app/repo/common.go index 766453336..578734501 100644 --- a/agent/app/repo/common.go +++ b/agent/app/repo/common.go @@ -19,6 +19,12 @@ func WithByID(id uint) DBOption { } } +func WithByGroupID(id uint) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("group_id = ?", id) + } +} + func WithByNOTID(id uint) DBOption { return func(g *gorm.DB) *gorm.DB { return g.Where("id != ?", id) @@ -43,6 +49,12 @@ func WithByName(name string) DBOption { } } +func WithByAddr(addr string) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("addr = ?", addr) + } +} + func WithByKey(key string) DBOption { return func(g *gorm.DB) *gorm.DB { return g.Where("key = ?", key) diff --git a/agent/app/repo/host.go b/agent/app/repo/host.go index 374dbcddf..a96a71db5 100644 --- a/agent/app/repo/host.go +++ b/agent/app/repo/host.go @@ -10,6 +10,18 @@ import ( type HostRepo struct{} type IHostRepo interface { + Get(opts ...DBOption) (model.Host, error) + GetList(opts ...DBOption) ([]model.Host, error) + Page(limit, offset int, opts ...DBOption) (int64, []model.Host, error) + Create(host *model.Host) error + Update(id uint, vars map[string]interface{}) error + UpdateGroup(group, newGroup uint) error + Delete(opts ...DBOption) error + + WithByInfo(info string) DBOption + WithByPort(port uint) DBOption + WithByUser(user string) DBOption + GetFirewallRecord(opts ...DBOption) (model.Firewall, error) ListFirewallRecord(opts ...DBOption) ([]model.Firewall, error) SaveFirewallRecord(firewall *model.Firewall) error @@ -30,6 +42,80 @@ func NewIHostRepo() IHostRepo { return &HostRepo{} } +func (h *HostRepo) Get(opts ...DBOption) (model.Host, error) { + var host model.Host + db := global.DB + for _, opt := range opts { + db = opt(db) + } + err := db.First(&host).Error + return host, err +} + +func (h *HostRepo) GetList(opts ...DBOption) ([]model.Host, error) { + var hosts []model.Host + db := global.DB.Model(&model.Host{}) + for _, opt := range opts { + db = opt(db) + } + err := db.Find(&hosts).Error + return hosts, err +} + +func (h *HostRepo) Page(page, size int, opts ...DBOption) (int64, []model.Host, error) { + var hosts []model.Host + db := global.DB.Model(&model.Host{}) + for _, opt := range opts { + db = opt(db) + } + count := int64(0) + db = db.Count(&count) + err := db.Limit(size).Offset(size * (page - 1)).Find(&hosts).Error + return count, hosts, err +} + +func (h *HostRepo) WithByInfo(info string) DBOption { + return func(g *gorm.DB) *gorm.DB { + if len(info) == 0 { + return g + } + infoStr := "%" + info + "%" + return g.Where("name LIKE ? OR addr LIKE ?", infoStr, infoStr) + } +} + +func (h *HostRepo) WithByPort(port uint) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("port = ?", port) + } +} + +func (h *HostRepo) WithByUser(user string) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("user = ?", user) + } +} + +func (h *HostRepo) Create(host *model.Host) error { + return global.DB.Create(host).Error +} + +func (h *HostRepo) Update(id uint, vars map[string]interface{}) error { + return global.DB.Model(&model.Host{}).Where("id = ?", id).Updates(vars).Error +} + +func (h *HostRepo) UpdateGroup(group, newGroup uint) error { + return global.DB.Model(&model.Host{}).Where("group_id = ?", group).Updates(map[string]interface{}{"group_id": newGroup}).Error +} + +func (h *HostRepo) Delete(opts ...DBOption) error { + db := global.DB + for _, opt := range opts { + db = opt(db) + } + return db.Delete(&model.Host{}).Error +} + func (h *HostRepo) GetFirewallRecord(opts ...DBOption) (model.Firewall, error) { var firewall model.Firewall db := global.DB diff --git a/core/app/service/host.go b/agent/app/service/host.go similarity index 94% rename from core/app/service/host.go rename to agent/app/service/host.go index 9a7b15516..8633f59ea 100644 --- a/core/app/service/host.go +++ b/agent/app/service/host.go @@ -4,13 +4,12 @@ import ( "encoding/base64" "fmt" - "github.com/1Panel-dev/1Panel/core/app/dto" - "github.com/1Panel-dev/1Panel/core/app/model" - "github.com/1Panel-dev/1Panel/core/app/repo" - "github.com/1Panel-dev/1Panel/core/buserr" - "github.com/1Panel-dev/1Panel/core/global" - "github.com/1Panel-dev/1Panel/core/utils/encrypt" - "github.com/1Panel-dev/1Panel/core/utils/ssh" + "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/utils/encrypt" + "github.com/1Panel-dev/1Panel/agent/utils/ssh" "github.com/jinzhu/copier" "github.com/pkg/errors" ) @@ -75,11 +74,7 @@ func (u *HostService) TestByInfo(req dto.HostConnTest) bool { } func (u *HostService) TestLocalConn(id uint) bool { - var ( - host model.Host - err error - ) - host, err = hostRepo.Get(repo.WithByID(id)) + host, err := hostRepo.Get(repo.WithByID(id)) if err != nil { return false } @@ -113,12 +108,11 @@ func (u *HostService) TestLocalConn(id uint) bool { return false } defer client.Close() - return true } func (u *HostService) SearchWithPage(req dto.SearchPageWithGroup) (int64, interface{}, error) { - var options []global.DBOption + var options []repo.DBOption if len(req.Info) != 0 { options = append(options, hostRepo.WithByInfo(req.Info)) } @@ -198,8 +192,7 @@ func (u *HostService) SearchForTree(search dto.SearchForTree) ([]dto.HostTree, e func (u *HostService) GetHostByID(id uint) (*dto.HostInfo, error) { var item dto.HostInfo - var host model.Host - host, _ = hostRepo.Get(repo.WithByID(id)) + host, _ := hostRepo.Get(repo.WithByID(id)) if host.ID == 0 { return nil, buserr.New("ErrRecordNotFound") } @@ -336,7 +329,6 @@ func GetHostInfo(id uint) (*model.Host, error) { return nil, err } } - if len(host.PassPhrase) != 0 { host.PassPhrase, err = encrypt.StringDecrypt(host.PassPhrase) if err != nil { diff --git a/agent/app/service/setting.go b/agent/app/service/setting.go index cc45f676e..dbd22a7d4 100644 --- a/agent/app/service/setting.go +++ b/agent/app/service/setting.go @@ -3,6 +3,8 @@ package service import ( "encoding/base64" "encoding/json" + "strconv" + "strings" "time" "github.com/1Panel-dev/1Panel/agent/app/dto" @@ -12,6 +14,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/ssh" + terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai" "github.com/jinzhu/copier" ) @@ -19,7 +22,9 @@ type SettingService struct{} type ISettingService interface { GetSettingInfo() (*dto.SettingInfo, error) + GetTerminalAIInfo() (*dto.TerminalAIInfo, error) Update(key, value string) error + UpdateTerminalAI(req dto.TerminalAIInfo) error TestConnByInfo(req dto.SSHConnData) bool SaveConnInfo(req dto.SSHConnData) error @@ -57,10 +62,62 @@ func (u *SettingService) GetSettingInfo() (*dto.SettingInfo, error) { return &info, err } +func (u *SettingService) GetTerminalAIInfo() (*dto.TerminalAIInfo, error) { + info := &dto.TerminalAIInfo{ + AIStatus: constant.StatusDisable, + AIAccountID: "", + AIPrefix: "#", + AIRiskCommands: "[\"rm -rf\",\"mkfs\",\"dd if=\",\"curl | sh\",\"wget | sh\",\"chmod -R 777 /\",\"shutdown\",\"reboot\",\"poweroff\",\"init 0\",\":(){ :|:& };:\"]", + } + + if value, err := settingRepo.GetValueByKey("AIStatus"); err == nil && value != "" { + info.AIStatus = value + } + if value, err := settingRepo.GetValueByKey("AIAccountID"); err == nil { + info.AIAccountID = value + } + if value, err := settingRepo.GetValueByKey("AIPrefix"); err == nil && value != "" { + info.AIPrefix = value + } + if value, err := settingRepo.GetValueByKey("AIRiskCommands"); err == nil && value != "" { + info.AIRiskCommands = value + } + + return info, nil +} + func (u *SettingService) Update(key, value string) error { return settingRepo.UpdateOrCreate(key, value) } +func (u *SettingService) UpdateTerminalAI(req dto.TerminalAIInfo) error { + if strings.EqualFold(strings.TrimSpace(req.AIStatus), constant.StatusEnable) { + accountID, err := strconv.ParseUint(strings.TrimSpace(req.AIAccountID), 10, 64) + if err != nil || accountID == 0 { + return buserr.New("ErrAgentAccountIDRequired") + } + currentStatus, _ := settingRepo.GetValueByKey("AIStatus") + currentAccountID, _ := settingRepo.GetValueByKey("AIAccountID") + needValidate := !strings.EqualFold(strings.TrimSpace(currentStatus), constant.StatusEnable) || + strings.TrimSpace(currentAccountID) != strings.TrimSpace(req.AIAccountID) + if needValidate { + if err := terminalai.ValidateTerminalAccount(uint(accountID)); err != nil { + return buserr.WithErr("ErrAgentAccountUnavailable", err) + } + } + } + if err := settingRepo.UpdateOrCreate("AIStatus", req.AIStatus); err != nil { + return err + } + if err := settingRepo.UpdateOrCreate("AIAccountID", req.AIAccountID); err != nil { + return err + } + if err := settingRepo.UpdateOrCreate("AIPrefix", req.AIPrefix); err != nil { + return err + } + return settingRepo.UpdateOrCreate("AIRiskCommands", req.AIRiskCommands) +} + func (u *SettingService) TestConnByInfo(req dto.SSHConnData) bool { if req.AuthMode == "password" && len(req.Password) != 0 { password, err := base64.StdEncoding.DecodeString(req.Password) diff --git a/agent/init/migration/migrate.go b/agent/init/migration/migrate.go index 98ba9cd2c..de56374e0 100644 --- a/agent/init/migration/migrate.go +++ b/agent/init/migration/migrate.go @@ -75,6 +75,8 @@ func InitAgentDB() { migrations.NormalizeOllamaAccountAPIType, migrations.RewriteOpenclawBundledCaddyfile, migrations.InitAgentAccountModelPool, + migrations.AddHostTable, + migrations.AddAITerminalSettings, }) if err := m.Migrate(); err != nil { global.LOG.Error(err) diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index f7c7ea080..325106c72 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -52,6 +52,7 @@ var AddTable = &gormigrate.Migration{ &model.DatabasePostgresql{}, &model.Favorite{}, &model.Firewall{}, + &model.Host{}, &model.Ftp{}, &model.ImageRepo{}, &model.ScriptLibrary{}, @@ -1112,3 +1113,131 @@ var InitAgentAccountModelPool = &gormigrate.Migration{ return migrationutils.MigrateAgentAccountModelPool(tx) }, } + +var AddHostTable = &gormigrate.Migration{ + ID: "20260318-add-host-table", + Migrate: func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.Host{}); err != nil { + return err + } + if global.CoreDB == nil || !global.CoreDB.Migrator().HasTable("hosts") { + if err := tx.Create(&model.Group{Name: "Default", Type: "host", IsDefault: true}).Error; err != nil { + return err + } + return nil + } + + var encryptSetting model.Setting + if err := global.CoreDB.Where("key = ?", "EncryptKey").First(&encryptSetting).Error; err != nil { + global.LOG.Errorf("failed to get encrypt key from core db, err: %v", err) + return nil + } + coreEncryptKey := strings.TrimSpace(encryptSetting.Value) + if coreEncryptKey == "" { + global.LOG.Error("encrypt key from core db is empty") + return nil + } + + groupIDMap := make(map[uint]uint) + defaultGroupID := uint(0) + var coreGroups []model.Group + if err := global.CoreDB.Where("type = ?", "host").Order("id asc").Find(&coreGroups).Error; err != nil { + return err + } + for _, coreGroup := range coreGroups { + agentGroup := model.Group{ + Name: coreGroup.Name, + Type: "host", + IsDefault: coreGroup.IsDefault, + } + if agentGroup.IsDefault { + defaultGroupID = coreGroup.ID + } + if err := tx.Create(&agentGroup).Error; err != nil { + global.LOG.Errorf("failed to create group, group id: %v, err: %v", coreGroup.ID, err) + continue + } + groupIDMap[coreGroup.ID] = agentGroup.ID + } + + var coreHosts []model.Host + if err := global.CoreDB.Order("id asc").Find(&coreHosts).Error; err != nil { + return err + } + for _, coreHost := range coreHosts { + password, err := encrypt.StringDecryptWithKey(coreHost.Password, coreEncryptKey) + if err != nil { + global.LOG.Errorf("failed to decrypt host password, host id: %v, err: %v", coreHost.ID, err) + continue + } + privateKey, err := encrypt.StringDecryptWithKey(coreHost.PrivateKey, coreEncryptKey) + if err != nil { + global.LOG.Errorf("failed to decrypt host private key, host id: %v, err: %v", coreHost.ID, err) + continue + } + passPhrase, err := encrypt.StringDecryptWithKey(coreHost.PassPhrase, coreEncryptKey) + if err != nil { + global.LOG.Errorf("failed to decrypt host pass phrase, host id: %v, err: %v", coreHost.ID, err) + continue + } + + encryptedPassword, err := encrypt.StringEncrypt(password) + if err != nil { + global.LOG.Errorf("failed to encrypt host password, host id: %v, err: %v", coreHost.ID, err) + continue + } + encryptedPrivateKey, err := encrypt.StringEncrypt(privateKey) + if err != nil { + global.LOG.Errorf("failed to encrypt host private key, host id: %v, err: %v", coreHost.ID, err) + continue + } + encryptedPassPhrase, err := encrypt.StringEncrypt(passPhrase) + if err != nil { + global.LOG.Errorf("failed to encrypt host pass phrase, host id: %v, err: %v", coreHost.ID, err) + continue + } + + groupID := defaultGroupID + if mappedGroupID, ok := groupIDMap[coreHost.GroupID]; ok && mappedGroupID != 0 { + groupID = mappedGroupID + } + host := model.Host{ + GroupID: groupID, + Name: coreHost.Name, + Addr: coreHost.Addr, + Port: coreHost.Port, + User: coreHost.User, + AuthMode: coreHost.AuthMode, + Password: encryptedPassword, + PrivateKey: encryptedPrivateKey, + PassPhrase: encryptedPassPhrase, + RememberPassword: coreHost.RememberPassword, + Description: coreHost.Description, + } + if err := tx.Create(&host).Error; err != nil { + global.LOG.Errorf("failed to create host, host id: %v, err: %v", coreHost.ID, err) + continue + } + } + return nil + }, +} + +var AddAITerminalSettings = &gormigrate.Migration{ + ID: "20260318-add-ai-terminal-settings", + Migrate: func(tx *gorm.DB) error { + if err := tx.Create(&model.Setting{Key: "AIStatus", Value: constant.StatusDisable}).Error; err != nil { + return err + } + if err := tx.Create(&model.Setting{Key: "AIAccountID", Value: ""}).Error; err != nil { + return err + } + if err := tx.Create(&model.Setting{Key: "AIPrefix", Value: "#"}).Error; err != nil { + return err + } + return tx.Create(&model.Setting{ + Key: "AIRiskCommands", + Value: "[\"rm -rf\",\"mkfs\",\"dd if=\",\"curl | sh\",\"wget | sh\",\"chmod -R 777 /\",\"shutdown\",\"reboot\",\"poweroff\",\"init 0\",\":(){ :|:& };:\"]", + }).Error + }, +} diff --git a/agent/router/ro_host.go b/agent/router/ro_host.go index 2b70bf6c6..bb4446842 100644 --- a/agent/router/ro_host.go +++ b/agent/router/ro_host.go @@ -11,6 +11,16 @@ func (s *HostRouter) InitRouter(Router *gin.RouterGroup) { hostRouter := Router.Group("hosts") baseApi := v2.ApiGroupApp.BaseApi { + hostRouter.POST("", baseApi.CreateHost) + hostRouter.POST("/info", baseApi.GetHostByID) + hostRouter.POST("/del", baseApi.DeleteHost) + hostRouter.POST("/update", baseApi.UpdateHost) + hostRouter.POST("/update/group", baseApi.UpdateHostGroup) + hostRouter.POST("/search", baseApi.SearchHost) + hostRouter.POST("/tree", baseApi.HostTree) + hostRouter.POST("/test/byinfo", baseApi.TestByInfo) + hostRouter.POST("/test/byid", baseApi.TestByID) + hostRouter.POST("/firewall/base", baseApi.LoadFirewallBaseInfo) hostRouter.POST("/firewall/search", baseApi.SearchFirewallRule) hostRouter.POST("/firewall/operate", baseApi.OperateFirewall) diff --git a/agent/router/ro_setting.go b/agent/router/ro_setting.go index fea8ede7e..377719a40 100644 --- a/agent/router/ro_setting.go +++ b/agent/router/ro_setting.go @@ -12,8 +12,10 @@ func (s *SettingRouter) InitRouter(Router *gin.RouterGroup) { baseApi := v2.ApiGroupApp.BaseApi { settingRouter.POST("/search", baseApi.GetSettingInfo) + settingRouter.POST("/terminal/ai/search", baseApi.GetTerminalAISettingInfo) settingRouter.GET("/search/available", baseApi.GetSystemAvailable) settingRouter.POST("/update", baseApi.UpdateSetting) + settingRouter.POST("/terminal/ai/update", baseApi.UpdateTerminalAISetting) settingRouter.GET("/get/:key", baseApi.GetSettingByKey) settingRouter.POST("/description/save", baseApi.SaveDescription) diff --git a/agent/utils/terminal/ai/client.go b/agent/utils/terminal/ai/client.go new file mode 100644 index 000000000..8360df0e5 --- /dev/null +++ b/agent/utils/terminal/ai/client.go @@ -0,0 +1,775 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider" +) + +const ( + defaultTimeout = 30 * time.Second + defaultUserAgent = "1panel-terminal-ai/1.0" + defaultAnthropicToken = 1024 +) + +type Client interface { + ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) +} + +type ClientConfig struct { + Provider string + BaseURL string + APIKey string + Model string + APIType string + MaxTokens int + Timeout time.Duration + HTTPClient *http.Client +} + +type GeneratorConfig struct { + Provider string + BaseURL string + APIKey string + Model string + APIType string + MaxTokens int +} + +type ChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ChatCompletionRequest struct { + Messages []ChatMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` +} + +type ChatCompletionResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Content string `json:"content"` + RawText string `json:"rawText"` + Usage ResponseUsage `json:"usage"` +} + +type ResponseUsage struct { + PromptTokens int `json:"promptTokens"` + CompletionTokens int `json:"completionTokens"` + TotalTokens int `json:"totalTokens"` +} + +type terminalAIClient struct { + config ClientConfig + httpClient *http.Client +} + +func NewClient(cfg ClientConfig) (Client, error) { + providerKey := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if providerKey == "" { + providerKey = "custom" + } + if strings.TrimSpace(cfg.Model) == "" { + return nil, fmt.Errorf("model is required") + } + if strings.TrimSpace(cfg.APIKey) == "" && providerKey != "ollama" { + return nil, fmt.Errorf("api key is required") + } + baseURL := normalizeBaseURL(providerKey, cfg.BaseURL) + if baseURL == "" { + return nil, fmt.Errorf("base url is required") + } + cfg.Provider = providerKey + cfg.BaseURL = baseURL + cfg.APIType = normalizeClientAPIType(providerKey, cfg.APIType) + if cfg.Timeout <= 0 { + cfg.Timeout = defaultTimeout + } + client := cfg.HTTPClient + if client == nil { + client = &http.Client{Timeout: cfg.Timeout} + } + return &terminalAIClient{ + config: cfg, + httpClient: client, + }, nil +} + +type ClientOption func(*ClientConfig) + +func WithBaseURL(baseURL string) ClientOption { + return func(cfg *ClientConfig) { + cfg.BaseURL = baseURL + } +} + +func WithTimeout(timeout time.Duration) ClientOption { + return func(cfg *ClientConfig) { + cfg.Timeout = timeout + } +} + +func WithHTTPClient(client *http.Client) ClientOption { + return func(cfg *ClientConfig) { + cfg.HTTPClient = client + } +} + +func (c *terminalAIClient) ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) { + if len(req.Messages) == 0 { + return nil, fmt.Errorf("messages are required") + } + switch c.config.APIType { + case "anthropic-messages": + return c.chatCompletionAnthropic(ctx, req) + case "gemini-generate-content": + return c.chatCompletionGemini(ctx, req) + case "openai-responses": + return c.chatCompletionOpenAIResponses(ctx, req) + default: + return c.chatCompletionOpenAI(ctx, req) + } +} + +func (c *terminalAIClient) chatCompletionOpenAI(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) { + payload := openAIChatCompletionRequest{ + Model: normalizeModelID(c.config.Model), + Messages: req.Messages, + MaxTokens: firstPositive(req.MaxTokens, c.config.MaxTokens), + Temperature: req.Temperature, + TopP: req.TopP, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildChatCompletionsURL(c.config.BaseURL), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.config.APIKey)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("User-Agent", defaultUserAgent) + + respBody, err := c.do(httpReq) + if err != nil { + return nil, err + } + + var completionResp openAIChatCompletionResponse + if err := json.Unmarshal(respBody, &completionResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + content := extractAssistantContent(completionResp) + return &ChatCompletionResponse{ + ID: completionResp.ID, + Model: firstNonEmptyString(c.config.Model, completionResp.Model), + Content: content, + RawText: strings.TrimSpace(string(respBody)), + Usage: ResponseUsage{ + PromptTokens: completionResp.Usage.PromptTokens, + CompletionTokens: completionResp.Usage.CompletionTokens, + TotalTokens: completionResp.Usage.TotalTokens, + }, + }, nil +} + +func (c *terminalAIClient) chatCompletionOpenAIResponses(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) { + payload := openAIResponsesRequest{ + Model: normalizeModelID(c.config.Model), + Input: toResponsesInput(req.Messages), + MaxOutputTokens: firstPositive(req.MaxTokens, c.config.MaxTokens), + Temperature: req.Temperature, + TopP: req.TopP, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildResponsesURL(c.config.BaseURL), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + if strings.TrimSpace(c.config.APIKey) != "" { + httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.config.APIKey)) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("User-Agent", defaultUserAgent) + + respBody, err := c.do(httpReq) + if err != nil { + return nil, err + } + + var completionResp openAIResponsesResponse + if err := json.Unmarshal(respBody, &completionResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + content := extractResponsesContent(completionResp) + return &ChatCompletionResponse{ + ID: completionResp.ID, + Model: firstNonEmptyString(c.config.Model, completionResp.Model), + Content: content, + RawText: strings.TrimSpace(string(respBody)), + Usage: ResponseUsage{ + PromptTokens: completionResp.Usage.InputTokens, + CompletionTokens: completionResp.Usage.OutputTokens, + TotalTokens: completionResp.Usage.TotalTokens, + }, + }, nil +} + +func (c *terminalAIClient) chatCompletionAnthropic(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) { + system, messages := toAnthropicMessages(req.Messages) + payload := anthropicMessagesRequest{ + Model: normalizeModelID(c.config.Model), + System: system, + Messages: messages, + MaxTokens: firstPositive(req.MaxTokens, c.config.MaxTokens, defaultAnthropicToken), + Temperature: req.Temperature, + TopP: req.TopP, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildAnthropicMessagesURL(c.config.BaseURL), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("x-api-key", strings.TrimSpace(c.config.APIKey)) + httpReq.Header.Set("anthropic-version", "2023-06-01") + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("User-Agent", defaultUserAgent) + + respBody, err := c.do(httpReq) + if err != nil { + return nil, err + } + + var completionResp anthropicMessagesResponse + if err := json.Unmarshal(respBody, &completionResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + content := extractAnthropicContent(completionResp) + return &ChatCompletionResponse{ + ID: completionResp.ID, + Model: firstNonEmptyString(c.config.Model, completionResp.Model), + Content: content, + RawText: strings.TrimSpace(string(respBody)), + Usage: ResponseUsage{ + PromptTokens: completionResp.Usage.InputTokens, + CompletionTokens: completionResp.Usage.OutputTokens, + TotalTokens: completionResp.Usage.InputTokens + completionResp.Usage.OutputTokens, + }, + }, nil +} + +func (c *terminalAIClient) chatCompletionGemini(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) { + payload := geminiGenerateContentRequest{ + Contents: toGeminiContents(req.Messages), + GenerationConfig: &geminiGenerationConfig{ + MaxOutputTokens: firstPositive(req.MaxTokens, c.config.MaxTokens), + Temperature: req.Temperature, + TopP: req.TopP, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, buildGeminiGenerateContentURL(c.config.BaseURL, normalizeModelID(c.config.Model)), bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + httpReq.Header.Set("x-goog-api-key", strings.TrimSpace(c.config.APIKey)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + httpReq.Header.Set("User-Agent", defaultUserAgent) + + respBody, err := c.do(httpReq) + if err != nil { + return nil, err + } + + var completionResp geminiGenerateContentResponse + if err := json.Unmarshal(respBody, &completionResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + content := extractGeminiContent(completionResp) + return &ChatCompletionResponse{ + Model: firstNonEmptyString(c.config.Model, completionResp.ModelVersion), + Content: content, + RawText: strings.TrimSpace(string(respBody)), + Usage: ResponseUsage{ + PromptTokens: completionResp.Usage.PromptTokenCount, + CompletionTokens: completionResp.Usage.CandidatesTokenCount, + TotalTokens: completionResp.Usage.TotalTokenCount, + }, + }, nil +} + +func (c *terminalAIClient) do(httpReq *http.Request) ([]byte, error) { + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if resp.StatusCode >= http.StatusBadRequest { + return nil, parseProviderError(resp.StatusCode, respBody) + } + return respBody, nil +} + +type openAIChatCompletionRequest struct { + Model string `json:"model"` + Messages []ChatMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` +} + +type openAIChatCompletionResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Choices []struct { + Message ChatMessage `json:"message"` + Text string `json:"text"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +type openAIResponsesRequest struct { + Model string `json:"model"` + Input []openAIResponsesInput `json:"input"` + MaxOutputTokens int `json:"max_output_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` +} + +type openAIResponsesInput struct { + Role string `json:"role"` + Content []openAIResponsesInputPart `json:"content"` +} + +type openAIResponsesInputPart struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type openAIResponsesResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Output []struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"output"` + OutputText string `json:"output_text"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` +} + +type anthropicMessagesRequest struct { + Model string `json:"model"` + System string `json:"system,omitempty"` + Messages []anthropicMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` +} + +type anthropicMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type anthropicMessagesResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"usage"` +} + +type geminiGenerateContentRequest struct { + Contents []geminiContent `json:"contents"` + GenerationConfig *geminiGenerationConfig `json:"generationConfig,omitempty"` +} + +type geminiContent struct { + Role string `json:"role,omitempty"` + Parts []geminiContentPart `json:"parts"` +} + +type geminiContentPart struct { + Text string `json:"text"` +} + +type geminiGenerationConfig struct { + MaxOutputTokens int `json:"maxOutputTokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"topP,omitempty"` +} + +type geminiGenerateContentResponse struct { + Candidates []struct { + Content geminiContent `json:"content"` + } `json:"candidates"` + ModelVersion string `json:"modelVersion"` + Usage struct { + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + } `json:"usageMetadata"` +} + +type openAIErrorResponse struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + Code string `json:"code"` + } `json:"error"` +} + +func normalizeBaseURL(provider, rawBaseURL string) string { + baseURL := strings.TrimSpace(rawBaseURL) + if baseURL == "" { + defaultBaseURL, ok := providercatalog.DefaultBaseURL(provider) + if ok { + baseURL = defaultBaseURL + } + } + return strings.TrimRight(baseURL, "/") +} + +func buildChatCompletionsURL(baseURL string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if strings.HasSuffix(baseURL, "/chat/completions") { + return baseURL + } + parsed, err := url.Parse(baseURL) + if err != nil { + return baseURL + "/chat/completions" + } + switch { + case strings.HasSuffix(parsed.Path, "/v1"): + parsed.Path += "/chat/completions" + case strings.HasSuffix(parsed.Path, "/v1beta"): + parsed.Path += "/chat/completions" + default: + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/chat/completions" + } + return strings.TrimRight(parsed.String(), "/") +} + +func buildResponsesURL(baseURL string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if strings.HasSuffix(baseURL, "/responses") { + return baseURL + } + parsed, err := url.Parse(baseURL) + if err != nil { + return baseURL + "/responses" + } + if strings.HasSuffix(parsed.Path, "/v1") { + parsed.Path += "/responses" + } else { + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/responses" + } + return strings.TrimRight(parsed.String(), "/") +} + +func buildAnthropicMessagesURL(baseURL string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if strings.HasSuffix(baseURL, "/messages") { + return baseURL + } + parsed, err := url.Parse(baseURL) + if err != nil { + return baseURL + "/messages" + } + if strings.HasSuffix(parsed.Path, "/v1") { + parsed.Path += "/messages" + } else { + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1/messages" + } + return strings.TrimRight(parsed.String(), "/") +} + +func buildGeminiGenerateContentURL(baseURL, model string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + model = strings.TrimSpace(model) + if model == "" { + model = "gemini-3-flash-preview" + } + parsed, err := url.Parse(baseURL) + if err != nil { + return strings.TrimRight(baseURL, "/") + "/v1beta/models/" + model + ":generateContent" + } + if strings.Contains(parsed.Path, "/models/") && strings.HasSuffix(parsed.Path, ":generateContent") { + return parsed.String() + } + if strings.Contains(parsed.Path, "/v1beta") { + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/models/" + model + ":generateContent" + } else { + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/v1beta/models/" + model + ":generateContent" + } + return parsed.String() +} + +func normalizeModelID(model string) string { + model = strings.TrimSpace(model) + if parts := strings.SplitN(model, "/", 2); len(parts) == 2 { + return parts[1] + } + return model +} + +func normalizeClientAPIType(provider, apiType string) string { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "anthropic", "kimi-coding", "minimax": + return "anthropic-messages" + case "gemini": + return "gemini-generate-content" + case "ollama": + trim := strings.ToLower(strings.TrimSpace(apiType)) + if trim == "openai-completions" { + return trim + } + return "openai-responses" + default: + trim := strings.ToLower(strings.TrimSpace(apiType)) + if trim == "" { + return "openai-completions" + } + return trim + } +} + +func extractAssistantContent(resp openAIChatCompletionResponse) string { + if len(resp.Choices) == 0 { + return "" + } + if content := strings.TrimSpace(resp.Choices[0].Message.Content); content != "" { + return content + } + return strings.TrimSpace(resp.Choices[0].Text) +} + +func extractResponsesContent(resp openAIResponsesResponse) string { + if text := strings.TrimSpace(resp.OutputText); text != "" { + return text + } + for _, item := range resp.Output { + for _, content := range item.Content { + if strings.TrimSpace(content.Text) != "" { + return strings.TrimSpace(content.Text) + } + } + } + return "" +} + +func extractAnthropicContent(resp anthropicMessagesResponse) string { + var parts []string + for _, item := range resp.Content { + if strings.TrimSpace(item.Text) == "" { + continue + } + parts = append(parts, strings.TrimSpace(item.Text)) + } + return strings.TrimSpace(strings.Join(parts, "\n")) +} + +func extractGeminiContent(resp geminiGenerateContentResponse) string { + if len(resp.Candidates) == 0 { + return "" + } + var parts []string + for _, part := range resp.Candidates[0].Content.Parts { + if strings.TrimSpace(part.Text) == "" { + continue + } + parts = append(parts, strings.TrimSpace(part.Text)) + } + return strings.TrimSpace(strings.Join(parts, "\n")) +} + +func toResponsesInput(messages []ChatMessage) []openAIResponsesInput { + result := make([]openAIResponsesInput, 0, len(messages)) + for _, message := range messages { + if strings.TrimSpace(message.Content) == "" { + continue + } + result = append(result, openAIResponsesInput{ + Role: normalizeRole(message.Role), + Content: []openAIResponsesInputPart{{ + Type: "input_text", + Text: message.Content, + }}, + }) + } + return result +} + +func toAnthropicMessages(messages []ChatMessage) (string, []anthropicMessage) { + var systemParts []string + result := make([]anthropicMessage, 0, len(messages)) + for _, message := range messages { + content := strings.TrimSpace(message.Content) + if content == "" { + continue + } + role := normalizeRole(message.Role) + if role == "system" { + systemParts = append(systemParts, content) + continue + } + if role != "assistant" { + role = "user" + } + result = append(result, anthropicMessage{ + Role: role, + Content: content, + }) + } + if len(result) == 0 && len(systemParts) > 0 { + result = append(result, anthropicMessage{ + Role: "user", + Content: "Generate one shell command only.", + }) + } + return strings.Join(systemParts, "\n\n"), result +} + +func toGeminiContents(messages []ChatMessage) []geminiContent { + result := make([]geminiContent, 0, len(messages)) + for _, message := range messages { + content := strings.TrimSpace(message.Content) + if content == "" { + continue + } + role := normalizeRole(message.Role) + if role == "assistant" { + role = "model" + } else { + role = "user" + } + result = append(result, geminiContent{ + Role: role, + Parts: []geminiContentPart{{ + Text: content, + }}, + }) + } + return result +} + +func normalizeRole(role string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case "assistant", "model": + return "assistant" + case "system": + return "system" + default: + return "user" + } +} + +func parseProviderError(statusCode int, body []byte) error { + var errResp openAIErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && strings.TrimSpace(errResp.Error.Message) != "" { + if strings.TrimSpace(errResp.Error.Code) != "" { + return fmt.Errorf("provider returned %d: %s (%s)", statusCode, errResp.Error.Message, errResp.Error.Code) + } + return fmt.Errorf("provider returned %d: %s", statusCode, errResp.Error.Message) + } + + var generic map[string]interface{} + if err := json.Unmarshal(body, &generic); err == nil { + if message := extractErrorMessage(generic); message != "" { + return fmt.Errorf("provider returned %d: %s", statusCode, message) + } + } + + message := strings.TrimSpace(string(body)) + if message == "" { + message = http.StatusText(statusCode) + } + if !utf8.ValidString(message) { + message = http.StatusText(statusCode) + } + return fmt.Errorf("provider returned %d: %s", statusCode, message) +} + +func extractErrorMessage(value map[string]interface{}) string { + if errorValue, ok := value["error"]; ok { + switch typed := errorValue.(type) { + case string: + return strings.TrimSpace(typed) + case map[string]interface{}: + if msg, ok := typed["message"].(string); ok { + return strings.TrimSpace(msg) + } + if msg, ok := typed["status"].(string); ok { + return strings.TrimSpace(msg) + } + } + } + if msg, ok := value["message"].(string); ok { + return strings.TrimSpace(msg) + } + return "" +} + +func firstPositive(values ...int) int { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/agent/utils/terminal/ai/command_generator.go b/agent/utils/terminal/ai/command_generator.go new file mode 100644 index 000000000..c7ea2bd28 --- /dev/null +++ b/agent/utils/terminal/ai/command_generator.go @@ -0,0 +1,172 @@ +package ai + +import ( + "context" + "fmt" + "strings" +) + +type CommandGenerator struct { + client Client +} + +type CommandGenerateRequest struct { + Input string + Shell string + WorkingDir string + OS string + RecentCommands []string + DirectoryHints []string +} + +type CommandGenerateResponse struct { + Command string + Model string + Provider string + RawText string + Usage ResponseUsage +} + +func NewCommandGeneratorFromConfig(cfg GeneratorConfig) (*CommandGenerator, error) { + client, err := NewClient(ClientConfig{ + Provider: cfg.Provider, + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + Model: cfg.Model, + APIType: cfg.APIType, + MaxTokens: cfg.MaxTokens, + }) + if err != nil { + return nil, err + } + return NewCommandGenerator(client) +} + +func NewCommandGenerator(client Client) (*CommandGenerator, error) { + if client == nil { + return nil, fmt.Errorf("client is required") + } + return &CommandGenerator{client: client}, nil +} + +func (g *CommandGenerator) Generate(ctx context.Context, req CommandGenerateRequest) (*CommandGenerateResponse, error) { + if strings.TrimSpace(req.Input) == "" { + return nil, fmt.Errorf("input is required") + } + + resp, err := g.client.ChatCompletion(ctx, ChatCompletionRequest{ + Messages: []ChatMessage{ + {Role: "system", Content: buildCommandSystemPrompt()}, + {Role: "user", Content: buildCommandUserPrompt(req)}, + }, + }) + if err != nil { + return nil, err + } + + command := sanitizeCommand(resp.Content) + if command == "" { + return nil, fmt.Errorf("model returned empty command") + } + + return &CommandGenerateResponse{ + Command: command, + Model: resp.Model, + Provider: providerNameFromModel(resp.Model), + RawText: resp.RawText, + Usage: resp.Usage, + }, nil +} + +func buildCommandSystemPrompt() string { + return strings.Join([]string{ + "You are a shell command generator.", + "Return exactly one command suitable for direct execution in the user's shell.", + "Do not include markdown, code fences, explanations, numbering, comments, or backticks.", + "If multiple commands are required, join them with shell operators in a single line.", + "Prefer safe, non-destructive commands unless the user explicitly asks for destructive behavior.", + "Preserve the user's language when filenames or arguments are ambiguous, but output only the command.", + }, "\n") +} + +func buildCommandUserPrompt(req CommandGenerateRequest) string { + var sections []string + sections = append(sections, "Task:\n"+strings.TrimSpace(req.Input)) + + var env []string + if shell := strings.TrimSpace(req.Shell); shell != "" { + env = append(env, "Shell: "+shell) + } + if wd := strings.TrimSpace(req.WorkingDir); wd != "" { + env = append(env, "Working directory: "+wd) + } + if osName := strings.TrimSpace(req.OS); osName != "" { + env = append(env, "Operating system: "+osName) + } + if len(env) > 0 { + sections = append(sections, "Environment:\n"+strings.Join(env, "\n")) + } + + if block := formatBulletBlock(req.DirectoryHints); block != "" { + sections = append(sections, "Directory hints:\n"+block) + } + if block := formatBulletBlock(req.RecentCommands); block != "" { + sections = append(sections, "Recent commands:\n"+block) + } + + sections = append(sections, "Output requirement:\nReturn one shell command only.") + return strings.Join(sections, "\n\n") +} + +func formatBulletBlock(values []string) string { + var lines []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + lines = append(lines, "- "+value) + } + return strings.Join(lines, "\n") +} + +func sanitizeCommand(raw string) string { + command := strings.TrimSpace(raw) + if command == "" { + return "" + } + command = strings.TrimPrefix(command, "```sh") + command = strings.TrimPrefix(command, "```bash") + command = strings.TrimPrefix(command, "```zsh") + command = strings.TrimPrefix(command, "```shell") + command = strings.TrimPrefix(command, "```") + command = strings.TrimSuffix(command, "```") + command = strings.TrimSpace(command) + + lines := strings.Split(command, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(strings.ToLower(line), "command:") { + line = strings.TrimSpace(line[len("command:"):]) + } + return strings.Trim(line, "` ") + } + return "" +} + +func providerNameFromModel(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if parts := strings.SplitN(model, "/", 2); len(parts) == 2 { + return parts[0] + } + return "" +} diff --git a/agent/utils/terminal/ai/config_runtime.go b/agent/utils/terminal/ai/config_runtime.go new file mode 100644 index 000000000..eb425a66d --- /dev/null +++ b/agent/utils/terminal/ai/config_runtime.go @@ -0,0 +1,198 @@ +package ai + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/model" + providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider" + "github.com/1Panel-dev/1Panel/agent/app/repo" + "github.com/1Panel-dev/1Panel/agent/global" + "gorm.io/gorm" +) + +var agentAccountRepo = repo.NewIAgentAccountRepo() + +type TerminalRuntimeSettings struct { + AccountID uint + Prefix string + RiskCommands []string +} + +var defaultRiskCommands = []string{ + "rm -rf", + "mkfs", + "dd if=", + "curl | sh", + "wget | sh", + "chmod -R 777 /", + "shutdown", + "reboot", + "poweroff", + "init 0", + ":(){ :|:& };:", +} + +func ResolveGeneratorConfig(accountID uint) (GeneratorConfig, time.Duration, error) { + account, err := loadAgentAccount(accountID) + if err != nil { + return GeneratorConfig{}, 0, err + } + + provider := strings.ToLower(strings.TrimSpace(account.Provider)) + if provider == "" { + return GeneratorConfig{}, 0, fmt.Errorf("agent account provider is required") + } + model := strings.TrimSpace(account.Model) + if model == "" { + model = defaultModelForProvider(provider) + } + baseURL := strings.TrimSpace(account.BaseURL) + if baseURL == "" { + if defaultURL, ok := providercatalog.DefaultBaseURL(provider); ok { + baseURL = defaultURL + } + } + apiKey := strings.TrimSpace(account.APIKey) + if apiKey == "" { + apiKey = lookupProviderAPIKey(provider) + } + if apiKey == "" && provider != "ollama" { + return GeneratorConfig{}, 0, fmt.Errorf("agent account api key is required") + } + return GeneratorConfig{ + Provider: provider, + BaseURL: baseURL, + APIKey: strings.TrimSpace(apiKey), + Model: model, + APIType: strings.TrimSpace(account.APIType), + MaxTokens: account.MaxTokens, + }, 30 * time.Second, nil +} + +func lookupProviderAPIKey(provider string) string { + envKey := providercatalog.EnvKey(provider) + if envKey == "" { + return "" + } + return strings.TrimSpace(os.Getenv(envKey)) +} + +func defaultModelForProvider(provider string) string { + meta, ok := providercatalog.Get(provider) + if !ok || len(meta.Models) == 0 { + return "" + } + return meta.Models[0].ID +} + +func ResolveGeneratorConfigFromAgentSettings() (GeneratorConfig, uint, time.Duration, error) { + status, err := loadAgentSettingValue("AIStatus") + if err != nil && !os.IsNotExist(err) { + return GeneratorConfig{}, 0, 0, err + } + if !strings.EqualFold(strings.TrimSpace(status), "Enable") { + return GeneratorConfig{}, 0, 0, os.ErrNotExist + } + accountValue, err := loadAgentSettingValue("AIAccountID") + if err != nil { + return GeneratorConfig{}, 0, 0, err + } + accountID, err := strconv.ParseUint(strings.TrimSpace(accountValue), 10, 64) + if err != nil || accountID == 0 { + return GeneratorConfig{}, 0, 0, os.ErrNotExist + } + config, timeout, err := ResolveGeneratorConfig(uint(accountID)) + return config, uint(accountID), timeout, err +} + +func LoadTerminalRuntimeSettings() (TerminalRuntimeSettings, GeneratorConfig, time.Duration, error) { + config, accountID, timeout, err := ResolveGeneratorConfigFromAgentSettings() + if err != nil { + return TerminalRuntimeSettings{}, GeneratorConfig{}, 0, err + } + prefix, err := loadAgentSettingValue("AIPrefix") + if err != nil && !os.IsNotExist(err) { + return TerminalRuntimeSettings{}, GeneratorConfig{}, 0, err + } + if strings.TrimSpace(prefix) == "" { + prefix = "#" + } + riskCommands, err := loadRiskCommands() + if err != nil { + return TerminalRuntimeSettings{}, GeneratorConfig{}, 0, err + } + return TerminalRuntimeSettings{ + AccountID: accountID, + Prefix: strings.TrimSpace(prefix), + RiskCommands: riskCommands, + }, config, timeout, nil +} + +func loadAgentAccount(accountID uint) (*model.AgentAccount, error) { + if accountID == 0 { + return nil, os.ErrNotExist + } + account, err := agentAccountRepo.GetFirst(repo.WithByID(accountID)) + if err != nil { + return nil, err + } + return account, nil +} + +func loadAgentSettingValue(key string) (string, error) { + var setting model.Setting + if err := global.DB.Where("key = ?", key).First(&setting).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", os.ErrNotExist + } + return "", err + } + return setting.Value, nil +} + +func loadRiskCommands() ([]string, error) { + value, err := loadAgentSettingValue("AIRiskCommands") + if err != nil { + if os.IsNotExist(err) { + return append([]string(nil), defaultRiskCommands...), nil + } + return nil, err + } + if strings.TrimSpace(value) == "" { + return append([]string(nil), defaultRiskCommands...), nil + } + var commands []string + if err := json.Unmarshal([]byte(value), &commands); err != nil { + return nil, err + } + return normalizeRiskCommands(commands), nil +} + +func normalizeRiskCommands(commands []string) []string { + if len(commands) == 0 { + return append([]string(nil), defaultRiskCommands...) + } + seen := make(map[string]struct{}, len(commands)) + result := make([]string, 0, len(commands)) + for _, command := range commands { + command = strings.TrimSpace(command) + if command == "" { + continue + } + if _, ok := seen[command]; ok { + continue + } + seen[command] = struct{}{} + result = append(result, command) + } + if len(result) == 0 { + return append([]string(nil), defaultRiskCommands...) + } + return result +} diff --git a/agent/utils/terminal/ai/validate.go b/agent/utils/terminal/ai/validate.go new file mode 100644 index 000000000..44a702215 --- /dev/null +++ b/agent/utils/terminal/ai/validate.go @@ -0,0 +1,52 @@ +package ai + +import ( + "context" + "fmt" + "strings" + "time" +) + +const validateTimeout = 15 * time.Second + +func ValidateTerminalAccount(accountID uint) error { + if accountID == 0 { + return fmt.Errorf("ai account is required") + } + + cfg, _, err := ResolveGeneratorConfig(accountID) + if err != nil { + return err + } + + client, err := NewClient(ClientConfig{ + Provider: cfg.Provider, + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + Model: cfg.Model, + APIType: cfg.APIType, + MaxTokens: cfg.MaxTokens, + Timeout: validateTimeout, + }) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), validateTimeout) + defer cancel() + + resp, err := client.ChatCompletion(ctx, ChatCompletionRequest{ + Messages: []ChatMessage{ + {Role: "system", Content: "You are a shell command generator. Return exactly one shell command."}, + {Role: "user", Content: "Print current directory."}, + }, + MaxTokens: 16, + }) + if err != nil { + return err + } + if resp == nil || strings.TrimSpace(resp.Content) == "" { + return fmt.Errorf("terminal ai account returned empty response") + } + return nil +} diff --git a/agent/utils/terminal/ai_interceptor.go b/agent/utils/terminal/ai_interceptor.go new file mode 100644 index 000000000..1e0aabf46 --- /dev/null +++ b/agent/utils/terminal/ai_interceptor.go @@ -0,0 +1,234 @@ +package terminal + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" + + "github.com/1Panel-dev/1Panel/agent/global" + terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai" +) + +const lineClearControl = 21 + +type aiInputInterceptor struct { + config terminalai.GeneratorConfig + timeout time.Duration + shell string + prefix string + + mu sync.Mutex + currentLine []byte + recentCommands []string + riskCommands []string + inEscapeSeq bool +} + +func newAIInputInterceptor(shell string) *aiInputInterceptor { + settings, config, timeout, err := terminalai.LoadTerminalRuntimeSettings() + if err != nil { + if !os.IsNotExist(err) { + global.LOG.Warnf("load terminal ai config failed: %v", err) + } + return nil + } + if strings.TrimSpace(config.APIKey) == "" && !strings.EqualFold(strings.TrimSpace(config.Provider), "ollama") { + return nil + } + return &aiInputInterceptor{ + config: config, + timeout: timeout, + shell: strings.TrimSpace(shell), + prefix: settings.Prefix, + riskCommands: append([]string(nil), settings.RiskCommands...), + } +} + +func (i *aiInputInterceptor) refreshSettings() error { + settings, config, timeout, err := terminalai.LoadTerminalRuntimeSettings() + if err != nil { + return err + } + i.mu.Lock() + defer i.mu.Unlock() + i.config = config + i.timeout = timeout + i.prefix = settings.Prefix + i.riskCommands = append([]string(nil), settings.RiskCommands...) + return nil +} + +func (i *aiInputInterceptor) HandleEnter() (string, bool) { + if i == nil { + return "", false + } + if err := i.refreshSettings(); err != nil { + if !os.IsNotExist(err) { + global.LOG.Warnf("refresh terminal ai config failed: %v", err) + } + return "", false + } + i.mu.Lock() + line := sanitizeInputLine(string(i.currentLine)) + i.currentLine = nil + i.inEscapeSeq = false + recentCommands := append([]string(nil), i.recentCommands...) + i.mu.Unlock() + + if !strings.HasPrefix(line, i.prefix) { + if line != "" { + i.pushRecentCommand(line) + } + return "", false + } + prompt := strings.TrimSpace(strings.TrimPrefix(line, i.prefix)) + if prompt == "" { + return "", false + } + + ctx, cancel := context.WithTimeout(context.Background(), i.timeout) + defer cancel() + generator, err := terminalai.NewCommandGeneratorFromConfig(i.config) + if err != nil { + global.LOG.Errorf("create terminal ai generator failed: %v", err) + return "", false + } + resp, err := generator.Generate(ctx, terminalai.CommandGenerateRequest{ + Input: prompt, + Shell: firstNonEmpty(i.shell, filepath.Base(strings.TrimSpace(os.Getenv("SHELL")))), + OS: runtime.GOOS, + RecentCommands: recentCommands, + }) + if err != nil { + global.LOG.Errorf("generate terminal ai command failed: %v", err) + return "", false + } + if i.isRiskCommand(resp.Command) { + return ": # blocked risky command: " + resp.Command, true + } + return resp.Command, strings.TrimSpace(resp.Command) != "" +} + +func (i *aiInputInterceptor) TrackInput(data []byte) { + if i == nil || len(data) == 0 { + return + } + i.mu.Lock() + defer i.mu.Unlock() + for _, b := range data { + if i.inEscapeSeq { + if isEscapeSequenceTerminator(b) { + i.inEscapeSeq = false + } + continue + } + switch b { + case '\r', '\n': + i.currentLine = nil + case 0x08, 0x7f: + i.currentLine = trimLastRuneBytes(i.currentLine) + case lineClearControl: + i.currentLine = nil + case 0x1b: + // Ignore ANSI escape sequences such as arrow keys and bracketed paste markers. + i.inEscapeSeq = true + default: + if b < 0x20 && b != '\t' { + continue + } + i.currentLine = append(i.currentLine, b) + } + } +} + +func (i *aiInputInterceptor) pushRecentCommand(command string) { + if i == nil { + return + } + command = strings.TrimSpace(command) + if command == "" || strings.HasPrefix(command, i.prefix) { + return + } + i.mu.Lock() + defer i.mu.Unlock() + i.recentCommands = append([]string{command}, i.recentCommands...) + if len(i.recentCommands) > 8 { + i.recentCommands = i.recentCommands[:8] + } +} + +func isEnterInput(data []byte) bool { + if len(data) == 1 && (data[0] == '\r' || data[0] == '\n') { + return true + } + return len(data) == 2 && data[0] == '\r' && data[1] == '\n' +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func sanitizeInputLine(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + var builder strings.Builder + builder.Grow(len(raw)) + for _, r := range raw { + switch { + case unicode.IsControl(r) && r != '\t' && r != ' ': + continue + case unicode.In(r, unicode.Cf): + continue + case r == '\u00a0' || r == '\u2007' || r == '\u202f' || r == '\u3000': + builder.WriteRune(' ') + default: + builder.WriteRune(r) + } + } + return strings.TrimSpace(builder.String()) +} + +func trimLastRuneBytes(data []byte) []byte { + if len(data) == 0 { + return data + } + _, size := utf8.DecodeLastRune(data) + if size <= 0 || size > len(data) { + return data[:len(data)-1] + } + return data[:len(data)-size] +} + +func isEscapeSequenceTerminator(b byte) bool { + return b >= 0x40 && b <= 0x7e +} + +func (i *aiInputInterceptor) isRiskCommand(command string) bool { + command = strings.ToLower(strings.TrimSpace(command)) + if command == "" { + return false + } + for _, riskCommand := range i.riskCommands { + riskCommand = strings.ToLower(strings.TrimSpace(riskCommand)) + if riskCommand == "" { + continue + } + if strings.Contains(command, riskCommand) { + return true + } + } + return false +} diff --git a/agent/utils/terminal/ws_local_session.go b/agent/utils/terminal/ws_local_session.go index da7c1ab36..230ece3b6 100644 --- a/agent/utils/terminal/ws_local_session.go +++ b/agent/utils/terminal/ws_local_session.go @@ -14,8 +14,9 @@ type LocalWsSession struct { slave *LocalCommand wsConn *websocket.Conn - allowCtrlC bool - writeMutex sync.Mutex + allowCtrlC bool + writeMutex sync.Mutex + aiInterceptor *aiInputInterceptor } func NewLocalWsSession(cols, rows int, wsConn *websocket.Conn, slave *LocalCommand, allowCtrlC bool) (*LocalWsSession, error) { @@ -27,7 +28,8 @@ func NewLocalWsSession(cols, rows int, wsConn *websocket.Conn, slave *LocalComma slave: slave, wsConn: wsConn, - allowCtrlC: allowCtrlC, + allowCtrlC: allowCtrlC, + aiInterceptor: newAIInputInterceptor(""), }, nil } @@ -108,6 +110,14 @@ func (sws *LocalWsSession) receiveWsMsg(exitCh chan bool) { if err != nil { global.LOG.Errorf("websock cmd string base64 decoding failed, err: %v", err) } + if isEnterInput(decodeBytes) { + if generated, ok := sws.aiInterceptor.HandleEnter(); ok { + sws.sendWebsocketInputCommandToSshSessionStdinPipe(append([]byte{lineClearControl}, []byte(generated)...)) + continue + } + } else { + sws.aiInterceptor.TrackInput(decodeBytes) + } sws.sendWebsocketInputCommandToSshSessionStdinPipe(decodeBytes) case WsMsgHeartbeat: err = wsConn.WriteMessage(websocket.TextMessage, wsData) diff --git a/agent/utils/terminal/ws_session.go b/agent/utils/terminal/ws_session.go index c5fd9dc60..6f6a7c993 100644 --- a/agent/utils/terminal/ws_session.go +++ b/agent/utils/terminal/ws_session.go @@ -57,6 +57,7 @@ type LogicSshWsSession struct { wsConn *websocket.Conn isAdmin bool IsFlagged bool + aiInterceptor *aiInputInterceptor } func NewLogicSshWsSession(cols, rows int, sshClient *ssh.Client, wsConn *websocket.Conn, initCmd string) (*LogicSshWsSession, error) { @@ -100,6 +101,7 @@ func NewLogicSshWsSession(cols, rows int, sshClient *ssh.Client, wsConn *websock wsConn: wsConn, isAdmin: true, IsFlagged: false, + aiInterceptor: newAIInputInterceptor(""), }, nil } @@ -151,6 +153,14 @@ func (sws *LogicSshWsSession) receiveWsMsg(exitCh chan bool) { if err != nil { global.LOG.Errorf("websock cmd string base64 decoding failed, err: %v", err) } + if isEnterInput(decodeBytes) { + if generated, ok := sws.aiInterceptor.HandleEnter(); ok { + sws.sendWebsocketInputCommandToSshSessionStdinPipe(append([]byte{lineClearControl}, []byte(generated)...)) + continue + } + } else { + sws.aiInterceptor.TrackInput(decodeBytes) + } sws.sendWebsocketInputCommandToSshSessionStdinPipe(decodeBytes) case WsMsgHeartbeat: err = wsConn.WriteMessage(websocket.TextMessage, wsData) diff --git a/core/app/api/v2/entry.go b/core/app/api/v2/entry.go index 2c4f9f02c..bc295a333 100644 --- a/core/app/api/v2/entry.go +++ b/core/app/api/v2/entry.go @@ -9,7 +9,6 @@ type ApiGroup struct { var ApiGroupApp = new(ApiGroup) var ( - hostService = service.NewIHostService() authService = service.NewIAuthService() backupService = service.NewIBackupService() settingService = service.NewISettingService() diff --git a/core/app/api/v2/host.go b/core/app/api/v2/host.go deleted file mode 100644 index 621f6d0df..000000000 --- a/core/app/api/v2/host.go +++ /dev/null @@ -1,355 +0,0 @@ -package v2 - -import ( - "encoding/base64" - "encoding/json" - "net/http" - "strconv" - "time" - - "github.com/1Panel-dev/1Panel/core/app/api/v2/helper" - "github.com/1Panel-dev/1Panel/core/app/dto" - "github.com/1Panel-dev/1Panel/core/app/service" - "github.com/1Panel-dev/1Panel/core/global" - "github.com/1Panel-dev/1Panel/core/utils/copier" - "github.com/1Panel-dev/1Panel/core/utils/encrypt" - "github.com/1Panel-dev/1Panel/core/utils/ssh" - "github.com/1Panel-dev/1Panel/core/utils/terminal" - "github.com/gin-gonic/gin" - "github.com/gorilla/websocket" - "github.com/pkg/errors" -) - -// @Tags Host -// @Summary Create host -// @Accept json -// @Param request body dto.HostOperate true "request" -// @Success 200 {object} dto.HostInfo -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts [post] -// @x-panel-log {"bodyKeys":["name","addr"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建主机 [name][addr]","formatEN":"create host [name][addr]"} -func (b *BaseApi) CreateHost(c *gin.Context) { - var req dto.HostOperate - if err := helper.CheckBindAndValidate(&req, c); err != nil { - return - } - - host, err := hostService.Create(req) - if err != nil { - helper.InternalServer(c, err) - return - } - helper.SuccessWithData(c, host) -} - -// @Tags Host -// @Summary Test host conn by info -// @Accept json -// @Param request body dto.HostConnTest true "request" -// @Success 200 {boolean} status -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts/test/byinfo [post] -func (b *BaseApi) TestByInfo(c *gin.Context) { - var req dto.HostConnTest - if err := helper.CheckBindAndValidate(&req, c); err != nil { - return - } - - connStatus := hostService.TestByInfo(req) - helper.SuccessWithData(c, connStatus) -} - -// @Tags Host -// @Summary Test host conn by host id -// @Accept json -// @Param id path integer true "request" -// @Success 200 {boolean} connStatus -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts/test/byid/:id [post] -func (b *BaseApi) TestByID(c *gin.Context) { - idParam, ok := c.Params.Get("id") - if !ok { - helper.BadRequest(c, errors.New("no such params find in request")) - return - } - intNum, err := strconv.Atoi(idParam) - if err != nil { - helper.BadRequest(c, err) - return - } - - connStatus := hostService.TestLocalConn(uint(intNum)) - helper.SuccessWithData(c, connStatus) -} - -// @Tags Host -// @Summary Load host tree -// @Accept json -// @Param request body dto.SearchForTree true "request" -// @Success 200 {array} dto.HostTree -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts/tree [post] -func (b *BaseApi) HostTree(c *gin.Context) { - var req dto.SearchForTree - if err := helper.CheckBindAndValidate(&req, c); err != nil { - return - } - - data, err := hostService.SearchForTree(req) - if err != nil { - helper.InternalServer(c, err) - return - } - - helper.SuccessWithData(c, data) -} - -// @Tags Host -// @Summary Page host -// @Accept json -// @Param request body dto.SearchPageWithGroup true "request" -// @Success 200 {object} dto.PageResult -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts/search [post] -func (b *BaseApi) SearchHost(c *gin.Context) { - var req dto.SearchPageWithGroup - if err := helper.CheckBindAndValidate(&req, c); err != nil { - return - } - - total, list, err := hostService.SearchWithPage(req) - if err != nil { - helper.InternalServer(c, err) - return - } - - helper.SuccessWithData(c, dto.PageResult{ - Items: list, - Total: total, - }) -} - -// @Tags Host -// @Summary Delete host -// @Accept json -// @Param request body dto.OperateByIDs true "request" -// @Success 200 -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts/del [post] -// @x-panel-log {"bodyKeys":["ids"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"ids","isList":true,"db":"hosts","output_column":"addr","output_value":"addrs"}],"formatZH":"删除主机 [addrs]","formatEN":"delete host [addrs]"} -func (b *BaseApi) DeleteHost(c *gin.Context) { - var req dto.OperateByIDs - if err := helper.CheckBindAndValidate(&req, c); err != nil { - return - } - - if err := hostService.Delete(req.IDs); err != nil { - helper.InternalServer(c, err) - return - } - helper.Success(c) -} - -// @Tags Host -// @Summary Update host -// @Accept json -// @Param request body dto.HostOperate true "request" -// @Success 200 {object} dto.HostInfo -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts/update [post] -// @x-panel-log {"bodyKeys":["name","addr"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新主机信息 [name][addr]","formatEN":"update host [name][addr]"} -func (b *BaseApi) UpdateHost(c *gin.Context) { - var req dto.HostOperate - if err := helper.CheckBindAndValidate(&req, c); err != nil { - return - } - - var err error - if len(req.Password) != 0 && req.AuthMode == "password" { - req.Password, err = hostService.EncryptHost(req.Password) - if err != nil { - helper.BadRequest(c, err) - return - } - req.PrivateKey = "" - req.PassPhrase = "" - } - if len(req.PrivateKey) != 0 && req.AuthMode == "key" { - req.PrivateKey, err = hostService.EncryptHost(req.PrivateKey) - if err != nil { - helper.BadRequest(c, err) - return - } - if len(req.PassPhrase) != 0 { - req.PassPhrase, err = encrypt.StringEncrypt(req.PassPhrase) - if err != nil { - helper.BadRequest(c, err) - return - } - } - req.Password = "" - } - - upMap := make(map[string]interface{}) - upMap["name"] = req.Name - upMap["group_id"] = req.GroupID - upMap["addr"] = req.Addr - upMap["port"] = req.Port - upMap["user"] = req.User - upMap["auth_mode"] = req.AuthMode - upMap["remember_password"] = req.RememberPassword - if req.AuthMode == "password" { - upMap["password"] = req.Password - upMap["private_key"] = "" - upMap["pass_phrase"] = "" - } else { - upMap["password"] = "" - upMap["private_key"] = req.PrivateKey - upMap["pass_phrase"] = req.PassPhrase - } - upMap["description"] = req.Description - hostItem, err := hostService.Update(req.ID, upMap) - if err != nil { - helper.InternalServer(c, err) - return - } - helper.SuccessWithData(c, hostItem) -} - -// @Tags Host -// @Summary Update host group -// @Accept json -// @Param request body dto.ChangeHostGroup true "request" -// @Success 200 -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts/update/group [post] -// @x-panel-log {"bodyKeys":["id","group"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"hosts","output_column":"addr","output_value":"addr"}],"formatZH":"切换主机[addr]分组 => [group]","formatEN":"change host [addr] group => [group]"} -func (b *BaseApi) UpdateHostGroup(c *gin.Context) { - var req dto.ChangeHostGroup - if err := helper.CheckBindAndValidate(&req, c); err != nil { - return - } - - upMap := make(map[string]interface{}) - upMap["group_id"] = req.GroupID - if _, err := hostService.Update(req.ID, upMap); err != nil { - helper.InternalServer(c, err) - return - } - helper.Success(c) -} - -// @Tags Host -// @Summary Get host info -// @Accept json -// @Param request body dto.OperateByID true "request" -// @Success 200 {object} dto.HostInfo -// @Security ApiKeyAuth -// @Security Timestamp -// @Router /core/hosts/info [post] -func (b *BaseApi) GetHostByID(c *gin.Context) { - var req dto.OperateByID - if err := helper.CheckBindAndValidate(&req, c); err != nil { - return - } - info, err := hostService.GetHostByID(req.ID) - if err != nil { - helper.InternalServer(c, err) - return - } - helper.SuccessWithData(c, info) -} - -func (b *BaseApi) WsSsh(c *gin.Context) { - wsConn, err := upGrader.Upgrade(c.Writer, c.Request, nil) - if err != nil { - global.LOG.Errorf("gin context http handler failed, err: %v", err) - return - } - defer wsConn.Close() - - if global.CONF.Base.IsDemo { - if wshandleError(wsConn, errors.New(" demo server, prohibit this operation!")) { - return - } - } - - id, err := strconv.Atoi(c.Query("id")) - if wshandleError(wsConn, errors.WithMessage(err, "invalid param id in request")) { - return - } - cols, err := strconv.Atoi(c.DefaultQuery("cols", "80")) - if wshandleError(wsConn, errors.WithMessage(err, "invalid param cols in request")) { - return - } - rows, err := strconv.Atoi(c.DefaultQuery("rows", "40")) - if wshandleError(wsConn, errors.WithMessage(err, "invalid param rows in request")) { - return - } - host, err := service.GetHostInfo(uint(id)) - if wshandleError(wsConn, errors.WithMessage(err, "load host info by id failed")) { - return - } - var connInfo ssh.ConnInfo - _ = copier.Copy(&connInfo, &host) - connInfo.PrivateKey = []byte(host.PrivateKey) - if len(host.PassPhrase) != 0 { - connInfo.PassPhrase = []byte(host.PassPhrase) - } - - client, err := ssh.NewClient(connInfo) - if wshandleError(wsConn, errors.WithMessage(err, "failed to set up the connection. Please check the host information")) { - return - } - defer client.Close() - sws, err := terminal.NewLogicSshWsSession(cols, rows, client.Client, wsConn, "") - if wshandleError(wsConn, err) { - return - } - defer sws.Close() - - quitChan := make(chan bool, 3) - sws.Start(quitChan) - go sws.Wait(quitChan) - - <-quitChan - - dt := time.Now().Add(time.Second) - _ = wsConn.WriteControl(websocket.CloseMessage, nil, dt) -} - -var upGrader = websocket.Upgrader{ - ReadBufferSize: 4096, - WriteBufferSize: 16384, - CheckOrigin: func(r *http.Request) bool { - return true - }, -} - -func wshandleError(ws *websocket.Conn, err error) bool { - if err != nil { - global.LOG.Errorf("handler ws faled:, err: %v", err) - dt := time.Now().Add(time.Second) - if ctlerr := ws.WriteControl(websocket.CloseMessage, []byte(err.Error()), dt); ctlerr != nil { - wsData, err := json.Marshal(terminal.WsMsg{ - Type: terminal.WsMsgCmd, - Data: base64.StdEncoding.EncodeToString([]byte(err.Error())), - }) - if err != nil { - _ = ws.WriteMessage(websocket.TextMessage, []byte("{\"type\":\"cmd\",\"data\":\"failed to encoding to json\"}")) - } else { - _ = ws.WriteMessage(websocket.TextMessage, wsData) - } - } - return true - } - return false -} diff --git a/core/app/api/v2/script_library.go b/core/app/api/v2/script_library.go index a2bda3697..7457323b8 100644 --- a/core/app/api/v2/script_library.go +++ b/core/app/api/v2/script_library.go @@ -1,8 +1,10 @@ package v2 import ( + "encoding/base64" "encoding/json" "fmt" + "net/http" "strconv" "strings" "time" @@ -228,3 +230,31 @@ func (b *BaseApi) RunScript(c *gin.Context) { dt := time.Now().Add(time.Second) _ = wsConn.WriteControl(websocket.CloseMessage, nil, dt) } + +var upGrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 16384, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +func wshandleError(ws *websocket.Conn, err error) bool { + if err != nil { + global.LOG.Errorf("handler ws faled:, err: %v", err) + dt := time.Now().Add(time.Second) + if ctlerr := ws.WriteControl(websocket.CloseMessage, []byte(err.Error()), dt); ctlerr != nil { + wsData, marshalErr := json.Marshal(terminal.WsMsg{ + Type: terminal.WsMsgCmd, + Data: base64.StdEncoding.EncodeToString([]byte(err.Error())), + }) + if marshalErr != nil { + _ = ws.WriteMessage(websocket.TextMessage, []byte("{\"type\":\"cmd\",\"data\":\"failed to encoding to json\"}")) + } else { + _ = ws.WriteMessage(websocket.TextMessage, wsData) + } + } + return true + } + return false +} diff --git a/core/app/repo/host.go b/core/app/repo/host.go deleted file mode 100644 index fc029c866..000000000 --- a/core/app/repo/host.go +++ /dev/null @@ -1,100 +0,0 @@ -package repo - -import ( - "github.com/1Panel-dev/1Panel/core/app/model" - "github.com/1Panel-dev/1Panel/core/global" - "gorm.io/gorm" -) - -type HostRepo struct{} - -type IHostRepo interface { - Get(opts ...global.DBOption) (model.Host, error) - GetList(opts ...global.DBOption) ([]model.Host, error) - Page(limit, offset int, opts ...global.DBOption) (int64, []model.Host, error) - Create(host *model.Host) error - Update(id uint, vars map[string]interface{}) error - UpdateGroup(group, newGroup uint) error - Delete(opts ...global.DBOption) error - - WithByInfo(info string) global.DBOption - WithByPort(port uint) global.DBOption - WithByUser(user string) global.DBOption -} - -func NewIHostRepo() IHostRepo { - return &HostRepo{} -} - -func (h *HostRepo) Get(opts ...global.DBOption) (model.Host, error) { - var host model.Host - db := global.DB - for _, opt := range opts { - db = opt(db) - } - err := db.First(&host).Error - return host, err -} - -func (h *HostRepo) GetList(opts ...global.DBOption) ([]model.Host, error) { - var hosts []model.Host - db := global.DB.Model(&model.Host{}) - for _, opt := range opts { - db = opt(db) - } - err := db.Find(&hosts).Error - return hosts, err -} - -func (h *HostRepo) Page(page, size int, opts ...global.DBOption) (int64, []model.Host, error) { - var users []model.Host - db := global.DB.Model(&model.Host{}) - for _, opt := range opts { - db = opt(db) - } - count := int64(0) - db = db.Count(&count) - err := db.Limit(size).Offset(size * (page - 1)).Find(&users).Error - return count, users, err -} - -func (h *HostRepo) WithByInfo(info string) global.DBOption { - return func(g *gorm.DB) *gorm.DB { - if len(info) == 0 { - return g - } - infoStr := "%" + info + "%" - return g.Where("name LIKE ? OR addr LIKE ?", infoStr, infoStr) - } -} - -func (h *HostRepo) WithByPort(port uint) global.DBOption { - return func(g *gorm.DB) *gorm.DB { - return g.Where("port = ?", port) - } -} -func (h *HostRepo) WithByUser(user string) global.DBOption { - return func(g *gorm.DB) *gorm.DB { - return g.Where("user = ?", user) - } -} - -func (h *HostRepo) Create(host *model.Host) error { - return global.DB.Create(host).Error -} - -func (h *HostRepo) Update(id uint, vars map[string]interface{}) error { - return global.DB.Model(&model.Host{}).Where("id = ?", id).Updates(vars).Error -} - -func (h *HostRepo) UpdateGroup(group, newGroup uint) error { - return global.DB.Model(&model.Host{}).Where("group_id = ?", group).Updates(map[string]interface{}{"group_id": newGroup}).Error -} - -func (h *HostRepo) Delete(opts ...global.DBOption) error { - db := global.DB - for _, opt := range opts { - db = opt(db) - } - return db.Delete(&model.Host{}).Error -} diff --git a/core/app/repo/script_library.go b/core/app/repo/script_library.go index 69960b3bd..1a5d13999 100644 --- a/core/app/repo/script_library.go +++ b/core/app/repo/script_library.go @@ -11,6 +11,7 @@ type IScriptRepo interface { GetList(opts ...global.DBOption) ([]model.ScriptLibrary, error) Create(script *model.ScriptLibrary) error Update(id uint, vars map[string]interface{}) error + UpdateGroup(group, newGroup uint) error Page(limit, offset int, opts ...global.DBOption) (int64, []model.ScriptLibrary, error) Delete(opts ...global.DBOption) error SyncAll(scripts []model.ScriptLibrary) error @@ -63,6 +64,9 @@ func (u *ScriptRepo) Create(ScriptLibrary *model.ScriptLibrary) error { func (u *ScriptRepo) Update(id uint, vars map[string]interface{}) error { return global.DB.Model(&model.ScriptLibrary{}).Where("id = ?", id).Updates(vars).Error } +func (u *ScriptRepo) UpdateGroup(group, newGroup uint) error { + return global.DB.Model(&model.ScriptLibrary{}).Where("group_id = ?", group).Updates(map[string]interface{}{"group_id": newGroup}).Error +} func (u *ScriptRepo) Delete(opts ...global.DBOption) error { db := global.DB diff --git a/core/app/service/entry.go b/core/app/service/entry.go index 51a327954..576336656 100644 --- a/core/app/service/entry.go +++ b/core/app/service/entry.go @@ -3,7 +3,6 @@ package service import "github.com/1Panel-dev/1Panel/core/app/repo" var ( - hostRepo = repo.NewIHostRepo() commandRepo = repo.NewICommandRepo() settingRepo = repo.NewISettingRepo() backupRepo = repo.NewIBackupRepo() diff --git a/core/app/service/group.go b/core/app/service/group.go index 8558c1076..c23495c8b 100644 --- a/core/app/service/group.go +++ b/core/app/service/group.go @@ -100,10 +100,8 @@ func (u *GroupService) Delete(id uint) error { return err } switch group.Type { - case "host": - err = hostRepo.UpdateGroup(id, defaultGroup.ID) case "script": - err = hostRepo.UpdateGroup(id, defaultGroup.ID) + err = scriptRepo.UpdateGroup(id, defaultGroup.ID) case "command": err = commandRepo.UpdateGroup(id, defaultGroup.ID) case "node": diff --git a/core/init/migration/migrations/init.go b/core/init/migration/migrations/init.go index d00d7d424..5856c7a1e 100644 --- a/core/init/migration/migrations/init.go +++ b/core/init/migration/migrations/init.go @@ -30,7 +30,6 @@ var AddTable = &gormigrate.Migration{ &model.Setting{}, &model.BackupAccount{}, &model.Group{}, - &model.Host{}, &model.Command{}, &model.UpgradeLog{}, &model.ScriptLibrary{}, diff --git a/core/router/common.go b/core/router/common.go index a22a1a207..f4180a55f 100644 --- a/core/router/common.go +++ b/core/router/common.go @@ -7,7 +7,6 @@ func commonGroups() []CommonRouter { &LogRouter{}, &SettingRouter{}, &CommandRouter{}, - &HostRouter{}, &GroupRouter{}, &ScriptRouter{}, } diff --git a/core/router/ro_host.go b/core/router/ro_host.go deleted file mode 100644 index 05a6d6ba1..000000000 --- a/core/router/ro_host.go +++ /dev/null @@ -1,29 +0,0 @@ -package router - -import ( - v2 "github.com/1Panel-dev/1Panel/core/app/api/v2" - "github.com/1Panel-dev/1Panel/core/middleware" - "github.com/gin-gonic/gin" -) - -type HostRouter struct{} - -func (s *HostRouter) InitRouter(Router *gin.RouterGroup) { - hostRouter := Router.Group("hosts"). - Use(middleware.SessionAuth()). - Use(middleware.PasswordExpired()) - baseApi := v2.ApiGroupApp.BaseApi - { - hostRouter.POST("", baseApi.CreateHost) - hostRouter.POST("/info", baseApi.GetHostByID) - hostRouter.POST("/del", baseApi.DeleteHost) - hostRouter.POST("/update", baseApi.UpdateHost) - hostRouter.POST("/update/group", baseApi.UpdateHostGroup) - hostRouter.POST("/search", baseApi.SearchHost) - hostRouter.POST("/tree", baseApi.HostTree) - hostRouter.POST("/test/byinfo", baseApi.TestByInfo) - hostRouter.POST("/test/byid/:id", baseApi.TestByID) - - hostRouter.GET("/terminal", baseApi.WsSsh) - } -} diff --git a/frontend/src/api/interface/setting.ts b/frontend/src/api/interface/setting.ts index 0a3d00fdc..ad2d4a036 100644 --- a/frontend/src/api/interface/setting.ts +++ b/frontend/src/api/interface/setting.ts @@ -82,6 +82,12 @@ export namespace Setting { scrollback: string; scrollSensitivity: string; } + export interface TerminalAIInfo { + aiStatus: string; + aiAccountId: string; + aiPrefix: string; + aiRiskCommands: string; + } export interface SettingUpdate { key: string; value: string; diff --git a/frontend/src/api/modules/setting.ts b/frontend/src/api/modules/setting.ts index a62ed3059..0bfc2fceb 100644 --- a/frontend/src/api/modules/setting.ts +++ b/frontend/src/api/modules/setting.ts @@ -73,6 +73,12 @@ export const updateAgentSetting = (param: Setting.SettingUpdate) => { export const getAgentSettingInfo = () => { return http.post(`/settings/search`); }; +export const getAgentTerminalAIInfo = () => { + return http.post(`/settings/terminal/ai/search`); +}; +export const updateAgentTerminalAIInfo = (param: Setting.TerminalAIInfo) => { + return http.post(`/settings/terminal/ai/update`, param); +}; export const getAgentSettingByKey = (key: string) => { return http.get(`/settings/get/${key}`); }; diff --git a/frontend/src/api/modules/terminal.ts b/frontend/src/api/modules/terminal.ts index 306602838..428188828 100644 --- a/frontend/src/api/modules/terminal.ts +++ b/frontend/src/api/modules/terminal.ts @@ -5,13 +5,13 @@ import { Base64 } from 'js-base64'; import { deepCopy } from '@/utils/util'; export const searchHosts = (params: Host.SearchWithPage) => { - return http.post>(`/core/hosts/search`, params); + return http.postLocalNode>(`/hosts/search`, params); }; export const getHostByID = (id: number) => { - return http.post(`/core/hosts/info`, { id: id }); + return http.postLocalNode(`/hosts/info`, { id: id }); }; export const getHostTree = (params: Host.ReqSearch) => { - return http.post>(`/core/hosts/tree`, params); + return http.postLocalNode>(`/hosts/tree`, params); }; export const updateLocalConn = (param: { withReset: boolean; defaultConn: string }) => { return http.post(`/settings/ssh/default`, param); @@ -27,7 +27,7 @@ export const addHost = (params: Host.HostOperate) => { if (params.isLocal) { return http.post(`/settings/ssh`, request); } - return http.post(`/core/hosts`, request); + return http.postLocalNode(`/hosts`, request); }; export const testByInfo = (params: Host.HostConnTest) => { let request = deepCopy(params) as Host.HostOperate; @@ -40,10 +40,10 @@ export const testByInfo = (params: Host.HostConnTest) => { if (params.isLocal) { return http.post(`/settings/ssh/check/info`, request); } - return http.post(`/core/hosts/test/byinfo`, request); + return http.postLocalNode(`/hosts/test/byinfo`, request); }; export const testByID = (id: number) => { - return http.post(`/core/hosts/test/byid/${id}`); + return http.postLocalNode(`/hosts/test/byid`, { id: id }); }; export const editHost = (params: Host.HostOperate) => { let request = deepCopy(params) as Host.HostOperate; @@ -53,13 +53,13 @@ export const editHost = (params: Host.HostOperate) => { if (request.privateKey) { request.privateKey = Base64.encode(request.privateKey); } - return http.post(`/core/hosts/update`, request); + return http.postLocalNode(`/hosts/update`, request); }; export const editHostGroup = (params: Host.GroupChange) => { - return http.post(`/core/hosts/update/group`, params); + return http.postLocalNode(`/hosts/update/group`, params); }; export const deleteHost = (params: { ids: number[] }) => { - return http.post(`/core/hosts/del`, params); + return http.postLocalNode(`/hosts/del`, params); }; // agent diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index c2d4b4638..8e66db8cc 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -1381,6 +1381,22 @@ const message = { cursorBar: 'Bar', scrollback: 'Scrollback', scrollSensitivity: 'Scroll Sensitivity', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'Use the selected model account to generate and fill commands. For local models like Ollama and vLLM, use a custom model account.', + aiAccountRequired: 'Please select an available AI account first.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with the {0} prefix and you press Enter, AI command generation is triggered.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Are you sure you want to save the current terminal configuration?', }, toolbox: { diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 00a857c23..fed373ff0 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -1420,6 +1420,21 @@ const message = { cursorBar: 'Barra', scrollback: 'Scrollback', scrollSensitivity: 'Sensibilidad de scroll', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'Use la cuenta de modelo seleccionada para generar y rellenar comandos. Para modelos locales como Ollama y vLLM, use una cuenta de modelo personalizada.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with the {0} prefix and you press Enter, AI command generation is triggered.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: '¿Está seguro de que desea guardar la configuración actual de la terminal?', }, toolbox: { diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index c24a67a82..372c2ff63 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -1391,6 +1391,21 @@ const message = { cursorBar: 'バー', scrollback: 'スクロールバック', scrollSensitivity: 'スクロール感度', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + '選択したモデルアカウントでコマンドを生成して補完します。Ollama や vLLM などのローカルモデルはカスタムモデルアカウントを使用してください。', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with the {0} prefix and you press Enter, AI command generation is triggered.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: '現在のターミナル設定を保存してもよろしいですか?', }, toolbox: { diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 30cd9e87f..f4a399d4f 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -1365,6 +1365,21 @@ const message = { cursorBar: '막대', scrollback: '스크롤백', scrollSensitivity: '스크롤 감도', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + '선택한 모델 계정으로 명령을 생성하고 채웁니다. Ollama, vLLM 같은 로컬 모델은 사용자 지정 모델 계정을 사용하세요.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with the {0} prefix and you press Enter, AI command generation is triggered.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: '현재 터미널 설정을 저장하시겠습니까?', }, toolbox: { diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 3aaed30a0..dc79e0235 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -1405,6 +1405,21 @@ const message = { cursorBar: 'Bar', scrollback: 'Skrol balik', scrollSensitivity: 'Kepekaan skrol', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'Gunakan akaun model yang dipilih untuk menjana dan mengisi arahan. Untuk model tempatan seperti Ollama dan vLLM, gunakan akaun model tersuai.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with the {0} prefix and you press Enter, AI command generation is triggered.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Adakah anda pasti mahu menyimpan konfigurasi terminal semasa?', }, toolbox: { diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index fc36489d4..d40361cb5 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -1413,6 +1413,21 @@ const message = { cursorBar: 'Barra', scrollback: 'Scrollback', scrollSensitivity: 'Sensibilidade de rolagem', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'Use a conta de modelo selecionada para gerar e preencher comandos. Para modelos locais como Ollama e vLLM, use uma conta de modelo personalizada.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with the {0} prefix and you press Enter, AI command generation is triggered.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Tem certeza de que deseja salvar a configuração atual do terminal?', }, toolbox: { diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index d8a48de7d..3432d7902 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -1398,6 +1398,21 @@ const message = { cursorBar: 'Полоса', scrollback: 'Буфер прокрутки', scrollSensitivity: 'Чувствительность прокрутки', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'Используйте выбранный модельный аккаунт для генерации и подстановки команд. Для локальных моделей, таких как Ollama и vLLM, используйте пользовательский модельный аккаунт.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with the {0} prefix and you press Enter, AI command generation is triggered.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Вы уверены, что хотите сохранить текущую конфигурацию терминала?', }, toolbox: { diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 7f03953f0..7e70532ed 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -1402,6 +1402,21 @@ const message = { cursorBar: 'Çubuk', scrollback: 'Geri Kaydırma', scrollSensitivity: 'Kaydırma Hassasiyeti', + aiStatus: 'AI Terminal', + aiSettings: 'AI Terminal Settings', + aiAccountHelper: + 'Komut üretmek ve doldurmak için seçili model hesabını kullanın. Ollama ve vLLM gibi yerel modeller için özel model hesabı kullanın.', + aiPrefix: 'Trigger Prefix', + aiPrefixHelper: + 'When a line starts with this prefix and you press Enter, AI command generation will be triggered, for example # or //ai.', + aiRiskCommands: 'Risk Command Interception', + aiRiskCommandsHelper: + 'Generated commands matching any of these fragments will be blocked and filled back as comments. Supports add, edit, and delete.', + aiAddRiskCommand: 'Add Risk Command', + aiRemoveRiskCommand: 'Delete', + aiSummary: 'When a line starts with the {0} prefix and you press Enter, AI command generation is triggered.', + aiPrefixAsciiVisible: + 'Only ASCII visible characters are supported. Spaces, CJK characters, and full-width symbols are not allowed.', saveHelper: 'Mevcut terminal yapılandırmasını kaydetmek istediğinizden emin misiniz?', }, toolbox: { diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 1d2a8df84..9800b1eac 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -1304,6 +1304,17 @@ const message = { cursorBar: '條形', scrollback: '滾動行數', scrollSensitivity: '滾動速度', + aiStatus: 'AI 終端', + aiSettings: 'AI 終端設定', + aiAccountHelper: '使用所選模型帳號生成並回填命令。Ollama、vLLM 本地模型請使用自訂模型帳號。', + aiPrefix: '觸發前綴', + aiPrefixHelper: '以該前綴開頭並按下 Enter 時,會觸發 AI 指令生成,例如 # 或 //ai', + aiRiskCommands: '風險命令攔截', + aiRiskCommandsHelper: '命中以下片段的生成命令會被攔截,並以註解形式回填,支援增刪改', + aiAddRiskCommand: '新增風險命令', + aiRemoveRiskCommand: '刪除', + aiSummary: '以 {0} 前綴開頭並按下 Enter 時,會觸發 AI 命令生成', + aiPrefixAsciiVisible: '僅支援 ASCII 可見字元,不支援空格、中文或全形符號', saveHelper: '是否確認儲存目前終端設定?', }, toolbox: { diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 3fc8b4b7c..b835dd327 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -1313,6 +1313,17 @@ const message = { cursorBar: '条形', scrollback: '滚动行数', scrollSensitivity: '滚动速度', + aiStatus: 'AI 终端', + aiSettings: 'AI 终端设置', + aiAccountHelper: '使用所选模型账号生成并回填命令。Ollama、vLLM 本地模型请用自定义模型账号。', + aiPrefix: '触发前缀', + aiPrefixHelper: '以该前缀开头并回车时,会触发 AI 命令生成,例如 # 或 //ai', + aiRiskCommands: '风险命令拦截', + aiRiskCommandsHelper: '命中以下片段的生成命令会被拦截,并以注释形式回填,支持增删改', + aiAddRiskCommand: '新增风险命令', + aiRemoveRiskCommand: '删除', + aiSummary: '以 {0} 前缀开头并回车时,会触发 AI 命令生成', + aiPrefixAsciiVisible: '仅支持 ASCII 可见字符,不支持空格、中文或全角符号', saveHelper: '是否确认保存当前终端配置?', }, toolbox: { diff --git a/frontend/src/views/terminal/host/index.vue b/frontend/src/views/terminal/host/index.vue index 08aad1e7f..999650a94 100644 --- a/frontend/src/views/terminal/host/index.vue +++ b/frontend/src/views/terminal/host/index.vue @@ -78,10 +78,10 @@ + + diff --git a/frontend/src/views/terminal/setting/index.vue b/frontend/src/views/terminal/setting/index.vue index d22c510b3..2fd6e62e6 100644 --- a/frontend/src/views/terminal/setting/index.vue +++ b/frontend/src/views/terminal/setting/index.vue @@ -111,7 +111,19 @@ {{ $t('commons.button.reset') }} {{ $t('commons.button.save') }} + + + + + + @@ -145,9 +157,11 @@