Add files via upload

This commit is contained in:
公明
2026-03-14 00:49:25 +08:00
committed by GitHub
parent 226f9b79e2
commit 5e8fef0ad4
11 changed files with 1147 additions and 25 deletions
+162
View File
@@ -320,6 +320,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
@@ -365,6 +366,13 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
}
configHandler.SetVulnerabilityToolRegistrar(vulnerabilityRegistrar)
// 设置 WebShell 工具注册器(ApplyConfig 时重新注册)
webshellRegistrar := func() error {
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
return nil
}
configHandler.SetWebshellToolRegistrar(webshellRegistrar)
// 设置Skills工具注册器(内置工具,必须设置)
skillsRegistrar := func() error {
// 创建一个适配器,将database.DB适配为SkillStatsStorage接口
@@ -823,6 +831,8 @@ func setupRoutes(
// WebShell 管理(代理执行 + 连接配置存 SQLite)
protected.GET("/webshell/connections", webshellHandler.ListConnections)
protected.POST("/webshell/connections", webshellHandler.CreateConnection)
protected.GET("/webshell/connections/:id/ai-history", webshellHandler.GetAIHistory)
protected.GET("/webshell/connections/:id/ai-conversations", webshellHandler.ListAIConversations)
protected.PUT("/webshell/connections/:id", webshellHandler.UpdateConnection)
protected.DELETE("/webshell/connections/:id", webshellHandler.DeleteConnection)
protected.POST("/webshell/exec", webshellHandler.Exec)
@@ -1067,6 +1077,158 @@ func registerVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *z
logger.Info("漏洞记录工具注册成功")
}
// registerWebshellTools 注册 WebShell 相关 MCP 工具,供 AI 助手在指定连接上执行命令与文件操作
func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandler *handler.WebShellHandler, logger *zap.Logger) {
if db == nil || webshellHandler == nil {
logger.Warn("跳过 WebShell 工具注册:db 或 webshellHandler 为空")
return
}
// webshell_exec
execTool := mcp.Tool{
Name: builtin.ToolWebshellExec,
Description: "在指定的 WebShell 连接上执行一条系统命令,返回命令的标准输出。connection_id 由用户在 AI 助手上下文中选定。",
ShortDescription: "在 WebShell 连接上执行命令",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{
"type": "string",
"description": "WebShell 连接 ID(如 ws_xxx",
},
"command": map[string]interface{}{
"type": "string",
"description": "要执行的系统命令",
},
},
"required": []string{"connection_id", "command"},
},
}
execHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
cid, _ := args["connection_id"].(string)
cmd, _ := args["command"].(string)
if cid == "" || cmd == "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 command 均为必填"}}, IsError: true}, nil
}
conn, err := db.GetWebshellConnection(cid)
if err != nil || conn == nil {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接或查询失败"}}, IsError: true}, nil
}
output, ok, errMsg := webshellHandler.ExecWithConnection(conn, cmd)
if errMsg != "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
}
if !ok {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "HTTP 非 200,输出:\n" + output}}, IsError: false}, nil
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: false}, nil
}
mcpServer.RegisterTool(execTool, execHandler)
// webshell_file_list
listTool := mcp.Tool{
Name: builtin.ToolWebshellFileList,
Description: "在指定 WebShell 连接上列出目录内容。path 默认为当前目录(.)。",
ShortDescription: "在 WebShell 上列出目录",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
"path": map[string]interface{}{"type": "string", "description": "目录路径,默认 ."},
},
"required": []string{"connection_id"},
},
}
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
cid, _ := args["connection_id"].(string)
path, _ := args["path"].(string)
if cid == "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 必填"}}, IsError: true}, nil
}
conn, err := db.GetWebshellConnection(cid)
if err != nil || conn == nil {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
}
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "list", path, "", "")
if errMsg != "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: !ok}, nil
}
mcpServer.RegisterTool(listTool, listHandler)
// webshell_file_read
readTool := mcp.Tool{
Name: builtin.ToolWebshellFileRead,
Description: "在指定 WebShell 连接上读取文件内容。",
ShortDescription: "在 WebShell 上读取文件",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
},
"required": []string{"connection_id", "path"},
},
}
readHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
cid, _ := args["connection_id"].(string)
path, _ := args["path"].(string)
if cid == "" || path == "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 path 必填"}}, IsError: true}, nil
}
conn, err := db.GetWebshellConnection(cid)
if err != nil || conn == nil {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
}
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "read", path, "", "")
if errMsg != "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: output}}, IsError: !ok}, nil
}
mcpServer.RegisterTool(readTool, readHandler)
// webshell_file_write
writeTool := mcp.Tool{
Name: builtin.ToolWebshellFileWrite,
Description: "在指定 WebShell 连接上写入文件内容(会覆盖已有文件)。",
ShortDescription: "在 WebShell 上写入文件",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"connection_id": map[string]interface{}{"type": "string", "description": "WebShell 连接 ID"},
"path": map[string]interface{}{"type": "string", "description": "文件路径"},
"content": map[string]interface{}{"type": "string", "description": "要写入的内容"},
},
"required": []string{"connection_id", "path", "content"},
},
}
writeHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
cid, _ := args["connection_id"].(string)
path, _ := args["path"].(string)
content, _ := args["content"].(string)
if cid == "" || path == "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "connection_id 和 path 必填"}}, IsError: true}, nil
}
conn, err := db.GetWebshellConnection(cid)
if err != nil || conn == nil {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "未找到该 WebShell 连接"}}, IsError: true}, nil
}
output, ok, errMsg := webshellHandler.FileOpWithConnection(conn, "write", path, content, "")
if errMsg != "" {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: errMsg}}, IsError: true}, nil
}
if !ok {
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "写入可能失败,输出:\n" + output}}, IsError: false}, nil
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "写入成功\n" + output}}, IsError: false}, nil
}
mcpServer.RegisterTool(writeTool, writeHandler)
logger.Info("WebShell 工具注册成功")
}
// initializeKnowledge 初始化知识库组件(用于动态初始化)
func initializeKnowledge(
cfg *config.Config,
+97 -4
View File
@@ -33,13 +33,26 @@ type Message struct {
// CreateConversation 创建新对话
func (db *DB) CreateConversation(title string) (*Conversation, error) {
return db.CreateConversationWithWebshell("", title)
}
// CreateConversationWithWebshell 创建新对话,可选绑定 WebShell 连接 ID(为空则普通对话)
func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string) (*Conversation, error) {
id := uuid.New().String()
now := time.Now()
_, err := db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
id, title, now, now,
)
var err error
if webshellConnectionID != "" {
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id) VALUES (?, ?, ?, ?, ?)",
id, title, now, now, webshellConnectionID,
)
} else {
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
id, title, now, now,
)
}
if err != nil {
return nil, fmt.Errorf("创建对话失败: %w", err)
}
@@ -52,6 +65,86 @@ func (db *DB) CreateConversation(title string) (*Conversation, error) {
}, nil
}
// GetConversationByWebshellConnectionID 根据 WebShell 连接 ID 获取该连接下最近一条对话(用于 AI 助手持久化)
func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conversation, error) {
if connectionID == "" {
return nil, fmt.Errorf("connectionID is empty")
}
var conv Conversation
var createdAt, updatedAt string
var pinned int
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at FROM conversations WHERE webshell_connection_id = ? ORDER BY updated_at DESC LIMIT 1",
connectionID,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("查询对话失败: %w", err)
}
conv.Pinned = pinned != 0
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt); e == nil {
conv.CreatedAt = t
} else if t, e := time.Parse("2006-01-02 15:04:05", createdAt); e == nil {
conv.CreatedAt = t
} else {
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
conv.UpdatedAt = t
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
conv.UpdatedAt = t
} else {
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
messages, err := db.GetMessages(conv.ID)
if err != nil {
return nil, fmt.Errorf("加载消息失败: %w", err)
}
conv.Messages = messages
return &conv, nil
}
// WebShellConversationItem 用于侧边栏列表,不含消息
type WebShellConversationItem struct {
ID string `json:"id"`
Title string `json:"title"`
UpdatedAt time.Time `json:"updatedAt"`
}
// ListConversationsByWebshellConnectionID 列出该 WebShell 连接下的所有对话(按更新时间倒序),供侧边栏展示
func (db *DB) ListConversationsByWebshellConnectionID(connectionID string) ([]WebShellConversationItem, error) {
if connectionID == "" {
return nil, nil
}
rows, err := db.Query(
"SELECT id, title, updated_at FROM conversations WHERE webshell_connection_id = ? ORDER BY updated_at DESC",
connectionID,
)
if err != nil {
return nil, fmt.Errorf("查询对话列表失败: %w", err)
}
defer rows.Close()
var list []WebShellConversationItem
for rows.Next() {
var item WebShellConversationItem
var updatedAt string
if err := rows.Scan(&item.ID, &item.Title, &updatedAt); err != nil {
continue
}
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
item.UpdatedAt = t
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
item.UpdatedAt = t
} else {
item.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
list = append(list, item)
}
return list, rows.Err()
}
// GetConversation 获取对话
func (db *DB) GetConversation(id string) (*Conversation, error) {
var conv Conversation
+15
View File
@@ -415,6 +415,21 @@ func (db *DB) migrateConversationsTable() error {
}
}
// 检查 webshell_connection_id 字段是否存在(WebShell AI 助手对话关联)
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='webshell_connection_id'").Scan(&count)
if err != nil {
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN webshell_connection_id TEXT"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加webshell_connection_id字段失败", zap.Error(addErr))
}
}
} else if count == 0 {
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN webshell_connection_id TEXT"); err != nil {
db.logger.Warn("添加webshell_connection_id字段失败", zap.Error(err))
}
}
return nil
}
+49 -19
View File
@@ -121,10 +121,11 @@ type ChatAttachment struct {
// ChatRequest 聊天请求
type ChatRequest struct {
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
}
const (
@@ -316,7 +317,24 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
finalMessage := req.Message
var roleTools []string // 角色配置的工具列表
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
if req.Role != "" && req.Role != "默认" {
// WebShell AI 助手模式:绑定当前连接,仅开放 webshell_* 工具并注入 connection_id
if req.WebShellConnectionID != "" {
conn, err := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
if err != nil || conn == nil {
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "未找到该 WebShell 连接"})
return
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write。请根据用户输入决定下一步:若仅为问候、闲聊或简单问题,直接简短回复即可,不必调用工具;当用户明确需要执行命令、列目录、读写字件等操作时再调用上述工具。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
roleTools = []string{builtin.ToolWebshellExec, builtin.ToolWebshellFileList, builtin.ToolWebshellFileRead, builtin.ToolWebshellFileWrite}
roleSkills = nil
} else if req.Role != "" && req.Role != "默认" {
if h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
// 应用用户提示词
@@ -712,11 +730,17 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
}
}
// 如果没有对话ID,创建新对话
// 如果没有对话ID,创建新对话(WebShell 助手模式下关联连接 ID 以便持久化展示)
conversationID := req.ConversationID
if conversationID == "" {
title := safeTruncateString(req.Message, 50)
conv, err := h.db.CreateConversation(title)
var conv *database.Conversation
var err error
if req.WebShellConnectionID != "" {
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title)
} else {
conv, err = h.db.CreateConversation(title)
}
if err != nil {
h.logger.Error("创建对话失败", zap.Error(err))
sendEvent("error", "创建对话失败: "+err.Error(), nil)
@@ -769,7 +793,22 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 应用角色用户提示词和工具配置
finalMessage := req.Message
var roleTools []string // 角色配置的工具列表
if req.Role != "" && req.Role != "默认" {
var roleSkills []string
if req.WebShellConnectionID != "" {
conn, errConn := h.db.GetWebshellConnection(strings.TrimSpace(req.WebShellConnectionID))
if errConn != nil || conn == nil {
h.logger.Warn("WebShell AI 助手:未找到连接", zap.String("id", req.WebShellConnectionID), zap.Error(errConn))
sendEvent("error", "未找到该 WebShell 连接", nil)
return
}
remark := conn.Remark
if remark == "" {
remark = conn.URL
}
finalMessage = fmt.Sprintf("[WebShell 助手上下文] 当前连接 ID:%s,备注:%s。可用工具(仅在该连接上操作时使用,connection_id 填 \"%s\"):webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write。请根据用户输入决定下一步:若仅为问候、闲聊或简单问题,直接简短回复即可,不必调用工具;当用户明确需要执行命令、列目录、读写字件等操作时再调用上述工具。\n\n用户请求:%s",
conn.ID, remark, conn.ID, req.Message)
roleTools = []string{builtin.ToolWebshellExec, builtin.ToolWebshellFileList, builtin.ToolWebshellFileRead, builtin.ToolWebshellFileWrite}
} else if req.Role != "" && req.Role != "默认" {
if h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
// 应用用户提示词
@@ -788,6 +827,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
}
// 注意:角色配置的skills不再硬编码注入,AI可以通过list_skills和read_skill工具按需调用
if len(role.Skills) > 0 {
roleSkills = role.Skills
h.logger.Info("角色配置了skillsAI可通过工具按需调用", zap.String("role", req.Role), zap.Int("skillCount", len(role.Skills)), zap.Strings("skills", role.Skills))
}
}
@@ -886,17 +926,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 执行Agent Loop,传入独立的上下文,确保任务不会因客户端断开而中断(使用包含角色提示词的finalMessage和角色工具列表)
sendEvent("progress", "正在分析您的请求...", nil)
// 注意:skills不会硬编码注入,但会在系统提示词中提示AI这个角色推荐使用哪些skills
var roleSkills []string // 角色配置的skills列表(用于提示AI,但不硬编码内容)
if req.Role != "" && req.Role != "默认" {
if h.config.Roles != nil {
if role, exists := h.config.Roles[req.Role]; exists && role.Enabled {
if len(role.Skills) > 0 {
roleSkills = role.Skills
}
}
}
}
// 注意:roleSkills 已在上方根据 req.Role 或 WebShell 模式设置
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
if err != nil {
h.logger.Error("Agent Loop执行失败", zap.Error(err))
+21
View File
@@ -28,6 +28,9 @@ type KnowledgeToolRegistrar func() error
// VulnerabilityToolRegistrar 漏洞工具注册器接口
type VulnerabilityToolRegistrar func() error
// WebshellToolRegistrar WebShell 工具注册器接口(ApplyConfig 时重新注册)
type WebshellToolRegistrar func() error
// SkillsToolRegistrar Skills工具注册器接口
type SkillsToolRegistrar func() error
@@ -60,6 +63,7 @@ type ConfigHandler struct {
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
knowledgeToolRegistrar KnowledgeToolRegistrar // 知识库工具注册器(可选)
vulnerabilityToolRegistrar VulnerabilityToolRegistrar // 漏洞工具注册器(可选)
webshellToolRegistrar WebshellToolRegistrar // WebShell 工具注册器(可选)
skillsToolRegistrar SkillsToolRegistrar // Skills工具注册器(可选)
retrieverUpdater RetrieverUpdater // 检索器更新器(可选)
knowledgeInitializer KnowledgeInitializer // 知识库初始化器(可选)
@@ -120,6 +124,13 @@ func (h *ConfigHandler) SetVulnerabilityToolRegistrar(registrar VulnerabilityToo
h.vulnerabilityToolRegistrar = registrar
}
// SetWebshellToolRegistrar 设置 WebShell 工具注册器
func (h *ConfigHandler) SetWebshellToolRegistrar(registrar WebshellToolRegistrar) {
h.mu.Lock()
defer h.mu.Unlock()
h.webshellToolRegistrar = registrar
}
// SetSkillsToolRegistrar 设置Skills工具注册器
func (h *ConfigHandler) SetSkillsToolRegistrar(registrar SkillsToolRegistrar) {
h.mu.Lock()
@@ -792,6 +803,16 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
}
}
// 重新注册 WebShell 工具(内置工具,必须注册)
if h.webshellToolRegistrar != nil {
h.logger.Info("重新注册 WebShell 工具")
if err := h.webshellToolRegistrar(); err != nil {
h.logger.Error("重新注册 WebShell 工具失败", zap.Error(err))
} else {
h.logger.Info("WebShell 工具已重新注册")
}
}
// 重新注册Skills工具(内置工具,必须注册)
if h.skillsToolRegistrar != nil {
h.logger.Info("重新注册Skills工具")
+152
View File
@@ -197,6 +197,53 @@ func (h *WebShellHandler) DeleteConnection(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// GetAIHistory 获取指定 WebShell 连接的 AI 助手对话历史(GET /api/webshell/connections/:id/ai-history
func (h *WebShellHandler) GetAIHistory(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
conv, err := h.db.GetConversationByWebshellConnectionID(id)
if err != nil {
h.logger.Warn("获取 WebShell AI 对话失败", zap.String("connectionId", id), zap.Error(err))
c.JSON(http.StatusOK, gin.H{"conversationId": nil, "messages": []database.Message{}})
return
}
if conv == nil {
c.JSON(http.StatusOK, gin.H{"conversationId": nil, "messages": []database.Message{}})
return
}
c.JSON(http.StatusOK, gin.H{"conversationId": conv.ID, "messages": conv.Messages})
}
// ListAIConversations 列出该 WebShell 连接下的所有 AI 对话(供侧边栏)
func (h *WebShellHandler) ListAIConversations(c *gin.Context) {
if h.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database not available"})
return
}
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
return
}
list, err := h.db.ListConversationsByWebshellConnectionID(id)
if err != nil {
h.logger.Warn("列出 WebShell AI 对话失败", zap.String("connectionId", id), zap.Error(err))
c.JSON(http.StatusOK, []database.WebShellConversationItem{})
return
}
if list == nil {
list = []database.WebShellConversationItem{}
}
c.JSON(http.StatusOK, list)
}
// ExecRequest 执行命令请求(前端传入连接信息 + 命令)
type ExecRequest struct {
URL string `json:"url" binding:"required"`
@@ -472,3 +519,108 @@ func (h *WebShellHandler) escapeForEcho(s string) string {
// 仅用于 write:base64 写入更安全,这里简单用单引号包裹
return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
}
// ExecWithConnection 在指定 WebShell 连接上执行命令(供 MCP/Agent 等非 HTTP 调用)
func (h *WebShellHandler) ExecWithConnection(conn *database.WebShellConnection, command string) (output string, ok bool, errMsg string) {
if conn == nil {
return "", false, "connection is nil"
}
command = strings.TrimSpace(command)
if command == "" {
return "", false, "command is required"
}
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
cmdParam := strings.TrimSpace(conn.CmdParam)
if cmdParam == "" {
cmdParam = "cmd"
}
var httpReq *http.Request
var err error
if useGET {
targetURL := h.buildExecURL(conn.URL, conn.Type, conn.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
} else {
body := h.buildExecBody(conn.Type, conn.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodPost, conn.URL, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if err != nil {
return "", false, err.Error()
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
return "", false, err.Error()
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return string(out), resp.StatusCode == http.StatusOK, ""
}
// FileOpWithConnection 在指定 WebShell 连接上执行文件操作(供 MCP/Agent 调用),支持 list / read / write
func (h *WebShellHandler) FileOpWithConnection(conn *database.WebShellConnection, action, path, content, targetPath string) (output string, ok bool, errMsg string) {
if conn == nil {
return "", false, "connection is nil"
}
action = strings.ToLower(strings.TrimSpace(action))
shellType := strings.ToLower(strings.TrimSpace(conn.Type))
if shellType == "" {
shellType = "php"
}
var command string
switch action {
case "list":
if path == "" {
path = "."
}
if shellType == "asp" || shellType == "aspx" {
command = "dir " + h.escapePath(strings.TrimSpace(path))
} else {
command = "ls -la " + h.escapePath(strings.TrimSpace(path))
}
case "read":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for read"
}
if shellType == "asp" || shellType == "aspx" {
command = "type " + h.escapePath(path)
} else {
command = "cat " + h.escapePath(path)
}
case "write":
path = strings.TrimSpace(path)
if path == "" {
return "", false, "path is required for write"
}
command = "echo " + h.escapeForEcho(content) + " > " + h.escapePath(path)
default:
return "", false, "unsupported action: " + action + " (supported: list, read, write)"
}
useGET := strings.ToUpper(strings.TrimSpace(conn.Method)) == "GET"
cmdParam := strings.TrimSpace(conn.CmdParam)
if cmdParam == "" {
cmdParam = "cmd"
}
var httpReq *http.Request
var err error
if useGET {
targetURL := h.buildExecURL(conn.URL, conn.Type, conn.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodGet, targetURL, nil)
} else {
body := h.buildExecBody(conn.Type, conn.Password, cmdParam, command)
httpReq, err = http.NewRequest(http.MethodPost, conn.URL, bytes.NewReader(body))
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if err != nil {
return "", false, err.Error()
}
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CyberStrikeAI-WebShell/1.0)")
resp, err := h.client.Do(httpReq)
if err != nil {
return "", false, err.Error()
}
defer resp.Body.Close()
out, _ := io.ReadAll(resp.Body)
return string(out), resp.StatusCode == http.StatusOK, ""
}
+15 -1
View File
@@ -13,6 +13,12 @@ const (
// Skills工具
ToolListSkills = "list_skills"
ToolReadSkill = "read_skill"
// WebShell 助手工具(AI 在 WebShell 管理 - AI 助手 中使用)
ToolWebshellExec = "webshell_exec"
ToolWebshellFileList = "webshell_file_list"
ToolWebshellFileRead = "webshell_file_read"
ToolWebshellFileWrite = "webshell_file_write"
)
// IsBuiltinTool 检查工具名称是否是内置工具
@@ -22,7 +28,11 @@ func IsBuiltinTool(toolName string) bool {
ToolListKnowledgeRiskTypes,
ToolSearchKnowledgeBase,
ToolListSkills,
ToolReadSkill:
ToolReadSkill,
ToolWebshellExec,
ToolWebshellFileList,
ToolWebshellFileRead,
ToolWebshellFileWrite:
return true
default:
return false
@@ -37,5 +47,9 @@ func GetAllBuiltinTools() []string {
ToolSearchKnowledgeBase,
ToolListSkills,
ToolReadSkill,
ToolWebshellExec,
ToolWebshellFileList,
ToolWebshellFileRead,
ToolWebshellFileWrite,
}
}
+241
View File
@@ -9092,6 +9092,247 @@ header {
min-width: 72px;
}
/* WebShell AI 助手 Tab:仅在此 pane 激活时显示;左右布局 = 左侧栏 + 右侧主区(与对话页一致) */
#webshell-pane-ai {
flex-direction: row;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* 不设 display,避免覆盖 .webshell-pane 的 display:none(否则终端/文件管理页会露出 AI 面板)。左右布局由 #webshell-pane-ai 的 flex-direction:row 提供 */
.webshell-pane-ai-with-sidebar {
flex: 1;
min-height: 0;
overflow: hidden;
width: 100%;
}
.webshell-ai-sidebar {
flex-shrink: 0;
width: 280px;
min-width: 200px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.webshell-ai-new-btn {
margin: 10px 12px;
width: calc(100% - 24px);
}
.webshell-ai-conv-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 0;
}
.webshell-ai-conv-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 0.9rem;
border-left: 3px solid transparent;
}
.webshell-ai-conv-item:hover {
background: var(--border-color);
}
.webshell-ai-conv-item.active {
background: var(--accent-light, rgba(59, 130, 246, 0.1));
border-left-color: var(--accent-color);
}
.webshell-ai-conv-item-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.webshell-ai-conv-item-date {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.webshell-ai-conv-del {
flex-shrink: 0;
padding: 2px 6px;
opacity: 0.6;
}
.webshell-ai-conv-item:hover .webshell-ai-conv-del {
opacity: 1;
}
.webshell-ai-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.webshell-ai-hint {
flex-shrink: 0;
padding: 10px 14px;
font-size: 0.9rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-color);
}
.webshell-ai-toolbar {
flex-shrink: 0;
padding: 6px 14px 8px;
display: flex;
gap: 8px;
border-bottom: 1px solid var(--border-color);
}
.webshell-ai-progress {
font-size: 0.85rem;
color: var(--text-secondary);
padding: 6px 12px;
margin: 0 0 4px 0;
border-radius: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
align-self: flex-start;
}
.webshell-ai-timeline {
display: none;
flex-direction: column;
gap: 6px;
width: 100%;
margin-bottom: 8px;
padding: 8px 12px;
border-radius: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.webshell-ai-timeline.has-items {
display: flex;
}
.webshell-ai-timeline-item {
font-size: 0.85rem;
color: var(--text-primary);
padding: 4px 0;
border-bottom: 1px solid var(--border-color);
}
.webshell-ai-timeline-item:last-child {
border-bottom: none;
}
.webshell-ai-timeline-title {
display: block;
font-weight: 500;
color: var(--text-secondary);
}
.webshell-ai-timeline-msg {
margin-top: 4px;
padding-left: 0;
font-size: 0.8rem;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
.webshell-ai-old-conv {
width: 100%;
margin-bottom: 8px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
overflow: hidden;
}
.webshell-ai-old-conv-label {
display: block;
width: 100%;
padding: 8px 12px;
text-align: left;
font-size: 0.9rem;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
}
.webshell-ai-old-conv-label:hover {
background: var(--border-color);
color: var(--text-primary);
}
.webshell-ai-old-conv-body {
padding: 0 12px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.webshell-ai-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.webshell-ai-msg {
padding: 10px 14px;
border-radius: 10px;
max-width: 90%;
white-space: pre-wrap;
word-break: break-word;
}
.webshell-ai-msg.user {
align-self: flex-end;
background: var(--accent-color);
color: #fff;
}
.webshell-ai-msg.assistant {
align-self: flex-start;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.webshell-ai-input-row {
flex-shrink: 0;
display: flex;
gap: 10px;
padding: 8px 14px;
border-top: 1px solid var(--border-color);
align-items: center;
}
.webshell-ai-input {
flex: 1;
height: 36px;
min-height: 36px;
resize: none;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--border-color);
font-size: 0.95rem;
/* 更柔和的滚动条样式 */
scrollbar-width: thin;
scrollbar-color: rgba(15, 23, 42, 0.25) transparent;
}
.webshell-ai-input:focus {
outline: none;
border-color: var(--accent-color);
}
.webshell-ai-input::-webkit-scrollbar {
width: 6px;
}
.webshell-ai-input::-webkit-scrollbar-track {
background: transparent;
}
.webshell-ai-input::-webkit-scrollbar-thumb {
background: rgba(15, 23, 42, 0.25);
border-radius: 999px;
}
.webshell-ai-input::-webkit-scrollbar-thumb:hover {
background: rgba(15, 23, 42, 0.4);
}
.webshell-ai-input-row .btn-primary {
flex-shrink: 0;
height: 36px;
min-width: 72px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
}
/* 仪表盘页面样式(最佳实践布局 + 视觉增强) */
.dashboard-page {
height: 100%;
+8
View File
@@ -355,6 +355,14 @@
"editConnectionTitle": "Edit connection",
"tabTerminal": "Virtual terminal",
"tabFileManager": "File manager",
"tabAiAssistant": "AI Assistant",
"aiSystemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.",
"aiNewConversation": "New conversation",
"aiPreviousConversation": "Previous conversation",
"aiDeleteConversation": "Delete conversation",
"aiDeleteConversationConfirm": "Delete this conversation?",
"aiPlaceholder": "e.g. List files in the current directory",
"aiSend": "Send",
"quickCommands": "Quick commands",
"downloadFile": "Download",
"terminalWelcome": "WebShell virtual terminal — type a command and press Enter (Ctrl+L clear)",
+8
View File
@@ -355,6 +355,14 @@
"editConnectionTitle": "编辑连接",
"tabTerminal": "虚拟终端",
"tabFileManager": "文件管理",
"tabAiAssistant": "AI 助手",
"aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
"aiNewConversation": "新对话",
"aiPreviousConversation": "之前的对话",
"aiDeleteConversation": "删除对话",
"aiDeleteConversationConfirm": "确定删除当前对话记录?",
"aiPlaceholder": "例如:列出当前目录下的文件",
"aiSend": "发送",
"quickCommands": "快捷命令",
"downloadFile": "下载",
"terminalWelcome": "WebShell 虚拟终端 — 输入命令后按回车执行(Ctrl+L 清屏)",
+379 -1
View File
@@ -20,6 +20,11 @@ let webshellHistoryIndex = -1;
const WEBSHELL_HISTORY_MAX = 100;
// 清屏防重入:一次点击只执行一次(避免多次绑定或重复触发导致多个 shell>)
let webshellClearInProgress = false;
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
let webshellAiConvMap = {};
let webshellAiSending = false;
// 流式打字机效果:当前会话的 response 序号,用于中止过期的打字
let webshellStreamingTypingId = 0;
// 从服务端(SQLite)拉取连接列表
function getWebshellConnections() {
@@ -61,6 +66,10 @@ function wsT(key) {
'webshell.editConnectionTitle': '编辑连接',
'webshell.tabTerminal': '虚拟终端',
'webshell.tabFileManager': '文件管理',
'webshell.tabAiAssistant': 'AI 助手',
'webshell.aiSystemReadyMessage': '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。',
'webshell.aiPlaceholder': '例如:列出当前目录下的文件',
'webshell.aiSend': '发送',
'webshell.terminalWelcome': 'WebShell 虚拟终端 — 输入命令后按回车执行(Ctrl+L 清屏)',
'webshell.quickCommands': '快捷命令',
'webshell.downloadFile': '下载',
@@ -268,6 +277,95 @@ function escapeHtml(s) {
return div.innerHTML;
}
function formatWebshellAiConvDate(updatedAt) {
if (!updatedAt) return '';
var d = typeof updatedAt === 'string' ? new Date(updatedAt) : updatedAt;
if (isNaN(d.getTime())) return '';
var now = new Date();
var sameDay = d.getDate() === now.getDate() && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear();
if (sameDay) return d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0');
return (d.getMonth() + 1) + '/' + d.getDate();
}
function fetchAndRenderWebshellAiConvList(conn, listEl) {
if (!conn || !conn.id || !listEl || typeof apiFetch !== 'function') return Promise.resolve();
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-conversations', { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (list) {
if (!Array.isArray(list)) list = [];
listEl.innerHTML = '';
list.forEach(function (item) {
var row = document.createElement('div');
row.className = 'webshell-ai-conv-item';
row.dataset.convId = item.id;
var title = (item.title || '').trim() || item.id.slice(0, 8);
var dateStr = item.updatedAt ? formatWebshellAiConvDate(item.updatedAt) : '';
row.innerHTML = '<span class="webshell-ai-conv-item-title">' + escapeHtml(title) + '</span><span class="webshell-ai-conv-item-date">' + escapeHtml(dateStr) + '</span>';
if (webshellAiConvMap[conn.id] === item.id) row.classList.add('active');
row.addEventListener('click', function () {
webshellAiConvListSelect(conn, item.id, document.getElementById('webshell-ai-messages'), listEl);
});
var delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn-ghost btn-sm webshell-ai-conv-del';
delBtn.textContent = '×';
delBtn.title = wsT('webshell.aiDeleteConversation') || '删除对话';
delBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (!confirm(wsT('webshell.aiDeleteConversationConfirm') || '确定删除该对话?')) return;
apiFetch('/api/conversations/' + encodeURIComponent(item.id), { method: 'DELETE' })
.then(function (r) {
if (r.ok) {
if (webshellAiConvMap[conn.id] === item.id) {
delete webshellAiConvMap[conn.id];
var msgs = document.getElementById('webshell-ai-messages');
if (msgs) msgs.innerHTML = '';
}
fetchAndRenderWebshellAiConvList(conn, listEl);
}
})
.catch(function (e) { console.warn('删除对话失败', e); });
});
row.appendChild(delBtn);
listEl.appendChild(row);
});
})
.catch(function (e) { console.warn('加载对话列表失败', e); });
}
function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
if (!conn || !convId || !messagesContainer) return;
webshellAiConvMap[conn.id] = convId;
if (listEl) listEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) {
el.classList.toggle('active', el.dataset.convId === convId);
});
if (typeof apiFetch !== 'function') return;
apiFetch('/api/conversations/' + encodeURIComponent(convId), { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (data) {
messagesContainer.innerHTML = '';
var list = data.messages || [];
list.forEach(function (msg) {
var role = (msg.role || '').toLowerCase();
var content = (msg.content || '').trim();
if (!content && role !== 'assistant') return;
var div = document.createElement('div');
div.className = 'webshell-ai-msg ' + (role === 'user' ? 'user' : 'assistant');
div.textContent = content;
messagesContainer.appendChild(div);
});
if (list.length === 0) {
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
var readyDiv = document.createElement('div');
readyDiv.className = 'webshell-ai-msg assistant';
readyDiv.textContent = readyMsg;
messagesContainer.appendChild(readyDiv);
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
})
.catch(function (e) { console.warn('加载对话失败', e); });
}
// 选择连接:渲染终端 + 文件管理 Tab,并初始化终端
function selectWebshell(id) {
currentWebshellId = id;
@@ -287,6 +385,7 @@ function selectWebshell(id) {
'<div class="webshell-tabs">' +
'<button type="button" class="webshell-tab active" data-tab="terminal">' + wsT('webshell.tabTerminal') + '</button>' +
'<button type="button" class="webshell-tab" data-tab="file">' + wsT('webshell.tabFileManager') + '</button>' +
'<button type="button" class="webshell-tab" data-tab="ai">' + (wsT('webshell.tabAiAssistant') || 'AI 助手') + '</button>' +
'</div>' +
'<div id="webshell-pane-terminal" class="webshell-pane active">' +
'<div class="webshell-terminal-toolbar">' +
@@ -321,6 +420,19 @@ function selectWebshell(id) {
'<button type="button" class="btn-ghost" id="webshell-batch-download-btn">' + (wsT('webshell.batchDownload') || '批量下载') + '</button>' +
'</div>' +
'<div id="webshell-file-list" class="webshell-file-list"></div>' +
'</div>' +
'<div id="webshell-pane-ai" class="webshell-pane webshell-pane-ai-with-sidebar">' +
'<div class="webshell-ai-sidebar">' +
'<button type="button" class="btn-primary btn-sm webshell-ai-new-btn" id="webshell-ai-new-conv">' + (wsT('webshell.aiNewConversation') || '新对话') + '</button>' +
'<div class="webshell-ai-conv-list" id="webshell-ai-conv-list"></div>' +
'</div>' +
'<div class="webshell-ai-main">' +
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
'<div class="webshell-ai-input-row">' +
'<textarea id="webshell-ai-input" class="webshell-ai-input form-control" rows="2" placeholder="' + (wsT('webshell.aiPlaceholder') || '例如:列出当前目录下的文件') + '"></textarea>' +
'<button type="button" class="btn-primary" id="webshell-ai-send">' + (wsT('webshell.aiSend') || '发送') + '</button>' +
'</div>' +
'</div>' +
'</div>';
// Tab 切换
@@ -376,9 +488,251 @@ function selectWebshell(id) {
document.getElementById('webshell-batch-delete-btn').addEventListener('click', function () { webshellBatchDelete(webshellCurrentConn, pathInput); });
document.getElementById('webshell-batch-download-btn').addEventListener('click', function () { webshellBatchDownload(webshellCurrentConn, pathInput); });
// AI 助手:侧边栏对话列表 + 主区消息
var aiInput = document.getElementById('webshell-ai-input');
var aiSendBtn = document.getElementById('webshell-ai-send');
var aiMessages = document.getElementById('webshell-ai-messages');
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
if (aiNewConvBtn) {
aiNewConvBtn.addEventListener('click', function () {
delete webshellAiConvMap[conn.id];
if (aiMessages) {
aiMessages.innerHTML = '';
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
var div = document.createElement('div');
div.className = 'webshell-ai-msg assistant';
div.textContent = readyMsg;
aiMessages.appendChild(div);
}
if (aiConvListEl) aiConvListEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) { el.classList.remove('active'); });
});
}
if (aiSendBtn && aiInput && aiMessages) {
aiSendBtn.addEventListener('click', function () { runWebshellAiSend(conn, aiInput, aiSendBtn, aiMessages); });
aiInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
runWebshellAiSend(conn, aiInput, aiSendBtn, aiMessages);
}
});
fetchAndRenderWebshellAiConvList(conn, aiConvListEl).then(function () {
loadWebshellAiHistory(conn, aiMessages).then(function () {
if (webshellAiConvMap[conn.id] && aiConvListEl) {
aiConvListEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) {
el.classList.toggle('active', el.dataset.convId === webshellAiConvMap[conn.id]);
});
}
});
});
}
initWebshellTerminal(conn);
}
// 加载 WebShell 连接的 AI 助手对话历史(持久化展示),返回 Promise 供 .then 更新工具栏等
function loadWebshellAiHistory(conn, messagesContainer) {
if (!conn || !conn.id || !messagesContainer) return Promise.resolve();
if (typeof apiFetch !== 'function') return Promise.resolve();
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-history', { method: 'GET' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.conversationId) webshellAiConvMap[conn.id] = data.conversationId;
var list = Array.isArray(data.messages) ? data.messages : [];
list.forEach(function (msg) {
var role = (msg.role || '').toLowerCase();
var content = (msg.content || '').trim();
if (!content && role !== 'assistant') return;
var div = document.createElement('div');
div.className = 'webshell-ai-msg ' + (role === 'user' ? 'user' : 'assistant');
div.textContent = content;
messagesContainer.appendChild(div);
});
if (list.length === 0) {
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
var readyDiv = document.createElement('div');
readyDiv.className = 'webshell-ai-msg assistant';
readyDiv.textContent = readyMsg;
messagesContainer.appendChild(readyDiv);
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
})
.catch(function (e) {
console.warn('加载 WebShell AI 历史失败', conn.id, e);
});
}
function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
if (!conn || !conn.id) return;
var message = (inputEl && inputEl.value || '').trim();
if (!message) return;
if (webshellAiSending) return;
if (typeof apiFetch !== 'function') {
if (messagesContainer) {
var errDiv = document.createElement('div');
errDiv.className = 'webshell-ai-msg assistant';
errDiv.textContent = '无法发送:未登录或 apiFetch 不可用';
messagesContainer.appendChild(errDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
return;
}
webshellAiSending = true;
if (sendBtn) sendBtn.disabled = true;
var userDiv = document.createElement('div');
userDiv.className = 'webshell-ai-msg user';
userDiv.textContent = message;
messagesContainer.appendChild(userDiv);
var timelineContainer = document.createElement('div');
timelineContainer.className = 'webshell-ai-timeline';
timelineContainer.setAttribute('aria-live', 'polite');
var assistantDiv = document.createElement('div');
assistantDiv.className = 'webshell-ai-msg assistant';
assistantDiv.textContent = '…';
messagesContainer.appendChild(timelineContainer);
messagesContainer.appendChild(assistantDiv);
messagesContainer.scrollTop = messagesContainer.scrollHeight;
function appendTimelineItem(type, title, message) {
var item = document.createElement('div');
item.className = 'webshell-ai-timeline-item webshell-ai-timeline-' + type;
item.innerHTML = '<span class="webshell-ai-timeline-title">' + escapeHtml(title || message || '') + '</span>';
if (message && message !== title) item.innerHTML += '<div class="webshell-ai-timeline-msg">' + escapeHtml(message) + '</div>';
timelineContainer.appendChild(item);
timelineContainer.classList.add('has-items');
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
if (inputEl) inputEl.value = '';
var convId = webshellAiConvMap[conn.id] || '';
var body = {
message: message,
webshellConnectionId: conn.id,
conversationId: convId
};
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果)
var streamingTypingId = 0; // 防重入,每次新 response 自增
apiFetch('/api/agent-loop/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(function (response) {
if (!response.ok) {
assistantDiv.textContent = '请求失败: ' + response.status;
return;
}
return response.body.getReader();
}).then(function (reader) {
if (!reader) return;
var decoder = new TextDecoder();
var buffer = '';
return reader.read().then(function processChunk(result) {
if (result.done) return;
buffer += decoder.decode(result.value, { stream: true });
var lines = buffer.split('\n');
buffer = lines.pop() || '';
for (var i = 0; i < lines.length; i++) {
var line = lines[i];
if (line.indexOf('data: ') !== 0) continue;
try {
var eventData = JSON.parse(line.slice(6));
if (eventData.type === 'conversation' && eventData.data && eventData.data.conversationId) {
webshellAiConvMap[conn.id] = eventData.data.conversationId;
var listEl = document.getElementById('webshell-ai-conv-list');
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
listEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) {
el.classList.toggle('active', el.dataset.convId === eventData.data.conversationId);
});
});
} else if (eventData.type === 'response') {
var text = (eventData.message != null && eventData.message !== '') ? eventData.message : (eventData.data && typeof eventData.data === 'string' ? eventData.data : '');
if (text) {
streamingTarget += text;
webshellStreamingTypingId += 1;
streamingTypingId = webshellStreamingTypingId;
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
}
} else if (eventData.type === 'error' && eventData.message) {
streamingTypingId += 1;
appendTimelineItem('error', '❌ 错误', eventData.message);
assistantDiv.textContent = '错误: ' + eventData.message;
} else if (eventData.type === 'progress' && eventData.message) {
appendTimelineItem('progress', '🔍 ' + eventData.message, '');
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'iteration') {
var iterN = (eventData.data && eventData.data.iteration) || 0;
var iterTitle = iterN ? '🔍 第 ' + iterN + ' 轮迭代' : ('🔍 ' + (eventData.message || '迭代'));
appendTimelineItem('iteration', iterTitle, eventData.message || '');
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'thinking' && eventData.message) {
appendTimelineItem('thinking', '🤔 AI 思考', eventData.message);
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'tool_calls_detected' && eventData.data) {
var count = eventData.data.count || 0;
appendTimelineItem('tool_calls_detected', '🔧 检测到 ' + count + ' 个工具调用', eventData.message || '');
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'tool_call' && eventData.data) {
var d = eventData.data;
var tn = d.toolName || '未知工具';
var idx = d.index || 0;
var total = d.total || 0;
var title = '🔧 调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '');
appendTimelineItem('tool_call', title, eventData.message || '');
if (!streamingTarget) assistantDiv.textContent = '…';
} else if (eventData.type === 'tool_result' && eventData.data) {
var dr = eventData.data;
var success = dr.success !== false;
var tname = dr.toolName || '工具';
var title = (success ? '✅ ' : '❌ ') + tname + (success ? ' 执行完成' : ' 执行失败');
var sub = eventData.message || (dr.result ? String(dr.result).slice(0, 300) : '');
appendTimelineItem('tool_result', title, sub);
if (!streamingTarget) assistantDiv.textContent = '…';
}
} catch (e) { /* ignore parse error */ }
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
return reader.read().then(processChunk);
});
}).catch(function (err) {
assistantDiv.textContent = '请求异常: ' + (err && err.message ? err.message : String(err));
}).then(function () {
webshellAiSending = false;
if (sendBtn) sendBtn.disabled = false;
if (assistantDiv.textContent === '…' && !streamingTarget) assistantDiv.textContent = '无回复内容';
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
}
// 打字机效果:将 target 逐字/逐段写入 el,保证只生效于当前 id 的调用
function runWebshellAiStreamingTyping(el, target, id, scrollContainer) {
if (!el || id === undefined) return;
var chunkSize = 3;
var delayMs = 24;
function tick() {
if (id !== webshellStreamingTypingId) return;
var cur = el.textContent || '';
if (cur.length >= target.length) {
el.textContent = target;
if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight;
return;
}
var next = target.slice(0, cur.length + chunkSize);
el.textContent = next;
if (scrollContainer) scrollContainer.scrollTop = scrollContainer.scrollHeight;
setTimeout(tick, delayMs);
}
if (el.textContent.length < target.length) setTimeout(tick, delayMs);
}
function getWebshellHistory(connId) {
if (!connId) return [];
if (!webshellHistoryByConn[connId]) webshellHistoryByConn[connId] = [];
@@ -1058,7 +1412,6 @@ function refreshWebshellUIOnLanguageChange() {
if (page !== 'webshell') return;
renderWebshellList();
var workspace = document.getElementById('webshell-workspace');
if (workspace) {
if (!currentWebshellId || !webshellCurrentConn) {
@@ -1067,8 +1420,10 @@ function refreshWebshellUIOnLanguageChange() {
// 只更新标签文案,不重建终端
var tabTerminal = workspace.querySelector('.webshell-tab[data-tab="terminal"]');
var tabFile = workspace.querySelector('.webshell-tab[data-tab="file"]');
var tabAi = workspace.querySelector('.webshell-tab[data-tab="ai"]');
if (tabTerminal) tabTerminal.textContent = wsT('webshell.tabTerminal');
if (tabFile) tabFile.textContent = wsT('webshell.tabFileManager');
if (tabAi) tabAi.textContent = wsT('webshell.tabAiAssistant') || 'AI 助手';
var quickLabel = workspace.querySelector('.webshell-quick-label');
if (quickLabel) quickLabel.textContent = (wsT('webshell.quickCommands') || '快捷命令') + ':';
@@ -1094,6 +1449,29 @@ function refreshWebshellUIOnLanguageChange() {
if (batchDownloadBtn) batchDownloadBtn.textContent = wsT('webshell.batchDownload') || '批量下载';
if (filterInput) filterInput.placeholder = wsT('webshell.filterPlaceholder') || '过滤文件名';
// AI 助手区域文案:Tab 内按钮、占位符、系统就绪提示
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
if (aiNewConvBtn) aiNewConvBtn.textContent = wsT('webshell.aiNewConversation') || '新对话';
var aiInput = document.getElementById('webshell-ai-input');
if (aiInput) aiInput.placeholder = wsT('webshell.aiPlaceholder') || '例如:列出当前目录下的文件';
var aiSendBtn = document.getElementById('webshell-ai-send');
if (aiSendBtn) aiSendBtn.textContent = wsT('webshell.aiSend') || '发送';
// 如果当前 AI 对话区只有系统就绪提示(没有用户消息),用当前语言重置这条提示
var aiMessages = document.getElementById('webshell-ai-messages');
if (aiMessages) {
var hasUserMsg = !!aiMessages.querySelector('.webshell-ai-msg.user');
var msgNodes = aiMessages.querySelectorAll('.webshell-ai-msg');
if (!hasUserMsg && msgNodes.length <= 1) {
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
aiMessages.innerHTML = '';
var readyDiv = document.createElement('div');
readyDiv.className = 'webshell-ai-msg assistant';
readyDiv.textContent = readyMsg;
aiMessages.appendChild(readyDiv);
}
}
var pathInput = document.getElementById('webshell-file-path');
var fileListEl = document.getElementById('webshell-file-list');
if (fileListEl && webshellCurrentConn && pathInput) {