mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-18 14:04:52 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6236e285d | |||
| ad8efffbb4 | |||
| 352d9b712c | |||
| acadbe19c6 | |||
| c265e66afb | |||
| 647bb4b5e4 | |||
| dd311f7a3b |
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.3.13"
|
version: "v1.3.15"
|
||||||
|
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.23.0
|
|||||||
toolchain go1.24.4
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/creack/pty v1.1.24
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/google/uuid v1.5.0
|
github.com/google/uuid v1.5.0
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
|
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZX
|
|||||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
roleHandler.SetSkillsManager(skillsManager) // 设置Skills管理器到RoleHandler
|
roleHandler.SetSkillsManager(skillsManager) // 设置Skills管理器到RoleHandler
|
||||||
skillsHandler := handler.NewSkillsHandler(skillsManager, cfg, configPath, log.Logger)
|
skillsHandler := handler.NewSkillsHandler(skillsManager, cfg, configPath, log.Logger)
|
||||||
fofaHandler := handler.NewFofaHandler(cfg, log.Logger)
|
fofaHandler := handler.NewFofaHandler(cfg, log.Logger)
|
||||||
|
terminalHandler := handler.NewTerminalHandler(log.Logger)
|
||||||
if db != nil {
|
if db != nil {
|
||||||
skillsHandler.SetDB(db) // 设置数据库连接以便获取调用统计
|
skillsHandler.SetDB(db) // 设置数据库连接以便获取调用统计
|
||||||
}
|
}
|
||||||
@@ -431,6 +432,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
roleHandler,
|
roleHandler,
|
||||||
skillsHandler,
|
skillsHandler,
|
||||||
fofaHandler,
|
fofaHandler,
|
||||||
|
terminalHandler,
|
||||||
mcpServer,
|
mcpServer,
|
||||||
authManager,
|
authManager,
|
||||||
openAPIHandler,
|
openAPIHandler,
|
||||||
@@ -542,6 +544,7 @@ func setupRoutes(
|
|||||||
roleHandler *handler.RoleHandler,
|
roleHandler *handler.RoleHandler,
|
||||||
skillsHandler *handler.SkillsHandler,
|
skillsHandler *handler.SkillsHandler,
|
||||||
fofaHandler *handler.FofaHandler,
|
fofaHandler *handler.FofaHandler,
|
||||||
|
terminalHandler *handler.TerminalHandler,
|
||||||
mcpServer *mcp.Server,
|
mcpServer *mcp.Server,
|
||||||
authManager *security.AuthManager,
|
authManager *security.AuthManager,
|
||||||
openAPIHandler *handler.OpenAPIHandler,
|
openAPIHandler *handler.OpenAPIHandler,
|
||||||
@@ -628,6 +631,10 @@ func setupRoutes(
|
|||||||
protected.PUT("/config", configHandler.UpdateConfig)
|
protected.PUT("/config", configHandler.UpdateConfig)
|
||||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||||
|
|
||||||
|
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||||
|
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||||
|
protected.POST("/terminal/run/stream", terminalHandler.RunCommandStream)
|
||||||
|
|
||||||
// 外部MCP管理
|
// 外部MCP管理
|
||||||
protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs)
|
protected.GET("/external-mcp", externalMCPHandler.GetExternalMCPs)
|
||||||
protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats)
|
protected.GET("/external-mcp/stats", externalMCPHandler.GetExternalMCPStats)
|
||||||
|
|||||||
+194
-7
@@ -2,10 +2,14 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -108,11 +112,159 @@ func (h *AgentHandler) SetSkillsManager(manager *skills.Manager) {
|
|||||||
h.skillsManager = manager
|
h.skillsManager = manager
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatAttachment 聊天附件(用户上传的文件)
|
||||||
|
type ChatAttachment struct {
|
||||||
|
FileName string `json:"fileName"` // 文件名
|
||||||
|
Content string `json:"content"` // 文本内容或 base64(由 MimeType 决定是否解码)
|
||||||
|
MimeType string `json:"mimeType,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChatRequest 聊天请求
|
// ChatRequest 聊天请求
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Message string `json:"message" binding:"required"`
|
Message string `json:"message" binding:"required"`
|
||||||
ConversationID string `json:"conversationId,omitempty"`
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
Role string `json:"role,omitempty"` // 角色名称
|
Role string `json:"role,omitempty"` // 角色名称
|
||||||
|
Attachments []ChatAttachment `json:"attachments,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxAttachments = 10
|
||||||
|
maxAttachmentBytes = 2 * 1024 * 1024 // 单文件约 2MB(仅用于是否内联展示内容,不限制上传)
|
||||||
|
chatUploadsDirName = "chat_uploads" // 对话附件保存的根目录(相对当前工作目录)
|
||||||
|
)
|
||||||
|
|
||||||
|
// saveAttachmentsToDateAndConversationDir 将附件保存到 chat_uploads/YYYY-MM-DD/{conversationID}/,返回每个文件的保存路径(与 attachments 顺序一致)
|
||||||
|
// conversationID 为空时使用 "_new" 作为目录名(新对话尚未有 ID)
|
||||||
|
func saveAttachmentsToDateAndConversationDir(attachments []ChatAttachment, conversationID string, logger *zap.Logger) (savedPaths []string, err error) {
|
||||||
|
if len(attachments) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("获取当前工作目录失败: %w", err)
|
||||||
|
}
|
||||||
|
dateDir := filepath.Join(cwd, chatUploadsDirName, time.Now().Format("2006-01-02"))
|
||||||
|
convDirName := strings.TrimSpace(conversationID)
|
||||||
|
if convDirName == "" {
|
||||||
|
convDirName = "_new"
|
||||||
|
} else {
|
||||||
|
convDirName = strings.ReplaceAll(convDirName, string(filepath.Separator), "_")
|
||||||
|
}
|
||||||
|
targetDir := filepath.Join(dateDir, convDirName)
|
||||||
|
if err = os.MkdirAll(targetDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建上传目录失败: %w", err)
|
||||||
|
}
|
||||||
|
savedPaths = make([]string, 0, len(attachments))
|
||||||
|
for i, a := range attachments {
|
||||||
|
raw, decErr := attachmentContentToBytes(a)
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, fmt.Errorf("附件 %s 解码失败: %w", a.FileName, decErr)
|
||||||
|
}
|
||||||
|
baseName := filepath.Base(a.FileName)
|
||||||
|
if baseName == "" || baseName == "." {
|
||||||
|
baseName = "file"
|
||||||
|
}
|
||||||
|
baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
|
||||||
|
ext := filepath.Ext(baseName)
|
||||||
|
nameNoExt := strings.TrimSuffix(baseName, ext)
|
||||||
|
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), shortRand(6))
|
||||||
|
var unique string
|
||||||
|
if ext != "" {
|
||||||
|
unique = nameNoExt + suffix + ext
|
||||||
|
} else {
|
||||||
|
unique = baseName + suffix
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(targetDir, unique)
|
||||||
|
if err = os.WriteFile(fullPath, raw, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("写入文件 %s 失败: %w", a.FileName, err)
|
||||||
|
}
|
||||||
|
absPath, _ := filepath.Abs(fullPath)
|
||||||
|
savedPaths = append(savedPaths, absPath)
|
||||||
|
if logger != nil {
|
||||||
|
logger.Debug("对话附件已保存", zap.Int("index", i+1), zap.String("fileName", a.FileName), zap.String("path", absPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return savedPaths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortRand(n int) string {
|
||||||
|
const letters = "0123456789abcdef"
|
||||||
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letters[int(b[i])%len(letters)]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachmentContentToBytes(a ChatAttachment) ([]byte, error) {
|
||||||
|
content := a.Content
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil && len(decoded) > 0 {
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
return []byte(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userMessageContentForStorage 返回要存入数据库的用户消息内容:有附件时在正文后追加附件名(及路径),刷新后仍能显示,继续对话时大模型也能从历史中拿到路径
|
||||||
|
func userMessageContentForStorage(message string, attachments []ChatAttachment, savedPaths []string) string {
|
||||||
|
if len(attachments) == 0 {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(message)
|
||||||
|
for i, a := range attachments {
|
||||||
|
b.WriteString("\n📎 ")
|
||||||
|
b.WriteString(a.FileName)
|
||||||
|
if i < len(savedPaths) && savedPaths[i] != "" {
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(savedPaths[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendAttachmentsToMessage 将附件内容拼接到用户消息末尾;若 savedPaths 与 attachments 一一对应,会先写入“已保存到”路径供大模型按路径读取
|
||||||
|
func appendAttachmentsToMessage(msg string, attachments []ChatAttachment, savedPaths []string, logger *zap.Logger) string {
|
||||||
|
if len(attachments) == 0 {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(msg)
|
||||||
|
if len(savedPaths) == len(attachments) {
|
||||||
|
b.WriteString("\n\n[用户上传的文件已保存到以下路径(可使用 cat/exec 等工具按路径读取)]\n")
|
||||||
|
for i, a := range attachments {
|
||||||
|
b.WriteString(fmt.Sprintf("- %s: %s\n", a.FileName, savedPaths[i]))
|
||||||
|
}
|
||||||
|
b.WriteString("\n[以下为附件内容(便于直接参考)]\n")
|
||||||
|
}
|
||||||
|
for i, a := range attachments {
|
||||||
|
b.WriteString(fmt.Sprintf("\n--- 附件 %d: %s ---\n", i+1, a.FileName))
|
||||||
|
content := a.Content
|
||||||
|
mime := strings.ToLower(strings.TrimSpace(a.MimeType))
|
||||||
|
isText := strings.HasPrefix(mime, "text/") || mime == "" ||
|
||||||
|
strings.Contains(mime, "json") || strings.Contains(mime, "xml") ||
|
||||||
|
strings.Contains(mime, "javascript") || strings.Contains(mime, "shell")
|
||||||
|
if isText && len(content) > 0 {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil && len(decoded) > 0 {
|
||||||
|
content = string(decoded)
|
||||||
|
}
|
||||||
|
b.WriteString("```\n")
|
||||||
|
b.WriteString(content)
|
||||||
|
b.WriteString("\n```\n")
|
||||||
|
} else {
|
||||||
|
if decoded, err := base64.StdEncoding.DecodeString(content); err == nil {
|
||||||
|
content = string(decoded)
|
||||||
|
}
|
||||||
|
if utf8.ValidString(content) && len(content) < maxAttachmentBytes {
|
||||||
|
b.WriteString("```\n")
|
||||||
|
b.WriteString(content)
|
||||||
|
b.WriteString("\n```\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString(fmt.Sprintf("(二进制文件,约 %d 字节,已保存到上述路径,可按路径读取)\n", len(content)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatResponse 聊天响应
|
// ChatResponse 聊天响应
|
||||||
@@ -181,6 +333,12 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验附件数量(非流式)
|
||||||
|
if len(req.Attachments) > maxAttachments {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("附件最多 %d 个", maxAttachments)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 应用角色用户提示词和工具配置
|
// 应用角色用户提示词和工具配置
|
||||||
finalMessage := req.Message
|
finalMessage := req.Message
|
||||||
var roleTools []string // 角色配置的工具列表
|
var roleTools []string // 角色配置的工具列表
|
||||||
@@ -206,9 +364,20 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var savedPaths []string
|
||||||
|
if len(req.Attachments) > 0 {
|
||||||
|
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("保存对话附件失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存上传文件失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths, h.logger)
|
||||||
|
|
||||||
// 保存用户消息(保存原始消息,不包含角色提示词)
|
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
|
||||||
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil)
|
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
||||||
|
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("保存用户消息失败", zap.Error(err))
|
h.logger.Error("保存用户消息失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存用户消息失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存用户消息失败: " + err.Error()})
|
||||||
@@ -618,6 +787,12 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
h.logger.Info("从ReAct数据恢复历史上下文", zap.Int("count", len(agentHistoryMessages)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验附件数量
|
||||||
|
if len(req.Attachments) > maxAttachments {
|
||||||
|
sendEvent("error", fmt.Sprintf("附件最多 %d 个", maxAttachments), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 应用角色用户提示词和工具配置
|
// 应用角色用户提示词和工具配置
|
||||||
finalMessage := req.Message
|
finalMessage := req.Message
|
||||||
var roleTools []string // 角色配置的工具列表
|
var roleTools []string // 角色配置的工具列表
|
||||||
@@ -645,10 +820,22 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
var savedPaths []string
|
||||||
|
if len(req.Attachments) > 0 {
|
||||||
|
savedPaths, err = saveAttachmentsToDateAndConversationDir(req.Attachments, conversationID, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("保存对话附件失败", zap.Error(err))
|
||||||
|
sendEvent("error", "保存上传文件失败: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 将附件内容拼接到 finalMessage,便于大模型识别上传了哪些文件及内容
|
||||||
|
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths, h.logger)
|
||||||
// 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色)
|
// 如果roleTools为空,表示使用所有工具(默认角色或未配置工具的角色)
|
||||||
|
|
||||||
// 保存用户消息(保存原始消息,不包含角色提示词)
|
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
|
||||||
_, err = h.db.AddMessage(conversationID, "user", req.Message, nil)
|
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
||||||
|
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("保存用户消息失败", zap.Error(err))
|
h.logger.Error("保存用户消息失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
terminalMaxCommandLen = 4096
|
||||||
|
terminalMaxOutputLen = 256 * 1024 // 256KB
|
||||||
|
terminalTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// TerminalHandler 处理系统设置中的终端命令执行
|
||||||
|
type TerminalHandler struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTerminalHandler 创建终端处理器
|
||||||
|
func NewTerminalHandler(logger *zap.Logger) *TerminalHandler {
|
||||||
|
return &TerminalHandler{logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCommandRequest 执行命令请求
|
||||||
|
type RunCommandRequest struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
Shell string `json:"shell,omitempty"`
|
||||||
|
Cwd string `json:"cwd,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCommandResponse 执行命令响应
|
||||||
|
type RunCommandResponse struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCommand 执行终端命令(需登录)
|
||||||
|
func (h *TerminalHandler) RunCommand(c *gin.Context) {
|
||||||
|
var req RunCommandRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效,需要 command 字段"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdStr := strings.TrimSpace(req.Command)
|
||||||
|
if cmdStr == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "command 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cmdStr) > terminalMaxCommandLen {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "命令过长"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shell := req.Shell
|
||||||
|
if shell == "" {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
shell = "cmd"
|
||||||
|
} else {
|
||||||
|
shell = "sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), terminalTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd = exec.CommandContext(ctx, "cmd", "/c", cmdStr)
|
||||||
|
} else {
|
||||||
|
cmd = exec.CommandContext(ctx, shell, "-c", cmdStr)
|
||||||
|
// 无 TTY 时设置 COLUMNS/TERM,使 ping 等工具的 usage 排版与真实终端一致
|
||||||
|
cmd.Env = append(os.Environ(), "COLUMNS=120", "LINES=40", "TERM=xterm-256color")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Cwd != "" {
|
||||||
|
absCwd, err := filepath.Abs(req.Cwd)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur, _ := os.Getwd()
|
||||||
|
curAbs, _ := filepath.Abs(cur)
|
||||||
|
rel, err := filepath.Rel(curAbs, absCwd)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") || rel == ".." {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录必须在当前进程目录下"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.Dir = absCwd
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
stdoutBytes := stdout.Bytes()
|
||||||
|
stderrBytes := stderr.Bytes()
|
||||||
|
|
||||||
|
// 限制输出长度,防止内存占用过大(复制后截断,避免修改原 buffer)
|
||||||
|
truncSuffix := []byte("\n...(输出已截断)\n")
|
||||||
|
if len(stdoutBytes) > terminalMaxOutputLen {
|
||||||
|
tmp := make([]byte, terminalMaxOutputLen+len(truncSuffix))
|
||||||
|
n := copy(tmp, stdoutBytes[:terminalMaxOutputLen])
|
||||||
|
copy(tmp[n:], truncSuffix)
|
||||||
|
stdoutBytes = tmp
|
||||||
|
}
|
||||||
|
if len(stderrBytes) > terminalMaxOutputLen {
|
||||||
|
tmp := make([]byte, terminalMaxOutputLen+len(truncSuffix))
|
||||||
|
n := copy(tmp, stderrBytes[:terminalMaxOutputLen])
|
||||||
|
copy(tmp[n:], truncSuffix)
|
||||||
|
stderrBytes = tmp
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := 0
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
so := strings.ReplaceAll(string(stdoutBytes), "\r\n", "\n")
|
||||||
|
so = strings.ReplaceAll(so, "\r", "\n")
|
||||||
|
se := strings.ReplaceAll(string(stderrBytes), "\r\n", "\n")
|
||||||
|
se = strings.ReplaceAll(se, "\r", "\n")
|
||||||
|
resp := RunCommandResponse{
|
||||||
|
Stdout: so,
|
||||||
|
Stderr: se,
|
||||||
|
ExitCode: -1,
|
||||||
|
Error: "命令执行超时(" + terminalTimeout.String() + ")",
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Debug("终端命令执行异常", zap.String("command", cmdStr), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一为 \n,避免前端因 \r 出现错位/对角线排版
|
||||||
|
stdoutStr := strings.ReplaceAll(string(stdoutBytes), "\r\n", "\n")
|
||||||
|
stdoutStr = strings.ReplaceAll(stdoutStr, "\r", "\n")
|
||||||
|
stderrStr := strings.ReplaceAll(string(stderrBytes), "\r\n", "\n")
|
||||||
|
stderrStr = strings.ReplaceAll(stderrStr, "\r", "\n")
|
||||||
|
|
||||||
|
resp := RunCommandResponse{
|
||||||
|
Stdout: stdoutStr,
|
||||||
|
Stderr: stderrStr,
|
||||||
|
ExitCode: exitCode,
|
||||||
|
}
|
||||||
|
if err != nil && exitCode != 0 {
|
||||||
|
resp.Error = err.Error()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamEvent SSE 事件
|
||||||
|
type streamEvent struct {
|
||||||
|
T string `json:"t"` // "out" | "err" | "exit"
|
||||||
|
D string `json:"d,omitempty"`
|
||||||
|
C int `json:"c"` // exit code(不用 omitempty,否则 0 不序列化导致前端显示 [exit undefined])
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCommandStream 流式执行命令,输出实时推送到前端(SSE)
|
||||||
|
func (h *TerminalHandler) RunCommandStream(c *gin.Context) {
|
||||||
|
var req RunCommandRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效,需要 command 字段"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmdStr := strings.TrimSpace(req.Command)
|
||||||
|
if cmdStr == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "command 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(cmdStr) > terminalMaxCommandLen {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "命令过长"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shell := req.Shell
|
||||||
|
if shell == "" {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
shell = "cmd"
|
||||||
|
} else {
|
||||||
|
shell = "sh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), terminalTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cmd = exec.CommandContext(ctx, "cmd", "/c", cmdStr)
|
||||||
|
} else {
|
||||||
|
cmd = exec.CommandContext(ctx, shell, "-c", cmdStr)
|
||||||
|
cmd.Env = append(os.Environ(), "COLUMNS=120", "LINES=40", "TERM=xterm-256color")
|
||||||
|
}
|
||||||
|
if req.Cwd != "" {
|
||||||
|
absCwd, err := filepath.Abs(req.Cwd)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cur, _ := os.Getwd()
|
||||||
|
curAbs, _ := filepath.Abs(cur)
|
||||||
|
rel, err := filepath.Rel(curAbs, absCwd)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") || rel == ".." {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "工作目录必须在当前进程目录下"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.Dir = absCwd
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("Connection", "keep-alive")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
c.Writer.WriteHeader(http.StatusOK)
|
||||||
|
flusher, ok := c.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent := func(ev streamEvent) {
|
||||||
|
body, _ := json.Marshal(ev)
|
||||||
|
c.SSEvent("", string(body))
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
runCommandStreamImpl(cmd, sendEvent, ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ptyCols = 120
|
||||||
|
const ptyRows = 40
|
||||||
|
|
||||||
|
// runCommandStreamImpl 在 Unix 下用 PTY 执行,使 ping 等命令按终端宽度排版(isatty 为真)
|
||||||
|
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) {
|
||||||
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: ptyCols, Rows: ptyRows})
|
||||||
|
if err != nil {
|
||||||
|
sendEvent(streamEvent{T: "exit", C: -1})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ptmx.Close()
|
||||||
|
|
||||||
|
normalize := func(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
return strings.ReplaceAll(s, "\r", "\n")
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(ptmx)
|
||||||
|
for sc.Scan() {
|
||||||
|
sendEvent(streamEvent{T: "out", D: normalize(sc.Text())})
|
||||||
|
}
|
||||||
|
exitCode := 0
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
sendEvent(streamEvent{T: "exit", C: exitCode})
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runCommandStreamImpl 在 Windows 下用 stdout/stderr 管道执行
|
||||||
|
func runCommandStreamImpl(cmd *exec.Cmd, sendEvent func(streamEvent), ctx context.Context) {
|
||||||
|
stdoutPipe, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
sendEvent(streamEvent{T: "exit", C: -1})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stderrPipe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
sendEvent(streamEvent{T: "exit", C: -1})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
sendEvent(streamEvent{T: "exit", C: -1})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize := func(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||||
|
return strings.ReplaceAll(s, "\r", "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sc := bufio.NewScanner(stdoutPipe)
|
||||||
|
for sc.Scan() {
|
||||||
|
sendEvent(streamEvent{T: "out", D: normalize(sc.Text())})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
sc := bufio.NewScanner(stderrPipe)
|
||||||
|
for sc.Scan() {
|
||||||
|
sendEvent(streamEvent{T: "err", D: normalize(sc.Text())})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
exitCode := 0
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
exitCode = -1
|
||||||
|
}
|
||||||
|
sendEvent(streamEvent{T: "exit", C: exitCode})
|
||||||
|
}
|
||||||
@@ -1582,12 +1582,106 @@ header {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-input-container > .chat-input-with-files {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-container > .chat-input-field {
|
.chat-input-container > .chat-input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: rgba(0, 102, 255, 0.08);
|
||||||
|
border: 1px solid rgba(0, 102, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-chip-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-chip-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-chip-remove:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-file-input-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-upload-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, color 0.2s, background 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-upload-btn:hover {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
color: var(--accent-color);
|
||||||
|
background: rgba(0, 102, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-container.drag-over {
|
||||||
|
background: rgba(0, 102, 255, 0.06);
|
||||||
|
border-radius: 12px;
|
||||||
|
outline: 2px dashed rgba(0, 102, 255, 0.35);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-input-field {
|
.chat-input-field {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -3038,6 +3132,164 @@ header {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 系统设置 - 终端 */
|
||||||
|
.terminal-wrapper {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0d1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
padding: 6px 10px 0;
|
||||||
|
background: #161b22;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 5px 8px 5px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: #8b949e;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-close {
|
||||||
|
padding: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #8b949e;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-close:hover {
|
||||||
|
color: #ff7b72;
|
||||||
|
background: rgba(255, 123, 114, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab:hover {
|
||||||
|
color: #e6edf3;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab.active {
|
||||||
|
color: #e6edf3;
|
||||||
|
background: #0d1117;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-new {
|
||||||
|
margin-left: 4px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: #8b949e;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-tab-new:hover {
|
||||||
|
color: #58a6ff;
|
||||||
|
background: rgba(88, 166, 255, 0.1);
|
||||||
|
border-color: rgba(88, 166, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: #161b22;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-toolbar-title {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #8b949e;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-panes {
|
||||||
|
position: relative;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-pane {
|
||||||
|
display: none;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-pane.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
min-height: 400px;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container .xterm {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container .xterm-viewport {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-error {
|
||||||
|
color: #ff7b72;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-12
@@ -22,6 +22,12 @@ const DRAFT_STORAGE_KEY = 'cyberstrike-chat-draft';
|
|||||||
let draftSaveTimer = null;
|
let draftSaveTimer = null;
|
||||||
const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟
|
const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟
|
||||||
|
|
||||||
|
// 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表)
|
||||||
|
const MAX_CHAT_FILES = 10;
|
||||||
|
const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。';
|
||||||
|
/** @type {{ fileName: string, content: string, mimeType: string }[]} */
|
||||||
|
let chatAttachments = [];
|
||||||
|
|
||||||
// 保存输入框草稿到localStorage(防抖版本)
|
// 保存输入框草稿到localStorage(防抖版本)
|
||||||
function saveChatDraftDebounced(content) {
|
function saveChatDraftDebounced(content) {
|
||||||
// 清除之前的定时器
|
// 清除之前的定时器
|
||||||
@@ -107,14 +113,22 @@ function adjustTextareaHeight(textarea) {
|
|||||||
// 发送消息
|
// 发送消息
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const input = document.getElementById('chat-input');
|
const input = document.getElementById('chat-input');
|
||||||
const message = input.value.trim();
|
let message = input.value.trim();
|
||||||
|
const hasAttachments = chatAttachments && chatAttachments.length > 0;
|
||||||
|
|
||||||
if (!message) {
|
if (!message && !hasAttachments) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 有附件且用户未输入时,发一句简短默认提示即可(后端会拼接路径和文件内容给大模型)
|
||||||
|
if (hasAttachments && !message) {
|
||||||
|
message = CHAT_FILE_DEFAULT_PROMPT;
|
||||||
|
}
|
||||||
|
|
||||||
// 显示用户消息
|
// 显示用户消息(含附件名,便于用户确认)
|
||||||
addMessage('user', message);
|
const displayMessage = hasAttachments
|
||||||
|
? message + '\n' + chatAttachments.map(a => '📎 ' + a.fileName).join('\n')
|
||||||
|
: message;
|
||||||
|
addMessage('user', displayMessage);
|
||||||
|
|
||||||
// 清除防抖定时器,防止在清空输入框后重新保存草稿
|
// 清除防抖定时器,防止在清空输入框后重新保存草稿
|
||||||
if (draftSaveTimer) {
|
if (draftSaveTimer) {
|
||||||
@@ -136,6 +150,23 @@ async function sendMessage() {
|
|||||||
// 强制重置输入框高度为初始高度(40px)
|
// 强制重置输入框高度为初始高度(40px)
|
||||||
input.style.height = '40px';
|
input.style.height = '40px';
|
||||||
|
|
||||||
|
// 构建请求体(含附件)
|
||||||
|
const body = {
|
||||||
|
message: message,
|
||||||
|
conversationId: currentConversationId,
|
||||||
|
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||||
|
};
|
||||||
|
if (hasAttachments) {
|
||||||
|
body.attachments = chatAttachments.map(a => ({
|
||||||
|
fileName: a.fileName,
|
||||||
|
content: a.content,
|
||||||
|
mimeType: a.mimeType || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
// 发送后清空附件列表
|
||||||
|
chatAttachments = [];
|
||||||
|
renderChatFileChips();
|
||||||
|
|
||||||
// 创建进度消息容器(使用详细的进度展示)
|
// 创建进度消息容器(使用详细的进度展示)
|
||||||
const progressId = addProgressMessage();
|
const progressId = addProgressMessage();
|
||||||
const progressElement = document.getElementById(progressId);
|
const progressElement = document.getElementById(progressId);
|
||||||
@@ -145,19 +176,12 @@ async function sendMessage() {
|
|||||||
let mcpExecutionIds = [];
|
let mcpExecutionIds = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取当前选中的角色(从 roles.js 的函数获取)
|
|
||||||
const roleName = typeof getCurrentRole === 'function' ? getCurrentRole() : '';
|
|
||||||
|
|
||||||
const response = await apiFetch('/api/agent-loop/stream', {
|
const response = await apiFetch('/api/agent-loop/stream', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
message: message,
|
|
||||||
conversationId: currentConversationId,
|
|
||||||
role: roleName || undefined
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -222,6 +246,130 @@ async function sendMessage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 对话文件上传 ----------
|
||||||
|
function renderChatFileChips() {
|
||||||
|
const list = document.getElementById('chat-file-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!chatAttachments.length) return;
|
||||||
|
chatAttachments.forEach((a, i) => {
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'chat-file-chip';
|
||||||
|
chip.setAttribute('role', 'listitem');
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'chat-file-chip-name';
|
||||||
|
name.title = a.fileName;
|
||||||
|
name.textContent = a.fileName;
|
||||||
|
const remove = document.createElement('button');
|
||||||
|
remove.type = 'button';
|
||||||
|
remove.className = 'chat-file-chip-remove';
|
||||||
|
remove.title = '移除';
|
||||||
|
remove.innerHTML = '×';
|
||||||
|
remove.setAttribute('aria-label', '移除 ' + a.fileName);
|
||||||
|
remove.addEventListener('click', () => removeChatAttachment(i));
|
||||||
|
chip.appendChild(name);
|
||||||
|
chip.appendChild(remove);
|
||||||
|
list.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeChatAttachment(index) {
|
||||||
|
chatAttachments.splice(index, 1);
|
||||||
|
renderChatFileChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有附件且输入框为空时,填入一句默认提示(可编辑);后端会单独拼接路径与内容给大模型
|
||||||
|
function appendChatFilePrompt() {
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
if (!input || !chatAttachments.length) return;
|
||||||
|
if (!input.value.trim()) {
|
||||||
|
input.value = CHAT_FILE_DEFAULT_PROMPT;
|
||||||
|
adjustTextareaHeight(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsAttachment(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const mimeType = file.type || '';
|
||||||
|
const isTextLike = /^text\//i.test(mimeType) || /^(application\/(json|xml|javascript)|image\/svg\+xml)/i.test(mimeType);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
let content = reader.result;
|
||||||
|
if (typeof content === 'string' && content.startsWith('data:')) {
|
||||||
|
content = content.replace(/^data:[^;]+;base64,/, '');
|
||||||
|
}
|
||||||
|
resolve({ fileName: file.name, content: content, mimeType: mimeType });
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
if (isTextLike) {
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
} else {
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFilesToChat(files) {
|
||||||
|
if (!files || !files.length) return;
|
||||||
|
const next = Array.from(files);
|
||||||
|
if (chatAttachments.length + next.length > MAX_CHAT_FILES) {
|
||||||
|
alert('最多同时上传 ' + MAX_CHAT_FILES + ' 个文件,当前已选 ' + chatAttachments.length + ' 个。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const addOne = (file) => {
|
||||||
|
return readFileAsAttachment(file).then((a) => {
|
||||||
|
chatAttachments.push(a);
|
||||||
|
renderChatFileChips();
|
||||||
|
appendChatFilePrompt();
|
||||||
|
}).catch(() => {
|
||||||
|
alert('读取文件失败:' + file.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let p = Promise.resolve();
|
||||||
|
next.forEach((file) => { p = p.then(() => addOne(file)); });
|
||||||
|
p.then(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupChatFileUpload() {
|
||||||
|
const inputEl = document.getElementById('chat-file-input');
|
||||||
|
const container = document.getElementById('chat-input-container') || document.querySelector('.chat-input-container');
|
||||||
|
if (!inputEl || !container) return;
|
||||||
|
|
||||||
|
inputEl.addEventListener('change', function () {
|
||||||
|
const files = this.files;
|
||||||
|
if (files && files.length) {
|
||||||
|
addFilesToChat(files);
|
||||||
|
}
|
||||||
|
this.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener('dragover', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
container.addEventListener('dragleave', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!this.contains(e.relatedTarget)) {
|
||||||
|
this.classList.remove('drag-over');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.addEventListener('drop', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.classList.remove('drag-over');
|
||||||
|
const files = e.dataTransfer && e.dataTransfer.files;
|
||||||
|
if (files && files.length) addFilesToChat(files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保 chat-input-container 有 id(若模板未写)
|
||||||
|
function ensureChatInputContainerId() {
|
||||||
|
const c = document.querySelector('.chat-input-container');
|
||||||
|
if (c && !c.id) c.id = 'chat-input-container';
|
||||||
|
}
|
||||||
|
|
||||||
function setupMentionSupport() {
|
function setupMentionSupport() {
|
||||||
mentionSuggestionsEl = document.getElementById('mention-suggestions');
|
mentionSuggestionsEl = document.getElementById('mention-suggestions');
|
||||||
if (mentionSuggestionsEl) {
|
if (mentionSuggestionsEl) {
|
||||||
@@ -799,6 +947,8 @@ function initializeChatUI() {
|
|||||||
}
|
}
|
||||||
activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL);
|
activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL);
|
||||||
setupMentionSupport();
|
setupMentionSupport();
|
||||||
|
ensureChatInputContainerId();
|
||||||
|
setupChatFileUpload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息计数器,确保ID唯一
|
// 消息计数器,确保ID唯一
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ function switchSettingsSection(section) {
|
|||||||
if (activeContent) {
|
if (activeContent) {
|
||||||
activeContent.classList.add('active');
|
activeContent.classList.add('active');
|
||||||
}
|
}
|
||||||
|
if (section === 'terminal' && typeof initTerminal === 'function') {
|
||||||
|
setTimeout(initTerminal, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开设置
|
// 打开设置
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* 系统设置 - 终端:多标签、流式输出、命令历史、Ctrl+L 清屏、长时间可取消
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
var getContext = HTMLCanvasElement.prototype.getContext;
|
||||||
|
HTMLCanvasElement.prototype.getContext = function (type, attrs) {
|
||||||
|
if (type === '2d') {
|
||||||
|
attrs = (attrs && typeof attrs === 'object') ? Object.assign({ willReadFrequently: true }, attrs) : { willReadFrequently: true };
|
||||||
|
return getContext.call(this, type, attrs);
|
||||||
|
}
|
||||||
|
return getContext.apply(this, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
var terminals = [];
|
||||||
|
var currentTabId = 1;
|
||||||
|
var inited = false;
|
||||||
|
var tabIdCounter = 1;
|
||||||
|
var PROMPT = '\x1b[32m$\x1b[0m ';
|
||||||
|
var HISTORY_MAX = 100;
|
||||||
|
var CANCEL_AFTER_MS = 125000;
|
||||||
|
|
||||||
|
function getCurrent() {
|
||||||
|
for (var i = 0; i < terminals.length; i++) {
|
||||||
|
if (terminals[i].id === currentTabId) return terminals[i];
|
||||||
|
}
|
||||||
|
return terminals[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var WELCOME_LINE = 'CyberStrikeAI 终端 - 直接输入命令,Enter 执行;↑↓ 历史;Ctrl+L 清屏\r\n';
|
||||||
|
|
||||||
|
function writePrompt(tab) {
|
||||||
|
var t = tab || getCurrent();
|
||||||
|
if (t && t.term) t.term.write(PROMPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redrawTabDisplay(t) {
|
||||||
|
if (!t || !t.term) return;
|
||||||
|
t.term.clear();
|
||||||
|
t.lineBuffer = '';
|
||||||
|
if (t.cursorIndex !== undefined) t.cursorIndex = 0;
|
||||||
|
t.term.write(WELCOME_LINE);
|
||||||
|
t.term.write(PROMPT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeln(tabOrS, s) {
|
||||||
|
var t, text;
|
||||||
|
if (arguments.length === 1) { text = tabOrS; t = getCurrent(); } else { t = tabOrS; text = s; }
|
||||||
|
if (!t || !t.term) return;
|
||||||
|
if (text) t.term.writeln(text);
|
||||||
|
else t.term.writeln('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeOutput(tab, text, isError) {
|
||||||
|
var t = tab || getCurrent();
|
||||||
|
if (!t || !t.term || !text) return;
|
||||||
|
var s = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
var lines = s.split('\n');
|
||||||
|
var prefix = isError ? '\x1b[31m' : '';
|
||||||
|
var suffix = isError ? '\x1b[0m' : '';
|
||||||
|
t.term.write(prefix);
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var line = lines[i].replace(/\r/g, '');
|
||||||
|
t.term.writeln(line);
|
||||||
|
}
|
||||||
|
t.term.write(suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
var h = new Headers();
|
||||||
|
h.set('Content-Type', 'application/json');
|
||||||
|
try {
|
||||||
|
var auth = localStorage.getItem('cyberstrike-auth');
|
||||||
|
if (auth) {
|
||||||
|
var o = JSON.parse(auth);
|
||||||
|
if (o && o.token) h.set('Authorization', 'Bearer ' + o.token);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(cmd, tab) {
|
||||||
|
var t = tab || getCurrent();
|
||||||
|
if (!t) return;
|
||||||
|
if (t.running) return;
|
||||||
|
runCommandImpl(cmd, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommandImpl(cmd, t) {
|
||||||
|
t.running = true;
|
||||||
|
t.abortController = new AbortController();
|
||||||
|
var cancelTimer = setTimeout(function () {
|
||||||
|
if (!t.running) return;
|
||||||
|
t.running = false;
|
||||||
|
writeln(t, '\x1b[2m(已取消 可继续输入)\x1b[0m');
|
||||||
|
writePrompt(t);
|
||||||
|
}, CANCEL_AFTER_MS);
|
||||||
|
|
||||||
|
var done = function () {
|
||||||
|
clearTimeout(cancelTimer);
|
||||||
|
t.running = false;
|
||||||
|
t.abortController = null;
|
||||||
|
writePrompt(t);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/terminal/run/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ command: cmd }),
|
||||||
|
signal: t.abortController.signal
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'HTTP ' + res.status); });
|
||||||
|
var ct = res.headers.get('Content-Type') || '';
|
||||||
|
if (ct.indexOf('text/event-stream') !== -1 && res.body) {
|
||||||
|
return readSSEStream(res.body, t).then(done).catch(function () { done(); });
|
||||||
|
}
|
||||||
|
return res.json().then(function (data) {
|
||||||
|
if (data.stdout) writeOutput(t, data.stdout, false);
|
||||||
|
if (data.stderr) writeOutput(t, data.stderr, true);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}).catch(function (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
writeln(t, '\x1b[2m(已取消)\x1b[0m');
|
||||||
|
} else {
|
||||||
|
writeln(t, '\x1b[31m错误: ' + (err.message || String(err)) + '\x1b[0m');
|
||||||
|
}
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSSEStream(body, t) {
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var reader = body.getReader();
|
||||||
|
var decoder = new TextDecoder();
|
||||||
|
var buf = '';
|
||||||
|
function read() {
|
||||||
|
reader.read().then(function (result) {
|
||||||
|
if (result.done) { resolve(); return; }
|
||||||
|
buf += decoder.decode(result.value, { stream: true });
|
||||||
|
var i;
|
||||||
|
while ((i = buf.indexOf('\n\n')) !== -1) {
|
||||||
|
var block = buf.slice(0, i);
|
||||||
|
buf = buf.slice(i + 2);
|
||||||
|
var dataLine = block.match(/data:\s*(.+)/);
|
||||||
|
if (dataLine) {
|
||||||
|
try {
|
||||||
|
var ev = JSON.parse(dataLine[1]);
|
||||||
|
if (ev.t === 'out' && ev.d !== undefined) t.term.writeln(ev.d);
|
||||||
|
else if (ev.t === 'err' && ev.d !== undefined) t.term.write('\x1b[31m' + ev.d + '\x1b[0m\n');
|
||||||
|
else if (ev.t === 'exit') {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
read();
|
||||||
|
}).catch(reject);
|
||||||
|
}
|
||||||
|
read();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerminalInContainer(container, tab) {
|
||||||
|
if (typeof Terminal === 'undefined') return null;
|
||||||
|
if (!tab.history) tab.history = [];
|
||||||
|
if (tab.historyIndex === undefined) tab.historyIndex = -1;
|
||||||
|
if (tab.cursorIndex === undefined) tab.cursorIndex = 0;
|
||||||
|
|
||||||
|
var term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'bar',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
scrollback: 1000,
|
||||||
|
theme: {
|
||||||
|
background: '#0d1117',
|
||||||
|
foreground: '#e6edf3',
|
||||||
|
cursor: '#58a6ff',
|
||||||
|
cursorAccent: '#0d1117',
|
||||||
|
selection: 'rgba(88, 166, 255, 0.3)',
|
||||||
|
black: '#484f58',
|
||||||
|
red: '#ff7b72',
|
||||||
|
green: '#3fb950',
|
||||||
|
yellow: '#d29922',
|
||||||
|
blue: '#58a6ff',
|
||||||
|
magenta: '#bc8cff',
|
||||||
|
cyan: '#39c5cf',
|
||||||
|
white: '#e6edf3',
|
||||||
|
brightBlack: '#6e7681',
|
||||||
|
brightRed: '#ffa198',
|
||||||
|
brightGreen: '#56d364',
|
||||||
|
brightYellow: '#e3b341',
|
||||||
|
brightBlue: '#79c0ff',
|
||||||
|
brightMagenta: '#d2a8ff',
|
||||||
|
brightCyan: '#56d4dd',
|
||||||
|
brightWhite: '#f0f6fc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var fitAddon = null;
|
||||||
|
if (typeof FitAddon !== 'undefined') {
|
||||||
|
var FitCtor = (FitAddon.FitAddon || FitAddon);
|
||||||
|
fitAddon = new FitCtor();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
}
|
||||||
|
term.open(container);
|
||||||
|
term.write(WELCOME_LINE);
|
||||||
|
term.write(PROMPT);
|
||||||
|
container.addEventListener('click', function () {
|
||||||
|
switchTerminalTab(tab.id);
|
||||||
|
if (term) term.focus();
|
||||||
|
});
|
||||||
|
container.setAttribute('tabindex', '0');
|
||||||
|
container.title = '点击此处后输入命令';
|
||||||
|
|
||||||
|
function redrawLine(t) {
|
||||||
|
if (!t || !t.term) return;
|
||||||
|
var n = t.lineBuffer.length - t.cursorIndex;
|
||||||
|
t.term.write('\r\x1b[K' + PROMPT + t.lineBuffer);
|
||||||
|
if (n > 0) t.term.write('\x1b[' + n + 'D');
|
||||||
|
}
|
||||||
|
|
||||||
|
term.onData(function (data) {
|
||||||
|
if (data === '\x0c') {
|
||||||
|
term.clear();
|
||||||
|
tab.lineBuffer = '';
|
||||||
|
tab.cursorIndex = 0;
|
||||||
|
writePrompt(tab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data === '\x1b[A') {
|
||||||
|
if (tab.history.length === 0) return;
|
||||||
|
if (tab.historyIndex < 0) tab.historyIndex = tab.history.length;
|
||||||
|
tab.historyIndex--;
|
||||||
|
if (tab.historyIndex < 0) tab.historyIndex = 0;
|
||||||
|
tab.lineBuffer = tab.history[tab.historyIndex];
|
||||||
|
tab.cursorIndex = tab.lineBuffer.length;
|
||||||
|
term.write('\r\x1b[K' + PROMPT + tab.lineBuffer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data === '\x1b[B') {
|
||||||
|
if (tab.history.length === 0) return;
|
||||||
|
tab.historyIndex++;
|
||||||
|
if (tab.historyIndex >= tab.history.length) {
|
||||||
|
tab.historyIndex = -1;
|
||||||
|
tab.lineBuffer = '';
|
||||||
|
tab.cursorIndex = 0;
|
||||||
|
term.write('\r\x1b[K' + PROMPT);
|
||||||
|
} else {
|
||||||
|
tab.lineBuffer = tab.history[tab.historyIndex];
|
||||||
|
tab.cursorIndex = tab.lineBuffer.length;
|
||||||
|
term.write('\r\x1b[K' + PROMPT + tab.lineBuffer);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data === '\x1b[D') {
|
||||||
|
if (tab.cursorIndex > 0) {
|
||||||
|
tab.cursorIndex--;
|
||||||
|
term.write('\x1b[D');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data === '\x1b[C') {
|
||||||
|
if (tab.cursorIndex < tab.lineBuffer.length) {
|
||||||
|
tab.cursorIndex++;
|
||||||
|
term.write('\x1b[C');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var code = data.charCodeAt(0);
|
||||||
|
if (code === 13 || code === 10) {
|
||||||
|
var cmd = tab.lineBuffer.trim();
|
||||||
|
tab.lineBuffer = '';
|
||||||
|
tab.cursorIndex = 0;
|
||||||
|
tab.historyIndex = -1;
|
||||||
|
term.writeln('');
|
||||||
|
if (cmd) {
|
||||||
|
if (tab.history.indexOf(cmd) === -1) {
|
||||||
|
tab.history.push(cmd);
|
||||||
|
if (tab.history.length > HISTORY_MAX) tab.history.shift();
|
||||||
|
}
|
||||||
|
runCommand(cmd, tab);
|
||||||
|
} else {
|
||||||
|
writePrompt(tab);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === 127) {
|
||||||
|
if (tab.cursorIndex > 0) {
|
||||||
|
tab.lineBuffer = tab.lineBuffer.slice(0, tab.cursorIndex - 1) + tab.lineBuffer.slice(tab.cursorIndex);
|
||||||
|
tab.cursorIndex--;
|
||||||
|
redrawLine(tab);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code === 3) {
|
||||||
|
if (tab.running && tab.abortController) {
|
||||||
|
tab.abortController.abort();
|
||||||
|
}
|
||||||
|
tab.lineBuffer = '';
|
||||||
|
tab.cursorIndex = 0;
|
||||||
|
term.writeln('^C');
|
||||||
|
writePrompt(tab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.length === 1 && code >= 32) {
|
||||||
|
tab.lineBuffer = tab.lineBuffer.slice(0, tab.cursorIndex) + data + tab.lineBuffer.slice(tab.cursorIndex);
|
||||||
|
tab.cursorIndex++;
|
||||||
|
redrawLine(tab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tab.lineBuffer += data;
|
||||||
|
tab.cursorIndex = tab.lineBuffer.length;
|
||||||
|
term.write(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
tab.term = term;
|
||||||
|
tab.fitAddon = fitAddon;
|
||||||
|
return term;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTerminalTab(id) {
|
||||||
|
var prevId = currentTabId;
|
||||||
|
currentTabId = id;
|
||||||
|
document.querySelectorAll('.terminal-tab').forEach(function (el) {
|
||||||
|
el.classList.toggle('active', parseInt(el.getAttribute('data-tab-id'), 10) === id);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.terminal-pane').forEach(function (el) {
|
||||||
|
var paneId = el.getAttribute('id');
|
||||||
|
var match = paneId && paneId.match(/terminal-pane-(\d+)/);
|
||||||
|
var paneTabId = match ? parseInt(match[1], 10) : 0;
|
||||||
|
el.classList.toggle('active', paneTabId === id);
|
||||||
|
});
|
||||||
|
var t = getCurrent();
|
||||||
|
if (t && t.term) {
|
||||||
|
if (prevId !== id) {
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
if (currentTabId === id && t.term) t.term.focus();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
t.term.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTerminalTab() {
|
||||||
|
if (typeof Terminal === 'undefined') return;
|
||||||
|
tabIdCounter += 1;
|
||||||
|
var id = tabIdCounter;
|
||||||
|
var paneId = 'terminal-pane-' + id;
|
||||||
|
var containerId = 'terminal-container-' + id;
|
||||||
|
var tabsEl = document.querySelector('.terminal-tabs');
|
||||||
|
var panesEl = document.querySelector('.terminal-panes');
|
||||||
|
if (!tabsEl || !panesEl) return;
|
||||||
|
|
||||||
|
var tabDiv = document.createElement('div');
|
||||||
|
tabDiv.className = 'terminal-tab';
|
||||||
|
tabDiv.setAttribute('data-tab-id', String(id));
|
||||||
|
var label = document.createElement('span');
|
||||||
|
label.className = 'terminal-tab-label';
|
||||||
|
label.textContent = '终端 ' + id;
|
||||||
|
label.onclick = function () { switchTerminalTab(id); };
|
||||||
|
var closeBtn = document.createElement('button');
|
||||||
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.className = 'terminal-tab-close';
|
||||||
|
closeBtn.title = '关闭';
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.onclick = function (e) { e.stopPropagation(); removeTerminalTab(id); };
|
||||||
|
tabDiv.appendChild(label);
|
||||||
|
tabDiv.appendChild(closeBtn);
|
||||||
|
var plusBtn = tabsEl.querySelector('.terminal-tab-new');
|
||||||
|
tabsEl.insertBefore(tabDiv, plusBtn);
|
||||||
|
|
||||||
|
var paneDiv = document.createElement('div');
|
||||||
|
paneDiv.id = paneId;
|
||||||
|
paneDiv.className = 'terminal-pane';
|
||||||
|
var containerDiv = document.createElement('div');
|
||||||
|
containerDiv.id = containerId;
|
||||||
|
containerDiv.className = 'terminal-container';
|
||||||
|
paneDiv.appendChild(containerDiv);
|
||||||
|
panesEl.appendChild(paneDiv);
|
||||||
|
|
||||||
|
var tab = { id: id, paneId: paneId, containerId: containerId, lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 };
|
||||||
|
terminals.push(tab);
|
||||||
|
createTerminalInContainer(containerDiv, tab);
|
||||||
|
switchTerminalTab(id);
|
||||||
|
updateTerminalTabCloseVisibility();
|
||||||
|
setTimeout(function () {
|
||||||
|
try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTerminalTabCloseVisibility() {
|
||||||
|
var tabsEl = document.querySelector('.terminal-tabs');
|
||||||
|
if (!tabsEl) return;
|
||||||
|
var tabDivs = tabsEl.querySelectorAll('.terminal-tab');
|
||||||
|
var showClose = terminals.length > 1;
|
||||||
|
for (var i = 0; i < tabDivs.length; i++) {
|
||||||
|
var btn = tabDivs[i].querySelector('.terminal-tab-close');
|
||||||
|
if (btn) btn.style.display = showClose ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTerminalTab(id) {
|
||||||
|
if (terminals.length <= 1) return;
|
||||||
|
var idx = -1;
|
||||||
|
for (var i = 0; i < terminals.length; i++) { if (terminals[i].id === id) { idx = i; break; } }
|
||||||
|
if (idx < 0) return;
|
||||||
|
|
||||||
|
var deletingCurrent = (currentTabId === id);
|
||||||
|
var switchToIndex = deletingCurrent ? (idx > 0 ? idx - 1 : 0) : -1;
|
||||||
|
|
||||||
|
var tab = terminals[idx];
|
||||||
|
if (tab.term && tab.term.dispose) tab.term.dispose();
|
||||||
|
tab.term = null;
|
||||||
|
tab.fitAddon = null;
|
||||||
|
terminals.splice(idx, 1);
|
||||||
|
|
||||||
|
var tabDiv = document.querySelector('.terminal-tab[data-tab-id="' + id + '"]');
|
||||||
|
var paneDiv = document.getElementById('terminal-pane-' + id);
|
||||||
|
if (tabDiv && tabDiv.parentNode) tabDiv.parentNode.removeChild(tabDiv);
|
||||||
|
if (paneDiv && paneDiv.parentNode) paneDiv.parentNode.removeChild(paneDiv);
|
||||||
|
|
||||||
|
var curIdxBeforeRenumber = -1;
|
||||||
|
if (!deletingCurrent) {
|
||||||
|
for (var i = 0; i < terminals.length; i++) {
|
||||||
|
if (terminals[i].id === currentTabId) { curIdxBeforeRenumber = i; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < terminals.length; i++) {
|
||||||
|
var t = terminals[i];
|
||||||
|
t.id = i + 1;
|
||||||
|
t.paneId = 'terminal-pane-' + (i + 1);
|
||||||
|
t.containerId = 'terminal-container-' + (i + 1);
|
||||||
|
}
|
||||||
|
tabIdCounter = terminals.length;
|
||||||
|
if (curIdxBeforeRenumber >= 0) currentTabId = terminals[curIdxBeforeRenumber].id;
|
||||||
|
|
||||||
|
var tabsEl = document.querySelector('.terminal-tabs');
|
||||||
|
var panesEl = document.querySelector('.terminal-panes');
|
||||||
|
if (tabsEl) {
|
||||||
|
var tabDivs = tabsEl.querySelectorAll('.terminal-tab');
|
||||||
|
for (var i = 0; i < tabDivs.length; i++) {
|
||||||
|
var t = terminals[i];
|
||||||
|
tabDivs[i].setAttribute('data-tab-id', String(t.id));
|
||||||
|
var lbl = tabDivs[i].querySelector('.terminal-tab-label');
|
||||||
|
if (lbl) lbl.textContent = '终端 ' + t.id;
|
||||||
|
if (lbl) lbl.onclick = (function (tid) { return function () { switchTerminalTab(tid); }; })(t.id);
|
||||||
|
var cb = tabDivs[i].querySelector('.terminal-tab-close');
|
||||||
|
if (cb) cb.onclick = (function (tid) { return function (e) { e.stopPropagation(); removeTerminalTab(tid); }; })(t.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (panesEl) {
|
||||||
|
var paneDivs = panesEl.querySelectorAll('.terminal-pane');
|
||||||
|
for (var i = 0; i < paneDivs.length; i++) {
|
||||||
|
var t = terminals[i];
|
||||||
|
paneDivs[i].id = t.paneId;
|
||||||
|
var cont = paneDivs[i].querySelector('.terminal-container');
|
||||||
|
if (cont) cont.id = t.containerId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTerminalTabCloseVisibility();
|
||||||
|
|
||||||
|
if (deletingCurrent && terminals.length > 0) {
|
||||||
|
currentTabId = terminals[switchToIndex].id;
|
||||||
|
switchTerminalTab(currentTabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTerminal() {
|
||||||
|
var pane1 = document.getElementById('terminal-pane-1');
|
||||||
|
var container1 = document.getElementById('terminal-container-1');
|
||||||
|
if (!pane1 || !container1) return;
|
||||||
|
if (inited) {
|
||||||
|
var t = getCurrent();
|
||||||
|
if (t && t.term) t.term.focus();
|
||||||
|
terminals.forEach(function (tab) { try { if (tab.fitAddon) tab.fitAddon.fit(); } catch (e) {} });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inited = true;
|
||||||
|
|
||||||
|
if (typeof Terminal === 'undefined') {
|
||||||
|
container1.innerHTML = '<p class="terminal-error">未加载 xterm.js,请刷新页面或检查网络。</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTabId = 1;
|
||||||
|
var tab = { id: 1, paneId: 'terminal-pane-1', containerId: 'terminal-container-1', lineBuffer: '', cursorIndex: 0, running: false, term: null, fitAddon: null, history: [], historyIndex: -1 };
|
||||||
|
terminals.push(tab);
|
||||||
|
createTerminalInContainer(container1, tab);
|
||||||
|
|
||||||
|
updateTerminalTabCloseVisibility();
|
||||||
|
|
||||||
|
setTimeout(function () {
|
||||||
|
try { if (tab.fitAddon) tab.fitAddon.fit(); if (tab.term) tab.term.focus(); } catch (e) {}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
var resizeTimer;
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(function () {
|
||||||
|
terminals.forEach(function (t) { try { if (t.fitAddon) t.fitAddon.fit(); } catch (e) {} });
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalClear() {
|
||||||
|
var t = getCurrent();
|
||||||
|
if (!t || !t.term) return;
|
||||||
|
t.term.clear();
|
||||||
|
t.lineBuffer = '';
|
||||||
|
if (t.cursorIndex !== undefined) t.cursorIndex = 0;
|
||||||
|
writePrompt(t);
|
||||||
|
t.term.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.initTerminal = initTerminal;
|
||||||
|
window.terminalClear = terminalClear;
|
||||||
|
window.switchTerminalTab = switchTerminalTab;
|
||||||
|
window.addTerminalTab = addTerminalTab;
|
||||||
|
window.removeTerminalTab = removeTerminalTab;
|
||||||
|
})();
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
<link rel="icon" type="image/png" href="/static/logo.png">
|
<link rel="icon" type="image/png" href="/static/logo.png">
|
||||||
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
|
<link rel="shortcut icon" type="image/png" href="/static/favicon.ico">
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="login-overlay" class="login-overlay" style="display: none;">
|
<div id="login-overlay" class="login-overlay" style="display: none;">
|
||||||
@@ -499,7 +500,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
<div id="active-tasks-bar" class="active-tasks-bar"></div>
|
||||||
<div id="chat-messages" class="chat-messages"></div>
|
<div id="chat-messages" class="chat-messages"></div>
|
||||||
<div class="chat-input-container">
|
<div id="chat-input-container" class="chat-input-container">
|
||||||
<div class="role-selector-wrapper">
|
<div class="role-selector-wrapper">
|
||||||
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" title="选择角色">
|
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" title="选择角色">
|
||||||
<span id="role-selector-icon" class="role-selector-icon">🔵</span>
|
<span id="role-selector-icon" class="role-selector-icon">🔵</span>
|
||||||
@@ -521,10 +522,19 @@
|
|||||||
<div id="role-selection-list" class="role-selection-list-main"></div>
|
<div id="role-selection-list" class="role-selection-list-main"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-input-field">
|
<div class="chat-input-with-files">
|
||||||
<textarea id="chat-input" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea>
|
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
|
||||||
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div>
|
<div class="chat-input-field">
|
||||||
|
<textarea id="chat-input" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea>
|
||||||
|
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" id="chat-file-input" class="chat-file-input-hidden" multiple accept="*" title="选择文件">
|
||||||
|
<button type="button" class="chat-upload-btn" onclick="document.getElementById('chat-file-input').click()" title="上传文件(可多选或拖拽到此处)">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="send-btn" onclick="sendMessage()">
|
<button class="send-btn" onclick="sendMessage()">
|
||||||
<span>发送</span>
|
<span>发送</span>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -1065,6 +1075,9 @@
|
|||||||
<div class="settings-nav-item" data-section="robots" onclick="switchSettingsSection('robots')">
|
<div class="settings-nav-item" data-section="robots" onclick="switchSettingsSection('robots')">
|
||||||
<span>机器人设置</span>
|
<span>机器人设置</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="settings-nav-item" data-section="terminal" onclick="switchSettingsSection('terminal')">
|
||||||
|
<span>终端</span>
|
||||||
|
</div>
|
||||||
<div class="settings-nav-item" data-section="security" onclick="switchSettingsSection('security')">
|
<div class="settings-nav-item" data-section="security" onclick="switchSettingsSection('security')">
|
||||||
<span>安全设置</span>
|
<span>安全设置</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1311,6 +1324,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 终端 -->
|
||||||
|
<div id="settings-section-terminal" class="settings-section-content">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<h3>终端</h3>
|
||||||
|
<p class="settings-description">在服务器上执行命令,便于运维与调试。命令在服务端执行,请勿执行敏感或破坏性操作。</p>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-wrapper">
|
||||||
|
<div class="terminal-tabs">
|
||||||
|
<div class="terminal-tab active" data-tab-id="1"><span class="terminal-tab-label" onclick="switchTerminalTab(1)">终端 1</span><button type="button" class="terminal-tab-close" onclick="removeTerminalTab(1); event.stopPropagation();" title="关闭">×</button></div>
|
||||||
|
<button type="button" class="terminal-tab-new" onclick="addTerminalTab()" title="新终端">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-panes">
|
||||||
|
<div id="terminal-pane-1" class="terminal-pane active">
|
||||||
|
<div id="terminal-container-1" class="terminal-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 安全设置 -->
|
<!-- 安全设置 -->
|
||||||
<div id="settings-section-security" class="settings-section-content">
|
<div id="settings-section-security" class="settings-section-content">
|
||||||
<div class="settings-section-header">
|
<div class="settings-section-header">
|
||||||
@@ -2135,6 +2167,9 @@ version: 1.0.0<br>
|
|||||||
<script src="/static/js/monitor.js"></script>
|
<script src="/static/js/monitor.js"></script>
|
||||||
<script src="/static/js/chat.js"></script>
|
<script src="/static/js/chat.js"></script>
|
||||||
<script src="/static/js/settings.js"></script>
|
<script src="/static/js/settings.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
|
||||||
|
<script src="/static/js/terminal.js"></script>
|
||||||
<script src="/static/js/knowledge.js"></script>
|
<script src="/static/js/knowledge.js"></script>
|
||||||
<script src="/static/js/skills.js"></script>
|
<script src="/static/js/skills.js"></script>
|
||||||
<script src="/static/js/vulnerability.js?v=4"></script>
|
<script src="/static/js/vulnerability.js?v=4"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user