mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 08:00:53 +00:00
feat: add ai terminal settings (#12215)
This commit is contained in:
@@ -70,6 +70,7 @@ var (
|
||||
|
||||
recycleBinService = service.NewIRecycleBinService()
|
||||
favoriteService = service.NewIFavoriteService()
|
||||
hostService = service.NewIHostService()
|
||||
|
||||
websiteCAService = service.NewIWebsiteCAService()
|
||||
taskService = service.NewITaskService()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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"`
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,7 +9,6 @@ type ApiGroup struct {
|
||||
var ApiGroupApp = new(ApiGroup)
|
||||
|
||||
var (
|
||||
hostService = service.NewIHostService()
|
||||
authService = service.NewIAuthService()
|
||||
backupService = service.NewIBackupService()
|
||||
settingService = service.NewISettingService()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -30,7 +30,6 @@ var AddTable = &gormigrate.Migration{
|
||||
&model.Setting{},
|
||||
&model.BackupAccount{},
|
||||
&model.Group{},
|
||||
&model.Host{},
|
||||
&model.Command{},
|
||||
&model.UpgradeLog{},
|
||||
&model.ScriptLibrary{},
|
||||
|
||||
@@ -7,7 +7,6 @@ func commonGroups() []CommonRouter {
|
||||
&LogRouter{},
|
||||
&SettingRouter{},
|
||||
&CommandRouter{},
|
||||
&HostRouter{},
|
||||
&GroupRouter{},
|
||||
&ScriptRouter{},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1304,6 +1304,17 @@ const message = {
|
||||
cursorBar: '條形',
|
||||
scrollback: '滾動行數',
|
||||
scrollSensitivity: '滾動速度',
|
||||
aiStatus: 'AI 終端',
|
||||
aiSettings: 'AI 終端設定',
|
||||
aiAccountHelper: '使用所選模型帳號生成並回填命令。Ollama、vLLM 本地模型請使用自訂模型帳號。',
|
||||
aiPrefix: '觸發前綴',
|
||||
aiPrefixHelper: '以該前綴開頭並按下 Enter 時,會觸發 AI 指令生成,例如 # 或 //ai',
|
||||
aiRiskCommands: '風險命令攔截',
|
||||
aiRiskCommandsHelper: '命中以下片段的生成命令會被攔截,並以註解形式回填,支援增刪改',
|
||||
aiAddRiskCommand: '新增風險命令',
|
||||
aiRemoveRiskCommand: '刪除',
|
||||
aiSummary: '以 {0} 前綴開頭並按下 Enter 時,會觸發 AI 命令生成',
|
||||
aiPrefixAsciiVisible: '僅支援 ASCII 可見字元,不支援空格、中文或全形符號',
|
||||
saveHelper: '是否確認儲存目前終端設定?',
|
||||
},
|
||||
toolbox: {
|
||||
|
||||
@@ -1313,6 +1313,17 @@ const message = {
|
||||
cursorBar: '条形',
|
||||
scrollback: '滚动行数',
|
||||
scrollSensitivity: '滚动速度',
|
||||
aiStatus: 'AI 终端',
|
||||
aiSettings: 'AI 终端设置',
|
||||
aiAccountHelper: '使用所选模型账号生成并回填命令。Ollama、vLLM 本地模型请用自定义模型账号。',
|
||||
aiPrefix: '触发前缀',
|
||||
aiPrefixHelper: '以该前缀开头并回车时,会触发 AI 命令生成,例如 # 或 //ai',
|
||||
aiRiskCommands: '风险命令拦截',
|
||||
aiRiskCommandsHelper: '命中以下片段的生成命令会被拦截,并以注释形式回填,支持增删改',
|
||||
aiAddRiskCommand: '新增风险命令',
|
||||
aiRemoveRiskCommand: '删除',
|
||||
aiSummary: '以 {0} 前缀开头并回车时,会触发 AI 命令生成',
|
||||
aiPrefixAsciiVisible: '仅支持 ASCII 可见字符,不支持空格、中文或全角符号',
|
||||
saveHelper: '是否确认保存当前终端配置?',
|
||||
},
|
||||
toolbox: {
|
||||
|
||||
@@ -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>
|
||||
@@ -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!',
|
||||
|
||||
Reference in New Issue
Block a user