feat: add ai terminal settings (#12215)

This commit is contained in:
ssongliu
2026-03-19 07:25:19 +00:00
committed by GitHub
parent 5bf6d17451
commit c2f0c8c6ac
53 changed files with 2606 additions and 542 deletions
+1
View File
@@ -70,6 +70,7 @@ var (
recycleBinService = service.NewIRecycleBinService()
favoriteService = service.NewIFavoriteService()
hostService = service.NewIHostService()
websiteCAService = service.NewIWebsiteCAService()
taskService = service.NewITaskService()
+167
View File
@@ -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)
}
+22
View File
@@ -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
+29 -3
View File
@@ -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", "")
+6
View File
@@ -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"`
@@ -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"`
+7
View File
@@ -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"`
+12
View File
@@ -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)
+86
View File
@@ -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
@@ -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 {
+57
View File
@@ -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)
+2
View File
@@ -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)
+129
View File
@@ -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
},
}
+10
View File
@@ -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)
+2
View File
@@ -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)
+775
View File
@@ -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 ""
}
@@ -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 ""
}
+198
View File
@@ -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
}
+52
View File
@@ -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
}
+234
View File
@@ -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
}
+13 -3
View File
@@ -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)
+10
View File
@@ -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)
-1
View File
@@ -9,7 +9,6 @@ type ApiGroup struct {
var ApiGroupApp = new(ApiGroup)
var (
hostService = service.NewIHostService()
authService = service.NewIAuthService()
backupService = service.NewIBackupService()
settingService = service.NewISettingService()
-355
View File
@@ -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
}
+30
View File
@@ -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
}
-100
View File
@@ -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
}
+4
View File
@@ -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
-1
View File
@@ -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()
+1 -3
View File
@@ -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":
-1
View File
@@ -30,7 +30,6 @@ var AddTable = &gormigrate.Migration{
&model.Setting{},
&model.BackupAccount{},
&model.Group{},
&model.Host{},
&model.Command{},
&model.UpgradeLog{},
&model.ScriptLibrary{},
-1
View File
@@ -7,7 +7,6 @@ func commonGroups() []CommonRouter {
&LogRouter{},
&SettingRouter{},
&CommandRouter{},
&HostRouter{},
&GroupRouter{},
&ScriptRouter{},
}
-29
View File
@@ -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)
}
}
+6
View File
@@ -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;
+6
View File
@@ -73,6 +73,12 @@ export const updateAgentSetting = (param: Setting.SettingUpdate) => {
export const getAgentSettingInfo = () => {
return http.post<Setting.SettingInfo>(`/settings/search`);
};
export const getAgentTerminalAIInfo = () => {
return http.post<Setting.TerminalAIInfo>(`/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<string>(`/settings/get/${key}`);
};
+9 -9
View File
@@ -5,13 +5,13 @@ import { Base64 } from 'js-base64';
import { deepCopy } from '@/utils/util';
export const searchHosts = (params: Host.SearchWithPage) => {
return http.post<ResPage<Host.Host>>(`/core/hosts/search`, params);
return http.postLocalNode<ResPage<Host.Host>>(`/hosts/search`, params);
};
export const getHostByID = (id: number) => {
return http.post<Host.Host>(`/core/hosts/info`, { id: id });
return http.postLocalNode<Host.Host>(`/hosts/info`, { id: id });
};
export const getHostTree = (params: Host.ReqSearch) => {
return http.post<Array<Host.HostTree>>(`/core/hosts/tree`, params);
return http.postLocalNode<Array<Host.HostTree>>(`/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<Host.HostOperate>(`/core/hosts`, request);
return http.postLocalNode<Host.HostOperate>(`/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<boolean>(`/settings/ssh/check/info`, request);
}
return http.post<boolean>(`/core/hosts/test/byinfo`, request);
return http.postLocalNode<boolean>(`/hosts/test/byinfo`, request);
};
export const testByID = (id: number) => {
return http.post<boolean>(`/core/hosts/test/byid/${id}`);
return http.postLocalNode<boolean>(`/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
+16
View File
@@ -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: {
+15
View File
@@ -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: {
+15
View File
@@ -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: {
+15
View File
@@ -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: {
+15
View File
@@ -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: {
+15
View File
@@ -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: {
+15
View File
@@ -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: {
+15
View File
@@ -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: {
+11
View File
@@ -1304,6 +1304,17 @@ const message = {
cursorBar: '條形',
scrollback: '滾動行數',
scrollSensitivity: '滾動速度',
aiStatus: 'AI 終端',
aiSettings: 'AI 終端設定',
aiAccountHelper: '使用所選模型帳號生成並回填命令OllamavLLM 本地模型請使用自訂模型帳號',
aiPrefix: '觸發前綴',
aiPrefixHelper: '以該前綴開頭並按下 Enter 會觸發 AI 指令生成例如 # //ai',
aiRiskCommands: '風險命令攔截',
aiRiskCommandsHelper: '命中以下片段的生成命令會被攔截並以註解形式回填支援增刪改',
aiAddRiskCommand: '新增風險命令',
aiRemoveRiskCommand: '刪除',
aiSummary: ' {0} 前綴開頭並按下 Enter 會觸發 AI 命令生成',
aiPrefixAsciiVisible: '僅支援 ASCII 可見字元不支援空格中文或全形符號',
saveHelper: '是否確認儲存目前終端設定',
},
toolbox: {
+11
View File
@@ -1313,6 +1313,17 @@ const message = {
cursorBar: '条形',
scrollback: '滚动行数',
scrollSensitivity: '滚动速度',
aiStatus: 'AI 终端',
aiSettings: 'AI 终端设置',
aiAccountHelper: '使用所选模型账号生成并回填命令OllamavLLM 本地模型请用自定义模型账号',
aiPrefix: '触发前缀',
aiPrefixHelper: '以该前缀开头并回车时会触发 AI 命令生成例如 # //ai',
aiRiskCommands: '风险命令拦截',
aiRiskCommandsHelper: '命中以下片段的生成命令会被拦截并以注释形式回填支持增删改',
aiAddRiskCommand: '新增风险命令',
aiRemoveRiskCommand: '删除',
aiSummary: ' {0} 前缀开头并回车时会触发 AI 命令生成',
aiPrefixAsciiVisible: '仅支持 ASCII 可见字符不支持空格中文或全角符号',
saveHelper: '是否确认保存当前终端配置',
},
toolbox: {
+3 -3
View File
@@ -78,10 +78,10 @@
</template>
<script setup lang="ts">
import GroupDialog from '@/components/group/index.vue';
import GroupDialog from '@/components/agent-group/index.vue';
import OperateDialog from '@/views/terminal/host/operate/index.vue';
import { deleteHost, editHostGroup, searchHosts } from '@/api/modules/terminal';
import { getGroupList } from '@/api/modules/group';
import { getAgentGroupList } from '@/api/modules/group';
import { reactive, ref } from 'vue';
import i18n from '@/lang';
import { Host } from '@/api/interface/host';
@@ -152,7 +152,7 @@ const onBatchDelete = async (row: Host.Host | null) => {
};
const loadGroups = async () => {
const res = await getGroupList('host');
const res = await getAgentGroupList('host');
groupList.value = res.data;
};
@@ -86,7 +86,7 @@ import { ref, reactive } from 'vue';
import type { ElForm } from 'element-plus';
import { Rules } from '@/global/form-rules';
import { addHost, editHost, testByInfo } from '@/api/modules/terminal';
import { getGroupList } from '@/api/modules/group';
import { getAgentGroupList } from '@/api/modules/group';
import i18n from '@/lang';
import { MsgError, MsgSuccess } from '@/utils/message';
@@ -126,7 +126,7 @@ const rules = reactive({
});
const loadGroups = async () => {
const res = await getGroupList('host');
const res = await getAgentGroupList('host');
groupList.value = res.data;
if (dialogData.value.title === 'create') {
for (const item of groupList.value) {
@@ -0,0 +1,44 @@
export const DEFAULT_AI_PREFIX = '#';
export const DEFAULT_AI_RISK_COMMANDS = [
'rm -rf',
'mkfs',
'dd if=',
'curl | sh',
'wget | sh',
'chmod -R 777 /',
'shutdown',
'reboot',
'poweroff',
'init 0',
':(){ :|:& };:',
];
export const parseRiskCommands = (value: string): string[] => {
if (!value) {
return [...DEFAULT_AI_RISK_COMMANDS];
}
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
return [...DEFAULT_AI_RISK_COMMANDS];
}
return parsed.map((item) => String(item).trim()).filter((item) => item.length > 0);
} catch {
return [...DEFAULT_AI_RISK_COMMANDS];
}
};
export const normalizeRiskCommands = (riskCommands: string[]): string[] => {
const seen = new Set<string>();
const result: string[] = [];
for (const command of riskCommands) {
const normalized = command.trim();
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
result.push(normalized);
}
return result.length > 0 ? result : [...DEFAULT_AI_RISK_COMMANDS];
};
@@ -0,0 +1,324 @@
<template>
<el-form-item :label="$t('terminal.aiSettings')">
<el-input :value="aiSummary" disabled>
<template #append>
<el-button @click="openDrawer" icon="Setting">
{{ $t('commons.button.set') }}
</el-button>
</template>
</el-input>
</el-form-item>
<DrawerPro v-model="drawerVisible" :header="$t('terminal.aiSettings')" size="60%" @close="handleClose">
<el-form ref="formRef" :model="formModel" label-position="top">
<el-form-item :label="$t('terminal.aiStatus')">
<el-switch v-model="formModel.status" active-value="Enable" inactive-value="Disable" />
</el-form-item>
<el-form-item
:label="$t('aiTools.agents.account')"
prop="accountId"
:rules="accountRules"
v-if="formModel.status === 'Enable'"
>
<el-select class="formInput" v-model="formModel.accountId" clearable filterable>
<el-option
v-for="item in agentAccountOptions"
:key="item.id"
:label="item.name"
:value="String(item.id)"
>
<div class="account-option">
<span class="account-option__name">{{ item.name }}</span>
<div class="account-option__tags">
<el-tag size="small" effect="plain">
{{ item.providerName || item.provider }}
</el-tag>
<el-tag size="small" effect="plain" :type="verificationTagType(item)">
{{ verificationLabel(item) }}
</el-tag>
</div>
</div>
</el-option>
</el-select>
<span class="input-help">{{ $t('terminal.aiAccountHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('terminal.aiPrefix')" prop="prefix" :rules="prefixRules">
<el-input class="formInput" v-model="formModel.prefix" />
<span class="input-help">{{ $t('terminal.aiPrefixHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('terminal.aiRiskCommands')" prop="riskCommands" :rules="riskCommandRules">
<div class="risk-command-list">
<div class="risk-command-item" v-for="(command, index) in formModel.riskCommands" :key="index">
<el-input :model-value="command" @update:model-value="updateRiskCommand(index, $event)" />
<el-button link type="danger" @click="removeRiskCommand(index)">
{{ $t('terminal.aiRemoveRiskCommand') }}
</el-button>
</div>
<div class="risk-command-actions">
<el-button plain @click="addRiskCommand">{{ $t('terminal.aiAddRiskCommand') }}</el-button>
</div>
</div>
<span class="input-help">{{ $t('terminal.aiRiskCommandsHelper') }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="drawerVisible = false" :disabled="saving">{{ $t('commons.button.cancel') }}</el-button>
<el-button plain @click="resetRiskCommands" :disabled="saving">
{{ $t('commons.button.setDefault') }}
</el-button>
<el-button type="primary" @click="handleConfirm" :loading="saving">
{{ $t('commons.button.confirm') }}
</el-button>
</template>
</DrawerPro>
</template>
<script lang="ts" setup>
import { computed, nextTick, reactive, ref, watch } from 'vue';
import type { ElForm } from 'element-plus';
import { Rules } from '@/global/form-rules';
import i18n from '@/lang';
import { pageAgentAccounts } from '@/api/modules/ai';
import { updateAgentTerminalAIInfo } from '@/api/modules/setting';
import { MsgSuccess } from '@/utils/message';
import { DEFAULT_AI_PREFIX, DEFAULT_AI_RISK_COMMANDS, normalizeRiskCommands } from '@/views/terminal/setting/ai/helper';
interface AgentAccountOption {
id: number | string;
name: string;
provider?: string;
providerName?: string;
verified?: boolean;
}
const props = defineProps<{
status: string;
accountId: string;
prefix: string;
riskCommands: string[];
}>();
const emit = defineEmits<{
(e: 'refresh'): void;
}>();
type FormInstance = InstanceType<typeof ElForm>;
const formRef = ref<FormInstance>();
const drawerVisible = ref(false);
const saving = ref(false);
const agentAccountOptions = ref<AgentAccountOption[]>([]);
const formModel = reactive({
status: props.status,
accountId: props.accountId,
prefix: props.prefix,
riskCommands: [...props.riskCommands],
});
const syncFormFromProps = () => {
formModel.status = props.status;
formModel.accountId = props.accountId;
formModel.prefix = props.prefix;
formModel.riskCommands = [...props.riskCommands];
};
const openDrawer = () => {
syncFormFromProps();
loadAgentAccounts();
drawerVisible.value = true;
};
watch(
() => [props.status, props.accountId, props.prefix, props.riskCommands],
() => {
if (drawerVisible.value) {
return;
}
syncFormFromProps();
},
{ deep: true },
);
const aiSummary = computed(() => {
if (props.status !== 'Enable') {
return i18n.global.t('setting.unSetting');
}
const prefix = String(props.prefix || '').trim();
return i18n.global.t('terminal.aiSummary', [prefix]);
});
const isVerificationSkipped = (provider?: string) => {
const key = (provider || '').toLowerCase();
return key === 'custom' || key === 'vllm' || key === 'ollama' || key === 'kimi-coding';
};
const verificationLabel = (item: AgentAccountOption) => {
if (isVerificationSkipped(item.provider)) {
return i18n.global.t('aiTools.agents.verifySkipped');
}
return item.verified ? 'OK' : 'N/A';
};
const verificationTagType = (item: AgentAccountOption) => {
if (isVerificationSkipped(item.provider)) {
return 'info';
}
return item.verified ? 'success' : 'warning';
};
const loadAgentAccounts = async () => {
await pageAgentAccounts({
page: 1,
pageSize: 1000,
provider: '',
name: '',
}).then((res) => {
agentAccountOptions.value = res.data?.items || [];
});
};
const accountRules = [
{
...Rules.requiredSelect,
validator: (_rule, value, callback) => {
if (formModel.status !== 'Enable') {
callback();
return;
}
if (!value) {
callback(new Error(i18n.global.t('commons.rule.requiredSelect')));
return;
}
callback();
},
},
];
const prefixRules = [
Rules.requiredInput,
{
validator: (_rule, value, callback) => {
const normalized = String(value ?? '').trim();
if (!normalized) {
callback(new Error(i18n.global.t('commons.rule.requiredInput')));
return;
}
if (!/^[!-~]+$/.test(normalized)) {
callback(new Error(i18n.global.t('terminal.aiPrefixAsciiVisible')));
return;
}
callback();
},
trigger: 'blur',
},
];
const riskCommandRules = [
{
validator: (_rule, value, callback) => {
const commands = Array.isArray(value) ? value : [];
if (commands.some((item) => String(item ?? '').trim().length === 0)) {
callback(new Error(i18n.global.t('commons.rule.requiredInput')));
return;
}
callback();
},
trigger: 'blur',
},
];
const addRiskCommand = () => {
formModel.riskCommands = [...formModel.riskCommands, ''];
};
const updateRiskCommand = (index: number, value: string) => {
formModel.riskCommands = formModel.riskCommands.map((item, currentIndex) =>
currentIndex === index ? value : item,
);
};
const removeRiskCommand = (index: number) => {
formModel.riskCommands = formModel.riskCommands.filter((_, currentIndex) => currentIndex !== index);
};
const resetRiskCommands = () => {
formModel.prefix = DEFAULT_AI_PREFIX;
formModel.riskCommands = [...DEFAULT_AI_RISK_COMMANDS];
};
const handleConfirm = async () => {
await nextTick();
if (!formRef.value) {
return;
}
try {
await formRef.value.validate();
} catch {
return;
}
saving.value = true;
try {
await updateAgentTerminalAIInfo({
aiStatus: formModel.status,
aiAccountId: formModel.status === 'Enable' ? formModel.accountId : '',
aiPrefix: formModel.prefix.trim() || DEFAULT_AI_PREFIX,
aiRiskCommands: JSON.stringify(normalizeRiskCommands(formModel.riskCommands)),
});
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
drawerVisible.value = false;
} finally {
saving.value = false;
}
};
const handleClose = () => {
syncFormFromProps();
emit('refresh');
};
</script>
<style lang="css" scoped>
.formInput {
width: 100%;
}
.risk-command-list {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.risk-command-actions {
display: flex;
gap: 8px;
}
.risk-command-item {
display: flex;
gap: 8px;
align-items: center;
}
.account-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.account-option__name {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-option__tags {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
</style>
+36 -2
View File
@@ -111,7 +111,19 @@
<el-button @click="search(true)" plain>{{ $t('commons.button.reset') }}</el-button>
<el-button @click="onSave" type="primary">{{ $t('commons.button.save') }}</el-button>
</el-form-item>
<el-divider border-style="dashed" />
<AiSetting
:status="aiForm.aiStatus"
:account-id="aiForm.aiAccountId"
:prefix="aiForm.aiPrefix"
:risk-commands="aiForm.aiRiskCommands"
@refresh="loadAISettings"
/>
<el-divider border-style="dashed" />
<el-form-item :label="$t('terminal.defaultConn')">
<el-switch v-model="form.showDefaultConn" @change="changeShow" />
</el-form-item>
@@ -145,9 +157,11 @@
<script lang="ts" setup>
import { ref, reactive, watch, onMounted, onBeforeUnmount } from 'vue';
import { getTerminalInfo, UpdateTerminalInfo } from '@/api/modules/setting';
import { getAgentTerminalAIInfo, getTerminalInfo, UpdateTerminalInfo } from '@/api/modules/setting';
import { Terminal } from '@xterm/xterm';
import OperateDialog from '@/views/terminal/setting/default_conn/index.vue';
import AiSetting from '@/views/terminal/setting/ai/index.vue';
import { DEFAULT_AI_PREFIX, parseRiskCommands } from '@/views/terminal/setting/ai/helper';
import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit';
import i18n from '@/lang';
@@ -190,10 +204,15 @@ const form = reactive({
cursorStyle: 'underline',
scrollback: 1000,
scrollSensitivity: 10,
showDefaultConn: false,
defaultConn: '',
});
const aiForm = reactive({
aiStatus: 'Disable',
aiAccountId: '',
aiPrefix: '',
aiRiskCommands: [],
});
const resetConn = ref(false);
const opRef = ref();
@@ -231,6 +250,7 @@ watch(
const acceptParams = () => {
search(true);
loadAISettings();
loadConnShow();
iniTerm();
};
@@ -276,6 +296,20 @@ const search = async (withReset?: boolean) => {
});
};
const loadAISettings = async () => {
loading.value = true;
await getAgentTerminalAIInfo()
.then((res) => {
aiForm.aiStatus = res.data.aiStatus || 'Disable';
aiForm.aiAccountId = res.data.aiAccountId || '';
aiForm.aiPrefix = res.data.aiPrefix || DEFAULT_AI_PREFIX;
aiForm.aiRiskCommands = parseRiskCommands(res.data.aiRiskCommands || '');
})
.finally(() => {
loading.value = false;
});
};
const loadConnShow = async () => {
await loadLocalConn().then((res) => {
form.showDefaultConn = res.data.localSSHConnShow === 'Enable';
@@ -77,7 +77,7 @@ import { addHost, editHost, testByInfo } from '@/api/modules/terminal';
import i18n from '@/lang';
import { reactive, ref } from 'vue';
import { MsgError, MsgSuccess } from '@/utils/message';
import { getGroupList } from '@/api/modules/group';
import { getAgentGroupList } from '@/api/modules/group';
const dialogVisible = ref();
const isOK = ref(false);
@@ -128,7 +128,7 @@ const handleClose = () => {
const emit = defineEmits(['on-conn-terminal', 'on-new-local', 'load-host-tree']);
const loadGroups = async () => {
const res = await getGroupList('host');
const res = await getAgentGroupList('host');
groupList.value = res.data;
for (const item of groupList.value) {
if (item.isDefault) {
@@ -513,7 +513,7 @@ const onReconnect = async (item: any) => {
nextTick(() => {
ctx.refs[`t-${item.index}`] &&
ctx.refs[`t-${item.index}`][0].acceptParams({
endpoint: '/api/v2/core/hosts/terminal',
endpoint: '/api/v2/hosts/terminal',
args: `id=${item.wsID}`,
initCmd: initCmd.value,
error: res.data ? '' : 'Failed to set up the connection. Please check the host information',
@@ -536,7 +536,7 @@ const onConnTerminal = async (title: string, wsID: number) => {
nextTick(() => {
ctx.refs[`t-${terminalValue.value}`] &&
ctx.refs[`t-${terminalValue.value}`][0].acceptParams({
endpoint: '/api/v2/core/hosts/terminal',
endpoint: '/api/v2/hosts/terminal',
args: `id=${wsID}`,
initCmd: initCmd.value,
error: res.data ? '' : 'Authentication failed. Please check the host information!',