mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 971a2d35cb | |||
| ff25d6e9ec | |||
| c247e8405d | |||
| 6c71c090b5 | |||
| 0d262cb30b | |||
| 5b82924035 | |||
| 7f32360096 | |||
| 6ffd084135 | |||
| 0e763cfd98 | |||
| 711eda935e | |||
| 42d5489993 | |||
| 5bc7a54118 | |||
| e41d19fffe | |||
| 1e222efe29 | |||
| 1c394acd4a | |||
| 5e29a6e9b7 | |||
| cce64e213f | |||
| 80de8cf748 |
@@ -31,49 +31,55 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
|||||||
<img src="./images/web-console.png" alt="Web Console" width="100%">
|
<img src="./images/web-console.png" alt="Web Console" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>Attack Chain Visualization</strong><br/>
|
|
||||||
<img src="./images/attack-chain.png" alt="Attack Chain" width="100%">
|
|
||||||
</td>
|
|
||||||
<td width="33.33%" align="center">
|
|
||||||
<strong>Task Management</strong><br/>
|
<strong>Task Management</strong><br/>
|
||||||
<img src="./images/task-management.png" alt="Task Management" width="100%">
|
<img src="./images/task-management.png" alt="Task Management" width="100%">
|
||||||
</td>
|
</td>
|
||||||
|
<td width="33.33%" align="center">
|
||||||
|
<strong>Vulnerability Management</strong><br/>
|
||||||
|
<img src="./images/vulnerability-management.png" alt="Vulnerability Management" width="100%">
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>Vulnerability Management</strong><br/>
|
<strong>WebShell Management</strong><br/>
|
||||||
<img src="./images/vulnerability-management.png" alt="Vulnerability Management" width="100%">
|
<img src="./images/webshell-management.png" alt="WebShell Management" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>MCP Management</strong><br/>
|
<strong>MCP Management</strong><br/>
|
||||||
<img src="./images/mcp-management.png" alt="MCP management" width="100%">
|
<img src="./images/mcp-management.png" alt="MCP management" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>MCP stdio Mode</strong><br/>
|
<strong>Knowledge Base</strong><br/>
|
||||||
<img src="./images/mcp-stdio2.png" alt="MCP stdio mode" width="100%">
|
<img src="./images/knowledge-base.png" alt="Knowledge Base" width="100%">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>Knowledge Base</strong><br/>
|
|
||||||
<img src="./images/knowledge-base.png" alt="Knowledge Base" width="100%">
|
|
||||||
</td>
|
|
||||||
<td width="33.33%" align="center">
|
|
||||||
<strong>Skills Management</strong><br/>
|
<strong>Skills Management</strong><br/>
|
||||||
<img src="./images/skills.png" alt="Skills Management" width="100%">
|
<img src="./images/skills.png" alt="Skills Management" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
|
<strong>Agent Management</strong><br/>
|
||||||
|
<img src="./images/agent-management.png" alt="Agent Management" width="100%">
|
||||||
|
</td>
|
||||||
|
<td width="33.33%" align="center">
|
||||||
<strong>Role Management</strong><br/>
|
<strong>Role Management</strong><br/>
|
||||||
<img src="./images/role-management.png" alt="Role Management" width="100%">
|
<img src="./images/role-management.png" alt="Role Management" width="100%">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>WebShell Management</strong><br/>
|
<strong>System Settings</strong><br/>
|
||||||
<img src="./images/webshell-management.png" alt="WebShell Management" width="100%">
|
<img src="./images/settings.png" alt="System settings" width="100%">
|
||||||
|
</td>
|
||||||
|
<td width="33.33%" align="center">
|
||||||
|
<strong>MCP stdio Mode</strong><br/>
|
||||||
|
<img src="./images/mcp-stdio2.png" alt="MCP stdio mode" width="100%">
|
||||||
|
</td>
|
||||||
|
<td width="33.33%" align="center">
|
||||||
|
<strong>Burp Suite Plugin</strong><br/>
|
||||||
|
<img src="./images/plugins.png" alt="Burp Suite plugin" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center"></td>
|
|
||||||
<td width="33.33%" align="center"></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -97,6 +103,14 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
|||||||
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
|
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
|
||||||
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
|
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
CyberStrikeAI includes optional integrations under `plugins/`.
|
||||||
|
|
||||||
|
- **Burp Suite extension**: `plugins/burp-suite/cyberstrikeai-burp-extension/`
|
||||||
|
Build output: `plugins/burp-suite/cyberstrikeai-burp-extension/dist/cyberstrikeai-burp-extension.jar`
|
||||||
|
Docs: `plugins/burp-suite/cyberstrikeai-burp-extension/README.md`
|
||||||
|
|
||||||
## Tool Overview
|
## Tool Overview
|
||||||
|
|
||||||
CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
|
CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
|
||||||
|
|||||||
+30
-16
@@ -30,49 +30,55 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
|||||||
<img src="./images/web-console.png" alt="Web 控制台" width="100%">
|
<img src="./images/web-console.png" alt="Web 控制台" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>攻击链可视化</strong><br/>
|
|
||||||
<img src="./images/attack-chain.png" alt="攻击链" width="100%">
|
|
||||||
</td>
|
|
||||||
<td width="33.33%" align="center">
|
|
||||||
<strong>任务管理</strong><br/>
|
<strong>任务管理</strong><br/>
|
||||||
<img src="./images/task-management.png" alt="任务管理" width="100%">
|
<img src="./images/task-management.png" alt="任务管理" width="100%">
|
||||||
</td>
|
</td>
|
||||||
|
<td width="33.33%" align="center">
|
||||||
|
<strong>漏洞管理</strong><br/>
|
||||||
|
<img src="./images/vulnerability-management.png" alt="漏洞管理" width="100%">
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>漏洞管理</strong><br/>
|
<strong>WebShell 管理</strong><br/>
|
||||||
<img src="./images/vulnerability-management.png" alt="漏洞管理" width="100%">
|
<img src="./images/webshell-management.png" alt="WebShell 管理" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>MCP 管理</strong><br/>
|
<strong>MCP 管理</strong><br/>
|
||||||
<img src="./images/mcp-management.png" alt="MCP 管理" width="100%">
|
<img src="./images/mcp-management.png" alt="MCP 管理" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>MCP stdio 模式</strong><br/>
|
<strong>知识库</strong><br/>
|
||||||
<img src="./images/mcp-stdio2.png" alt="MCP stdio 模式" width="100%">
|
<img src="./images/knowledge-base.png" alt="知识库" width="100%">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>知识库</strong><br/>
|
|
||||||
<img src="./images/knowledge-base.png" alt="知识库" width="100%">
|
|
||||||
</td>
|
|
||||||
<td width="33.33%" align="center">
|
|
||||||
<strong>Skills 管理</strong><br/>
|
<strong>Skills 管理</strong><br/>
|
||||||
<img src="./images/skills.png" alt="Skills 管理" width="100%">
|
<img src="./images/skills.png" alt="Skills 管理" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
|
<strong>Agent 管理</strong><br/>
|
||||||
|
<img src="./images/agent-management.png" alt="Agent 管理" width="100%">
|
||||||
|
</td>
|
||||||
|
<td width="33.33%" align="center">
|
||||||
<strong>角色管理</strong><br/>
|
<strong>角色管理</strong><br/>
|
||||||
<img src="./images/role-management.png" alt="角色管理" width="100%">
|
<img src="./images/role-management.png" alt="角色管理" width="100%">
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td width="33.33%" align="center">
|
<td width="33.33%" align="center">
|
||||||
<strong>WebShell 管理</strong><br/>
|
<strong>系统设置</strong><br/>
|
||||||
<img src="./images/webshell-management.png" alt="WebShell 管理" width="100%">
|
<img src="./images/settings.png" alt="系统设置" width="100%">
|
||||||
|
</td>
|
||||||
|
<td width="33.33%" align="center">
|
||||||
|
<strong>MCP stdio 模式</strong><br/>
|
||||||
|
<img src="./images/mcp-stdio2.png" alt="MCP stdio 模式" width="100%">
|
||||||
|
</td>
|
||||||
|
<td width="33.33%" align="center">
|
||||||
|
<strong>Burp Suite 插件</strong><br/>
|
||||||
|
<img src="./images/plugins.png" alt="Burp Suite 插件" width="100%">
|
||||||
</td>
|
</td>
|
||||||
<td width="33.33%" align="center"></td>
|
|
||||||
<td width="33.33%" align="center"></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -96,6 +102,14 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
|||||||
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
||||||
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
||||||
|
|
||||||
|
## 插件(Plugins)
|
||||||
|
|
||||||
|
可选集成在 `plugins/` 目录下。
|
||||||
|
|
||||||
|
- **Burp Suite 插件**:`plugins/burp-suite/cyberstrikeai-burp-extension/`
|
||||||
|
构建产物:`plugins/burp-suite/cyberstrikeai-burp-extension/dist/cyberstrikeai-burp-extension.jar`
|
||||||
|
说明文档:`plugins/burp-suite/cyberstrikeai-burp-extension/README.zh-CN.md`
|
||||||
|
|
||||||
## 工具概览
|
## 工具概览
|
||||||
|
|
||||||
系统预置 100+ 渗透/攻防工具,覆盖完整攻击链:
|
系统预置 100+ 渗透/攻防工具,覆盖完整攻击链:
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.4.2"
|
version: "v1.4.3"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 627 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 508 KiB |
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/storage"
|
"cyberstrike-ai/internal/storage"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -336,6 +338,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
||||||
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
|
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
|
||||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||||
|
registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger)
|
||||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||||
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
||||||
@@ -384,6 +387,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
// 设置 WebShell 工具注册器(ApplyConfig 时重新注册)
|
// 设置 WebShell 工具注册器(ApplyConfig 时重新注册)
|
||||||
webshellRegistrar := func() error {
|
webshellRegistrar := func() error {
|
||||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||||
|
registerWebshellManagementTools(mcpServer, db, webshellHandler, log.Logger)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
configHandler.SetWebshellToolRegistrar(webshellRegistrar)
|
configHandler.SetWebshellToolRegistrar(webshellRegistrar)
|
||||||
@@ -1270,6 +1274,367 @@ func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandl
|
|||||||
logger.Info("WebShell 工具注册成功")
|
logger.Info("WebShell 工具注册成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registerWebshellManagementTools 注册 WebShell 连接管理 MCP 工具
|
||||||
|
func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, webshellHandler *handler.WebShellHandler, logger *zap.Logger) {
|
||||||
|
if db == nil {
|
||||||
|
logger.Warn("跳过 WebShell 管理工具注册:db 为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// manage_webshell_list - 列出所有 webshell 连接
|
||||||
|
listTool := mcp.Tool{
|
||||||
|
Name: builtin.ToolManageWebshellList,
|
||||||
|
Description: "列出所有已保存的 WebShell 连接,返回连接ID、URL、类型、备注等信息。",
|
||||||
|
ShortDescription: "列出所有 WebShell 连接",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
listHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
connections, err := db.ListWebshellConnections()
|
||||||
|
if err != nil {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "获取连接列表失败: " + err.Error()}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
if len(connections) == 0 {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "暂无 WebShell 连接"}},
|
||||||
|
IsError: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("找到 %d 个 WebShell 连接:\n\n", len(connections)))
|
||||||
|
for _, conn := range connections {
|
||||||
|
sb.WriteString(fmt.Sprintf("ID: %s\n", conn.ID))
|
||||||
|
sb.WriteString(fmt.Sprintf(" URL: %s\n", conn.URL))
|
||||||
|
sb.WriteString(fmt.Sprintf(" 类型: %s\n", conn.Type))
|
||||||
|
sb.WriteString(fmt.Sprintf(" 请求方式: %s\n", conn.Method))
|
||||||
|
sb.WriteString(fmt.Sprintf(" 命令参数: %s\n", conn.CmdParam))
|
||||||
|
if conn.Remark != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" 备注: %s\n", conn.Remark))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" 创建时间: %s\n", conn.CreatedAt.Format("2006-01-02 15:04:05")))
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: sb.String()}},
|
||||||
|
IsError: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
mcpServer.RegisterTool(listTool, listHandler)
|
||||||
|
|
||||||
|
// manage_webshell_add - 添加新的 webshell 连接
|
||||||
|
addTool := mcp.Tool{
|
||||||
|
Name: builtin.ToolManageWebshellAdd,
|
||||||
|
Description: "添加新的 WebShell 连接到管理系统。支持 PHP、ASP、ASPX、JSP 等类型的一句话木马。",
|
||||||
|
ShortDescription: "添加 WebShell 连接",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"url": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Shell 地址,如 http://target.com/shell.php(必填)",
|
||||||
|
},
|
||||||
|
"password": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "连接密码/密钥,如冰蝎/蚁剑的连接密码",
|
||||||
|
},
|
||||||
|
"type": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Shell 类型:php、asp、aspx、jsp,默认为 php",
|
||||||
|
"enum": []string{"php", "asp", "aspx", "jsp"},
|
||||||
|
},
|
||||||
|
"method": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "请求方式:GET 或 POST,默认为 POST",
|
||||||
|
"enum": []string{"GET", "POST"},
|
||||||
|
},
|
||||||
|
"cmd_param": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "命令参数名,不填默认为 cmd",
|
||||||
|
},
|
||||||
|
"remark": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "备注,便于识别的备注名",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"url"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
addHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
urlStr, _ := args["url"].(string)
|
||||||
|
if urlStr == "" {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "错误: url 参数必填"}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
password, _ := args["password"].(string)
|
||||||
|
shellType, _ := args["type"].(string)
|
||||||
|
if shellType == "" {
|
||||||
|
shellType = "php"
|
||||||
|
}
|
||||||
|
method, _ := args["method"].(string)
|
||||||
|
if method == "" {
|
||||||
|
method = "post"
|
||||||
|
}
|
||||||
|
cmdParam, _ := args["cmd_param"].(string)
|
||||||
|
if cmdParam == "" {
|
||||||
|
cmdParam = "cmd"
|
||||||
|
}
|
||||||
|
remark, _ := args["remark"].(string)
|
||||||
|
|
||||||
|
// 生成连接ID
|
||||||
|
connID := "ws_" + strings.ReplaceAll(uuid.New().String(), "-", "")[:12]
|
||||||
|
conn := &database.WebShellConnection{
|
||||||
|
ID: connID,
|
||||||
|
URL: urlStr,
|
||||||
|
Password: password,
|
||||||
|
Type: strings.ToLower(shellType),
|
||||||
|
Method: strings.ToLower(method),
|
||||||
|
CmdParam: cmdParam,
|
||||||
|
Remark: remark,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.CreateWebshellConnection(conn); err != nil {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "添加 WebShell 连接失败: " + err.Error()}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{
|
||||||
|
Type: "text",
|
||||||
|
Text: fmt.Sprintf("WebShell 连接添加成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s", conn.ID, conn.URL, conn.Type, conn.Method, conn.CmdParam),
|
||||||
|
}},
|
||||||
|
IsError: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
mcpServer.RegisterTool(addTool, addHandler)
|
||||||
|
|
||||||
|
// manage_webshell_update - 更新 webshell 连接
|
||||||
|
updateTool := mcp.Tool{
|
||||||
|
Name: builtin.ToolManageWebshellUpdate,
|
||||||
|
Description: "更新已存在的 WebShell 连接信息。",
|
||||||
|
ShortDescription: "更新 WebShell 连接",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"connection_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要更新的 WebShell 连接 ID(必填)",
|
||||||
|
},
|
||||||
|
"url": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "新的 Shell 地址",
|
||||||
|
},
|
||||||
|
"password": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "新的连接密码/密钥",
|
||||||
|
},
|
||||||
|
"type": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "新的 Shell 类型:php、asp、aspx、jsp",
|
||||||
|
"enum": []string{"php", "asp", "aspx", "jsp"},
|
||||||
|
},
|
||||||
|
"method": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "新的请求方式:GET 或 POST",
|
||||||
|
"enum": []string{"GET", "POST"},
|
||||||
|
},
|
||||||
|
"cmd_param": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "新的命令参数名",
|
||||||
|
},
|
||||||
|
"remark": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "新的备注",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"connection_id"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
updateHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
connID, _ := args["connection_id"].(string)
|
||||||
|
if connID == "" {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "错误: connection_id 参数必填"}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取现有连接
|
||||||
|
existing, err := db.GetWebshellConnection(connID)
|
||||||
|
if err != nil || existing == nil {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "未找到指定的 WebShell 连接: " + connID}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段(如果提供了新值)
|
||||||
|
if urlStr, ok := args["url"].(string); ok && urlStr != "" {
|
||||||
|
existing.URL = urlStr
|
||||||
|
}
|
||||||
|
if password, ok := args["password"].(string); ok {
|
||||||
|
existing.Password = password
|
||||||
|
}
|
||||||
|
if shellType, ok := args["type"].(string); ok && shellType != "" {
|
||||||
|
existing.Type = strings.ToLower(shellType)
|
||||||
|
}
|
||||||
|
if method, ok := args["method"].(string); ok && method != "" {
|
||||||
|
existing.Method = strings.ToLower(method)
|
||||||
|
}
|
||||||
|
if cmdParam, ok := args["cmd_param"].(string); ok && cmdParam != "" {
|
||||||
|
existing.CmdParam = cmdParam
|
||||||
|
}
|
||||||
|
if remark, ok := args["remark"].(string); ok {
|
||||||
|
existing.Remark = remark
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.UpdateWebshellConnection(existing); err != nil {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "更新 WebShell 连接失败: " + err.Error()}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{
|
||||||
|
Type: "text",
|
||||||
|
Text: fmt.Sprintf("WebShell 连接更新成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n请求方式: %s\n命令参数: %s\n备注: %s", existing.ID, existing.URL, existing.Type, existing.Method, existing.CmdParam, existing.Remark),
|
||||||
|
}},
|
||||||
|
IsError: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
mcpServer.RegisterTool(updateTool, updateHandler)
|
||||||
|
|
||||||
|
// manage_webshell_delete - 删除 webshell 连接
|
||||||
|
deleteTool := mcp.Tool{
|
||||||
|
Name: builtin.ToolManageWebshellDelete,
|
||||||
|
Description: "删除指定的 WebShell 连接。",
|
||||||
|
ShortDescription: "删除 WebShell 连接",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"connection_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要删除的 WebShell 连接 ID(必填)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"connection_id"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
deleteHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
connID, _ := args["connection_id"].(string)
|
||||||
|
if connID == "" {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "错误: connection_id 参数必填"}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.DeleteWebshellConnection(connID); err != nil {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "删除 WebShell 连接失败: " + err.Error()}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{
|
||||||
|
Type: "text",
|
||||||
|
Text: fmt.Sprintf("WebShell 连接 %s 已成功删除", connID),
|
||||||
|
}},
|
||||||
|
IsError: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
mcpServer.RegisterTool(deleteTool, deleteHandler)
|
||||||
|
|
||||||
|
// manage_webshell_test - 测试 webshell 连接
|
||||||
|
testTool := mcp.Tool{
|
||||||
|
Name: builtin.ToolManageWebshellTest,
|
||||||
|
Description: "测试指定的 WebShell 连接是否可用,会尝试执行一个简单的命令(如 whoami 或 dir)。",
|
||||||
|
ShortDescription: "测试 WebShell 连接",
|
||||||
|
InputSchema: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"connection_id": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "要测试的 WebShell 连接 ID(必填)",
|
||||||
|
},
|
||||||
|
"command": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "测试命令,默认为 whoami(Linux)或 dir(Windows)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"connection_id"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
testHandler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
|
connID, _ := args["connection_id"].(string)
|
||||||
|
if connID == "" {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "错误: connection_id 参数必填"}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取连接
|
||||||
|
conn, err := db.GetWebshellConnection(connID)
|
||||||
|
if err != nil || conn == nil {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: "未找到指定的 WebShell 连接: " + connID}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定测试命令
|
||||||
|
testCmd, _ := args["command"].(string)
|
||||||
|
if testCmd == "" {
|
||||||
|
// 根据 shell 类型选择默认命令
|
||||||
|
if conn.Type == "asp" || conn.Type == "aspx" {
|
||||||
|
testCmd = "dir"
|
||||||
|
} else {
|
||||||
|
testCmd = "whoami"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行测试命令
|
||||||
|
output, ok, errMsg := webshellHandler.ExecWithConnection(conn, testCmd)
|
||||||
|
if errMsg != "" {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("连接测试失败!\n\n连接ID: %s\nURL: %s\n错误: %s", connID, conn.URL, errMsg)}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("连接测试失败!HTTP 非 200\n\n连接ID: %s\nURL: %s\n输出: %s", connID, conn.URL, output)}},
|
||||||
|
IsError: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mcp.ToolResult{
|
||||||
|
Content: []mcp.Content{{
|
||||||
|
Type: "text",
|
||||||
|
Text: fmt.Sprintf("连接测试成功!\n\n连接ID: %s\nURL: %s\n类型: %s\n\n测试命令: %s\n输出结果:\n%s", connID, conn.URL, conn.Type, testCmd, output),
|
||||||
|
}},
|
||||||
|
IsError: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
mcpServer.RegisterTool(testTool, testHandler)
|
||||||
|
|
||||||
|
logger.Info("WebShell 管理工具注册成功")
|
||||||
|
}
|
||||||
|
|
||||||
// initializeKnowledge 初始化知识库组件(用于动态初始化)
|
// initializeKnowledge 初始化知识库组件(用于动态初始化)
|
||||||
func initializeKnowledge(
|
func initializeKnowledge(
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
|||||||
@@ -224,9 +224,9 @@ func (h *SkillsHandler) GetSkillBoundRoles(c *gin.Context) {
|
|||||||
|
|
||||||
boundRoles := h.getRolesBoundToSkill(skillName)
|
boundRoles := h.getRolesBoundToSkill(skillName)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"skill": skillName,
|
"skill": skillName,
|
||||||
"bound_roles": boundRoles,
|
"bound_roles": boundRoles,
|
||||||
"bound_count": len(boundRoles),
|
"bound_count": len(boundRoles),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +323,7 @@ func (h *SkillsHandler) CreateSkill(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建skill文件失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建skill文件失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.manager.InvalidateSkill(req.Name)
|
||||||
|
|
||||||
h.logger.Info("创建skill成功", zap.String("skill", req.Name))
|
h.logger.Info("创建skill成功", zap.String("skill", req.Name))
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -443,6 +444,7 @@ func (h *SkillsHandler) UpdateSkill(c *gin.Context) {
|
|||||||
if skillFile != targetFile {
|
if skillFile != targetFile {
|
||||||
os.Remove(skillFile)
|
os.Remove(skillFile)
|
||||||
}
|
}
|
||||||
|
h.manager.InvalidateSkill(skillName)
|
||||||
|
|
||||||
h.logger.Info("更新skill成功", zap.String("skill", skillName))
|
h.logger.Info("更新skill成功", zap.String("skill", skillName))
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -483,6 +485,7 @@ func (h *SkillsHandler) DeleteSkill(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除skill失败: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除skill失败: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.manager.InvalidateSkill(skillName)
|
||||||
|
|
||||||
responseMsg := "skill已删除"
|
responseMsg := "skill已删除"
|
||||||
if len(affectedRoles) > 0 {
|
if len(affectedRoles) > 0 {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
terminalMaxCommandLen = 4096
|
terminalMaxCommandLen = 4096
|
||||||
terminalMaxOutputLen = 256 * 1024 // 256KB
|
terminalMaxOutputLen = 256 * 1024 // 256KB
|
||||||
terminalTimeout = 120 * time.Second
|
terminalTimeout = 30 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// TerminalHandler 处理系统设置中的终端命令执行
|
// TerminalHandler 处理系统设置中的终端命令执行
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ const (
|
|||||||
ToolWebshellFileList = "webshell_file_list"
|
ToolWebshellFileList = "webshell_file_list"
|
||||||
ToolWebshellFileRead = "webshell_file_read"
|
ToolWebshellFileRead = "webshell_file_read"
|
||||||
ToolWebshellFileWrite = "webshell_file_write"
|
ToolWebshellFileWrite = "webshell_file_write"
|
||||||
|
|
||||||
|
// WebShell 连接管理工具(用于通过 MCP 管理 webshell 连接)
|
||||||
|
ToolManageWebshellList = "manage_webshell_list"
|
||||||
|
ToolManageWebshellAdd = "manage_webshell_add"
|
||||||
|
ToolManageWebshellUpdate = "manage_webshell_update"
|
||||||
|
ToolManageWebshellDelete = "manage_webshell_delete"
|
||||||
|
ToolManageWebshellTest = "manage_webshell_test"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsBuiltinTool 检查工具名称是否是内置工具
|
// IsBuiltinTool 检查工具名称是否是内置工具
|
||||||
@@ -32,7 +39,12 @@ func IsBuiltinTool(toolName string) bool {
|
|||||||
ToolWebshellExec,
|
ToolWebshellExec,
|
||||||
ToolWebshellFileList,
|
ToolWebshellFileList,
|
||||||
ToolWebshellFileRead,
|
ToolWebshellFileRead,
|
||||||
ToolWebshellFileWrite:
|
ToolWebshellFileWrite,
|
||||||
|
ToolManageWebshellList,
|
||||||
|
ToolManageWebshellAdd,
|
||||||
|
ToolManageWebshellUpdate,
|
||||||
|
ToolManageWebshellDelete,
|
||||||
|
ToolManageWebshellTest:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -51,5 +63,10 @@ func GetAllBuiltinTools() []string {
|
|||||||
ToolWebshellFileList,
|
ToolWebshellFileList,
|
||||||
ToolWebshellFileRead,
|
ToolWebshellFileRead,
|
||||||
ToolWebshellFileWrite,
|
ToolWebshellFileWrite,
|
||||||
|
ToolManageWebshellList,
|
||||||
|
ToolManageWebshellAdd,
|
||||||
|
ToolManageWebshellUpdate,
|
||||||
|
ToolManageWebshellDelete,
|
||||||
|
ToolManageWebshellTest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ func RunDeepAgent(
|
|||||||
return agent == "" || agent == orchestratorName
|
return agent == "" || agent == orchestratorName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅保留主代理最后一次 assistant 输出,避免把多轮中间回复拼接到最终答案。
|
||||||
var lastAssistant string
|
var lastAssistant string
|
||||||
var reasoningStreamSeq int64
|
var reasoningStreamSeq int64
|
||||||
var einoSubReplyStreamSeq int64
|
var einoSubReplyStreamSeq int64
|
||||||
@@ -335,6 +336,7 @@ func RunDeepAgent(
|
|||||||
var toolStreamFragments []schema.ToolCall
|
var toolStreamFragments []schema.ToolCall
|
||||||
var subAssistantBuf strings.Builder
|
var subAssistantBuf strings.Builder
|
||||||
var subReplyStreamID string
|
var subReplyStreamID string
|
||||||
|
var mainAssistantBuf strings.Builder
|
||||||
for {
|
for {
|
||||||
chunk, rerr := mv.MessageStream.Recv()
|
chunk, rerr := mv.MessageStream.Recv()
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
@@ -376,7 +378,7 @@ func RunDeepAgent(
|
|||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
})
|
})
|
||||||
lastAssistant += chunk.Content
|
mainAssistantBuf.WriteString(chunk.Content)
|
||||||
} else if !streamsMainAssistant(ev.AgentName) {
|
} else if !streamsMainAssistant(ev.AgentName) {
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
if subReplyStreamID == "" {
|
if subReplyStreamID == "" {
|
||||||
@@ -401,6 +403,11 @@ func RunDeepAgent(
|
|||||||
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
|
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if streamsMainAssistant(ev.AgentName) {
|
||||||
|
if s := strings.TrimSpace(mainAssistantBuf.String()); s != "" {
|
||||||
|
lastAssistant = s
|
||||||
|
}
|
||||||
|
}
|
||||||
if subAssistantBuf.Len() > 0 && progress != nil {
|
if subAssistantBuf.Len() > 0 && progress != nil {
|
||||||
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
|
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
|
||||||
if subReplyStreamID != "" {
|
if subReplyStreamID != "" {
|
||||||
@@ -455,7 +462,7 @@ func RunDeepAgent(
|
|||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
lastAssistant += body
|
lastAssistant = body
|
||||||
} else if progress != nil {
|
} else if progress != nil {
|
||||||
progress("eino_agent_reply", body, map[string]interface{}{
|
progress("eino_agent_reply", body, map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
|
|||||||
+72
-37
@@ -14,8 +14,14 @@ import (
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
skillsDir string
|
skillsDir string
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
skills map[string]*Skill // 缓存已加载的skills
|
skills map[string]*cachedSkill // 缓存已加载的skills(含文件状态)
|
||||||
mu sync.RWMutex // 保护skills map的并发访问
|
mu sync.RWMutex // 保护skills map的并发访问
|
||||||
|
}
|
||||||
|
|
||||||
|
type cachedSkill struct {
|
||||||
|
skill *Skill
|
||||||
|
filePath string
|
||||||
|
modTime int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill Skill定义
|
// Skill Skill定义
|
||||||
@@ -31,49 +37,43 @@ func NewManager(skillsDir string, logger *zap.Logger) *Manager {
|
|||||||
return &Manager{
|
return &Manager{
|
||||||
skillsDir: skillsDir,
|
skillsDir: skillsDir,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
skills: make(map[string]*Skill),
|
skills: make(map[string]*cachedSkill),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadSkill 加载单个skill
|
// LoadSkill 加载单个skill
|
||||||
func (m *Manager) LoadSkill(skillName string) (*Skill, error) {
|
func (m *Manager) LoadSkill(skillName string) (*Skill, error) {
|
||||||
// 先尝试读锁检查缓存
|
|
||||||
m.mu.RLock()
|
|
||||||
if skill, exists := m.skills[skillName]; exists {
|
|
||||||
m.mu.RUnlock()
|
|
||||||
return skill, nil
|
|
||||||
}
|
|
||||||
m.mu.RUnlock()
|
|
||||||
|
|
||||||
// 构建skill路径
|
// 构建skill路径
|
||||||
skillPath := filepath.Join(m.skillsDir, skillName)
|
skillPath := filepath.Join(m.skillsDir, skillName)
|
||||||
|
|
||||||
// 检查目录是否存在
|
// 检查目录是否存在
|
||||||
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
|
if _, err := os.Stat(skillPath); os.IsNotExist(err) {
|
||||||
|
m.InvalidateSkill(skillName)
|
||||||
return nil, fmt.Errorf("skill %s not found", skillName)
|
return nil, fmt.Errorf("skill %s not found", skillName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找SKILL.md文件
|
// 查找skill文件并读取文件状态
|
||||||
skillFile := filepath.Join(skillPath, "SKILL.md")
|
skillFile, err := m.resolveSkillFile(skillPath)
|
||||||
if _, err := os.Stat(skillFile); os.IsNotExist(err) {
|
if err != nil {
|
||||||
// 尝试其他可能的文件名
|
m.InvalidateSkill(skillName)
|
||||||
alternatives := []string{
|
return nil, err
|
||||||
filepath.Join(skillPath, "skill.md"),
|
|
||||||
filepath.Join(skillPath, "README.md"),
|
|
||||||
filepath.Join(skillPath, "readme.md"),
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, alt := range alternatives {
|
|
||||||
if _, err := os.Stat(alt); err == nil {
|
|
||||||
skillFile = alt
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return nil, fmt.Errorf("skill file not found for %s", skillName)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fileInfo, err := os.Stat(skillFile)
|
||||||
|
if err != nil {
|
||||||
|
m.InvalidateSkill(skillName)
|
||||||
|
return nil, fmt.Errorf("failed to stat skill file: %w", err)
|
||||||
|
}
|
||||||
|
modTime := fileInfo.ModTime().UnixNano()
|
||||||
|
|
||||||
|
// 先尝试读锁命中缓存(文件路径和修改时间都未变化)
|
||||||
|
m.mu.RLock()
|
||||||
|
if cached, exists := m.skills[skillName]; exists &&
|
||||||
|
cached.filePath == skillFile &&
|
||||||
|
cached.modTime == modTime {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return cached.skill, nil
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
// 读取skill文件
|
// 读取skill文件
|
||||||
content, err := os.ReadFile(skillFile)
|
content, err := os.ReadFile(skillFile)
|
||||||
@@ -84,14 +84,13 @@ func (m *Manager) LoadSkill(skillName string) (*Skill, error) {
|
|||||||
// 解析skill内容
|
// 解析skill内容
|
||||||
skill := m.parseSkillContent(string(content), skillName, skillPath)
|
skill := m.parseSkillContent(string(content), skillName, skillPath)
|
||||||
|
|
||||||
// 使用写锁缓存skill(双重检查,避免重复加载)
|
// 使用写锁更新缓存
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
// 再次检查,可能其他goroutine已经加载了
|
m.skills[skillName] = &cachedSkill{
|
||||||
if existing, exists := m.skills[skillName]; exists {
|
skill: skill,
|
||||||
m.mu.Unlock()
|
filePath: skillFile,
|
||||||
return existing, nil
|
modTime: modTime,
|
||||||
}
|
}
|
||||||
m.skills[skillName] = skill
|
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
return skill, nil
|
return skill, nil
|
||||||
@@ -161,6 +160,42 @@ func (m *Manager) ListSkills() ([]string, error) {
|
|||||||
return skills, nil
|
return skills, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) resolveSkillFile(skillPath string) (string, error) {
|
||||||
|
// 优先标准文件名
|
||||||
|
skillFile := filepath.Join(skillPath, "SKILL.md")
|
||||||
|
if _, err := os.Stat(skillFile); err == nil {
|
||||||
|
return skillFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容历史文件名
|
||||||
|
alternatives := []string{
|
||||||
|
filepath.Join(skillPath, "skill.md"),
|
||||||
|
filepath.Join(skillPath, "README.md"),
|
||||||
|
filepath.Join(skillPath, "readme.md"),
|
||||||
|
}
|
||||||
|
for _, alt := range alternatives {
|
||||||
|
if _, err := os.Stat(alt); err == nil {
|
||||||
|
return alt, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("skill file not found for %s", filepath.Base(skillPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateSkill 使指定skill缓存失效
|
||||||
|
func (m *Manager) InvalidateSkill(skillName string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.skills, skillName)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateAll 清空全部skill缓存
|
||||||
|
func (m *Manager) InvalidateAll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.skills = make(map[string]*cachedSkill)
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// parseSkillContent 解析skill内容
|
// parseSkillContent 解析skill内容
|
||||||
// 支持YAML front matter格式,类似goskills
|
// 支持YAML front matter格式,类似goskills
|
||||||
func (m *Manager) parseSkillContent(content, skillName, skillPath string) *Skill {
|
func (m *Manager) parseSkillContent(content, skillName, skillPath string) *Skill {
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ _LISTENER_PORT: int | None = None
|
|||||||
_CLIENT_SOCK: socket.socket | None = None
|
_CLIENT_SOCK: socket.socket | None = None
|
||||||
_CLIENT_ADDR: tuple[str, int] | None = None
|
_CLIENT_ADDR: tuple[str, int] | None = None
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
|
_STOP_EVENT = threading.Event()
|
||||||
|
_READY_EVENT = threading.Event()
|
||||||
|
_LAST_LISTEN_ERROR: str | None = None
|
||||||
|
_LISTENER_THREAD_JOIN_TIMEOUT = 1.0
|
||||||
|
_START_READY_TIMEOUT = 1.5
|
||||||
|
|
||||||
# 用于 send_command 的输出结束标记(避免无限等待)
|
# 用于 send_command 的输出结束标记(避免无限等待)
|
||||||
_END_MARKER = "__RS_DONE__"
|
_END_MARKER = "__RS_DONE__"
|
||||||
@@ -62,37 +67,55 @@ def _get_local_ips() -> list[str]:
|
|||||||
|
|
||||||
def _accept_loop(port: int) -> None:
|
def _accept_loop(port: int) -> None:
|
||||||
"""在后台线程中:bind、listen、accept,只接受一个客户端。"""
|
"""在后台线程中:bind、listen、accept,只接受一个客户端。"""
|
||||||
global _LISTENER, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
|
global _LISTENER, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT, _LAST_LISTEN_ERROR
|
||||||
|
sock: socket.socket | None = None
|
||||||
try:
|
try:
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
sock.bind(("0.0.0.0", port))
|
sock.bind(("0.0.0.0", port))
|
||||||
sock.listen(1)
|
sock.listen(1)
|
||||||
|
# 避免 stop_listener 关闭后 accept() 长时间不返回:用超时轮询检查停止事件
|
||||||
|
sock.settimeout(0.5)
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_LISTENER = sock
|
_LISTENER = sock
|
||||||
# 阻塞 accept,只接受一个连接
|
_LISTENER_PORT = port
|
||||||
client, addr = sock.accept()
|
_LAST_LISTEN_ERROR = None
|
||||||
|
_READY_EVENT.set()
|
||||||
|
# 循环 accept:只接受一个连接,或等待 stop 事件
|
||||||
|
while not _STOP_EVENT.is_set():
|
||||||
|
try:
|
||||||
|
client, addr = sock.accept()
|
||||||
|
except socket.timeout:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
with _LOCK:
|
||||||
|
_CLIENT_SOCK = client
|
||||||
|
_CLIENT_ADDR = (addr[0], addr[1])
|
||||||
|
break
|
||||||
|
except OSError as e:
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
_CLIENT_SOCK = client
|
_LAST_LISTEN_ERROR = str(e)
|
||||||
_CLIENT_ADDR = (addr[0], addr[1])
|
_READY_EVENT.set()
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
finally:
|
finally:
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
if _LISTENER:
|
_LISTENER = None
|
||||||
try:
|
|
||||||
_LISTENER.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
_LISTENER = None
|
|
||||||
_LISTENER_PORT = None
|
_LISTENER_PORT = None
|
||||||
|
if sock is not None:
|
||||||
|
try:
|
||||||
|
sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _start_listener(port: int) -> str:
|
def _start_listener(port: int) -> str:
|
||||||
global _LISTENER_THREAD, _LISTENER_PORT, _CLIENT_SOCK, _CLIENT_ADDR
|
global _LISTENER_THREAD, _LISTENER_PORT, _CLIENT_SOCK, _CLIENT_ADDR, _LAST_LISTEN_ERROR
|
||||||
|
old_thread: threading.Thread | None = None
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
if _LISTENER is not None or (_LISTENER_THREAD is not None and _LISTENER_THREAD.is_alive()):
|
if _LISTENER is not None:
|
||||||
return f"已在监听中(端口: {_LISTENER_PORT}),请先 stop_listener 再重新 start。"
|
# _LISTENER_PORT 可能短暂为 None(例如刚 stop/start),因此做个兜底显示
|
||||||
|
show_port = _LISTENER_PORT if _LISTENER_PORT is not None else port
|
||||||
|
return f"已在监听中(端口: {show_port}),请先 stop_listener 再重新 start。"
|
||||||
if _CLIENT_SOCK is not None:
|
if _CLIENT_SOCK is not None:
|
||||||
try:
|
try:
|
||||||
_CLIENT_SOCK.close()
|
_CLIENT_SOCK.close()
|
||||||
@@ -100,39 +123,72 @@ def _start_listener(port: int) -> str:
|
|||||||
pass
|
pass
|
||||||
_CLIENT_SOCK = None
|
_CLIENT_SOCK = None
|
||||||
_CLIENT_ADDR = None
|
_CLIENT_ADDR = None
|
||||||
|
old_thread = _LISTENER_THREAD
|
||||||
|
|
||||||
|
# 若旧线程还没完全退出,短暂等待一下以减少端口绑定失败概率
|
||||||
|
if old_thread is not None and old_thread.is_alive():
|
||||||
|
old_thread.join(timeout=0.5)
|
||||||
|
|
||||||
|
_STOP_EVENT.clear()
|
||||||
|
_READY_EVENT.clear()
|
||||||
|
_LAST_LISTEN_ERROR = None
|
||||||
th = threading.Thread(target=_accept_loop, args=(port,), daemon=True)
|
th = threading.Thread(target=_accept_loop, args=(port,), daemon=True)
|
||||||
th.start()
|
th.start()
|
||||||
_LISTENER_THREAD = th
|
_LISTENER_THREAD = th
|
||||||
time.sleep(0.2)
|
|
||||||
|
# 等待后台线程完成 bind/listen(或失败)
|
||||||
|
_READY_EVENT.wait(timeout=_START_READY_TIMEOUT)
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
if _LISTENER is not None:
|
err = _LAST_LISTEN_ERROR
|
||||||
_LISTENER_PORT = port
|
listening = _LISTENER is not None
|
||||||
ips = _get_local_ips()
|
|
||||||
addrs = ", ".join(f"{ip}:{port}" for ip in ips)
|
if listening:
|
||||||
return (
|
ips = _get_local_ips()
|
||||||
f"已在 0.0.0.0:{port} 开始监听。"
|
addrs = ", ".join(f"{ip}:{port}" for ip in ips)
|
||||||
f"目标机请反弹到: {addrs}(任选其一)。连接后使用 reverse_shell_send_command 执行命令。"
|
return (
|
||||||
)
|
f"已在 0.0.0.0:{port} 开始监听。"
|
||||||
return f"监听 0.0.0.0:{port} 已启动(若端口被占用会失败,请检查)。"
|
f"目标机请反弹到: {addrs}(任选其一)。连接后使用 reverse_shell_send_command 执行命令。"
|
||||||
|
)
|
||||||
|
|
||||||
|
if err:
|
||||||
|
return f"启动监听失败(0.0.0.0:{port}):{err}"
|
||||||
|
|
||||||
|
# 仍未准备好:可能线程调度较慢或环境异常;给出可操作的提示
|
||||||
|
return f"启动监听未确认成功(0.0.0.0:{port})。请调用 reverse_shell_status 确认,或稍后重试。"
|
||||||
|
|
||||||
|
|
||||||
def _stop_listener() -> str:
|
def _stop_listener() -> str:
|
||||||
global _LISTENER, _LISTENER_THREAD, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
|
global _LISTENER, _LISTENER_THREAD, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
|
||||||
|
listener_sock: socket.socket | None = None
|
||||||
|
client_sock: socket.socket | None = None
|
||||||
|
old_thread: threading.Thread | None = None
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
if _LISTENER is not None:
|
_STOP_EVENT.set()
|
||||||
try:
|
_READY_EVENT.set()
|
||||||
_LISTENER.close()
|
listener_sock = _LISTENER
|
||||||
except OSError:
|
old_thread = _LISTENER_THREAD
|
||||||
pass
|
_LISTENER = None
|
||||||
_LISTENER = None
|
|
||||||
_LISTENER_PORT = None
|
_LISTENER_PORT = None
|
||||||
if _CLIENT_SOCK is not None:
|
client_sock = _CLIENT_SOCK
|
||||||
try:
|
_CLIENT_SOCK = None
|
||||||
_CLIENT_SOCK.close()
|
_CLIENT_ADDR = None
|
||||||
except OSError:
|
|
||||||
pass
|
if listener_sock is not None:
|
||||||
_CLIENT_SOCK = None
|
try:
|
||||||
_CLIENT_ADDR = None
|
listener_sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if client_sock is not None:
|
||||||
|
try:
|
||||||
|
client_sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 等待监听线程退出,避免 stop/start 竞态导致“端口 None 仍提示已在监听中”
|
||||||
|
if old_thread is not None and old_thread.is_alive():
|
||||||
|
old_thread.join(timeout=_LISTENER_THREAD_JOIN_TIMEOUT)
|
||||||
|
with _LOCK:
|
||||||
|
_LISTENER_THREAD = None
|
||||||
return "监听已停止,已断开当前客户端(如有)。"
|
return "监听已停止,已断开当前客户端(如有)。"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
## Plugins
|
||||||
|
|
||||||
|
This directory contains optional plugins/extensions that integrate CyberStrikeAI with other tools.
|
||||||
|
|
||||||
|
- `burp-suite/`: Burp Suite extensions
|
||||||
|
|
||||||
|
### Burp Suite Extension
|
||||||
|
|
||||||
|
- **Path**: `plugins/burp-suite/cyberstrikeai-burp-extension/`
|
||||||
|
- **Build output**: `plugins/burp-suite/cyberstrikeai-burp-extension/dist/cyberstrikeai-burp-extension.jar`
|
||||||
|
- **Docs**: see the plugin folder `README.md` / `README.zh-CN.md`
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## CyberStrikeAI Burp Suite Extension
|
||||||
|
|
||||||
|
中文说明见:`README.zh-CN.md`
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
- Configure **Host / Port / Password** and choose **Single-Agent** or **Multi-Agent**
|
||||||
|
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`)
|
||||||
|
- Right-click any HTTP message in Burp and send it to CyberStrikeAI for **streaming web pentest**
|
||||||
|
- Keep a **test history sidebar** (searchable) so you can revisit previous runs
|
||||||
|
- Output is split into **collapsible Progress** + **Final Response** (Markdown rendering supported)
|
||||||
|
- View captured **Request / Response** for each run
|
||||||
|
- **Stop** a running task (calls `/api/agent-loop/cancel` once `conversationId` is available)
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- JDK 11+
|
||||||
|
- Maven (recommended) OR Burp Extender API jar (offline mode)
|
||||||
|
|
||||||
|
#### Option A (recommended): Maven build (no need to locate Burp)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||||
|
./build-mvn.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
- `dist/cyberstrikeai-burp-extension.jar`
|
||||||
|
|
||||||
|
#### Option B: Offline build with `build.sh` (needs Burp API jar)
|
||||||
|
|
||||||
|
1) Create `lib/` and copy Burp's API jar into it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p lib
|
||||||
|
# copy from your Burp installation, for example:
|
||||||
|
# cp "/path/to/burp-extender-api.jar" lib/
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
- `dist/cyberstrikeai-burp-extension.jar`
|
||||||
|
|
||||||
|
#### Option C: Gradle (optional)
|
||||||
|
|
||||||
|
If you already have Gradle available, you can still use `build.gradle` to build.
|
||||||
|
|
||||||
|
### Load in Burp Suite
|
||||||
|
|
||||||
|
- Burp Suite → **Extensions** → **Installed** → **Add**
|
||||||
|
- Extension type: **Java**
|
||||||
|
- Select the jar above
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- This extension connects to your CyberStrikeAI server (default is `http://127.0.0.1:8080`).
|
||||||
|
- It uses **Bearer Token** authentication obtained from the configured password.
|
||||||
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
## CyberStrikeAI Burp Suite 插件(中文说明)
|
||||||
|
|
||||||
|
### 功能概述
|
||||||
|
|
||||||
|
- 在 Burp 的 `CyberStrikeAI` 标签页中配置 **Host、端口、密码、单/多 Agent**
|
||||||
|
- 点击 **Validate(验证)**:
|
||||||
|
- 调用 `POST /api/auth/login` 用密码换取 Token
|
||||||
|
- 调用 `GET /api/auth/validate` 校验 Token
|
||||||
|
- 验证通过后 Token 会保存在插件内存中(本次 Burp 会话有效)
|
||||||
|
- 右键任意 HTTP 请求包 → **Send to CyberStrikeAI (stream test)**:
|
||||||
|
- 将该 HTTP 请求(含 headers/body;若存在响应则附带截断片段)发送到 CyberStrikeAI
|
||||||
|
- 以 **SSE 流式**接收返回内容,并在标签页中实时展示
|
||||||
|
- 单 Agent:`POST /api/agent-loop/stream`
|
||||||
|
- 多 Agent:`POST /api/multi-agent/stream`(需要服务端启用 `multi_agent.enabled: true`)
|
||||||
|
- **测试历史侧边栏(可搜索)**:每次发送都会新增一条记录,方便回看与对比
|
||||||
|
- **Output 分区**:`Progress`(可折叠)+ `Final Response`(主区域)
|
||||||
|
- **Markdown 渲染**:最终输出可在 Output 主区域渲染为富文本(可开关)
|
||||||
|
- **Request / Response 回看**:右侧 Tab 可直接查看该次捕获到的原始请求/响应
|
||||||
|
- **Stop 取消**:任务创建会话后可调用 `/api/agent-loop/cancel` 停止当前会话任务
|
||||||
|
|
||||||
|
### 编译(不依赖 Gradle/Maven,推荐)
|
||||||
|
|
||||||
|
> 给普通用户:你们应当直接发 **编译好的 jar**,用户在 Burp 里加载即可,**不需要编译**。
|
||||||
|
|
||||||
|
#### 方式 A(推荐,通用):用 Maven 编译(不需要知道 Burp 在哪)
|
||||||
|
|
||||||
|
适合:开发者/CI 打包一次,发布给所有用户使用。
|
||||||
|
|
||||||
|
环境要求:
|
||||||
|
|
||||||
|
- JDK 11+
|
||||||
|
- Maven(会从 Maven Central 下载 `burp-extender-api` 依赖)
|
||||||
|
|
||||||
|
编译打包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||||
|
./build-mvn.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
产物:
|
||||||
|
|
||||||
|
- `dist/cyberstrikeai-burp-extension.jar`
|
||||||
|
|
||||||
|
#### 方式 B(离线):纯 JDK 编译(需要 Burp 的 API jar)
|
||||||
|
|
||||||
|
- JDK 11+
|
||||||
|
- Burp Extender API 的 jar(来自你的 Burp 安装目录)
|
||||||
|
|
||||||
|
#### 步骤
|
||||||
|
|
||||||
|
1) 在插件目录创建 `lib/`,并把 `burp-extender-api.jar` 复制进去:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||||
|
mkdir -p lib
|
||||||
|
# 复制 Burp 自带的 API jar 到这里,例如:
|
||||||
|
# cp "/path/to/burp-extender-api.jar" lib/
|
||||||
|
```
|
||||||
|
|
||||||
|
2) 一键编译打包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
产物:
|
||||||
|
|
||||||
|
- `dist/cyberstrikeai-burp-extension.jar`
|
||||||
|
|
||||||
|
### 在 Burp Suite 中加载
|
||||||
|
|
||||||
|
- Burp Suite → **Extensions** → **Installed** → **Add**
|
||||||
|
- Extension type:**Java**
|
||||||
|
- 选择 `dist/cyberstrikeai-burp-extension.jar`
|
||||||
|
|
||||||
|
### 使用方法
|
||||||
|
|
||||||
|
1) 打开 Burp 顶部标签页 `CyberStrikeAI`
|
||||||
|
2) 填写:
|
||||||
|
- **Host**:例如 `127.0.0.1`
|
||||||
|
- **Port**:例如 `8080`
|
||||||
|
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `config.yaml` 的 `auth.password`)
|
||||||
|
- **Agent mode**:选择 `Single Agent` 或 `Multi Agent`
|
||||||
|
3) 点击 **Validate**
|
||||||
|
- 成功:状态显示 `OK (token saved)`
|
||||||
|
- 失败:状态会显示错误原因(例如密码错误、服务不可达、401/403 等)
|
||||||
|
4) 在 Burp 的 Proxy/HTTP history/Repeater 等列表中选中一条 HTTP 包
|
||||||
|
5) 右键 → **Send to CyberStrikeAI (stream test)**
|
||||||
|
6) 每次发送后会在 `CyberStrikeAI` 标签页左侧显示一个“测试记录”(请求标题 + 单/多 Agent + 状态);点击对应记录即可在右侧查看该次的流式输出结果
|
||||||
|
|
||||||
|
### 常见问题(排错)
|
||||||
|
|
||||||
|
- **Validate 失败 / 401**
|
||||||
|
- 确认密码是否正确(服务端 `auth.password`)
|
||||||
|
- 确认 IP/端口是否能访问(例如浏览器能打开 `http://IP:PORT/`)
|
||||||
|
- 若服务器启用了反向代理/HTTPS,需要把插件里 baseUrl 改成对应协议与端口(当前插件默认使用 `http://`)
|
||||||
|
|
||||||
|
- **选择 Multi Agent 后提示“多代理未启用”**
|
||||||
|
- 服务端需要开启:`config.yaml` 中 `multi_agent.enabled: true`
|
||||||
|
- 并重启服务(或按你们项目的动态 apply 配置流程启用)
|
||||||
|
|
||||||
|
- **右键发送后无流式输出**
|
||||||
|
- 先确认已 Validate(拿到 Token)
|
||||||
|
- 确认 Burp 能访问到 CyberStrikeAI(网络/代理/防火墙)
|
||||||
|
- 服务端的流式端点为 SSE,插件会解析 `data: {json}` 行;如果中间件缓冲可能影响实时性
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DIST_DIR="$ROOT_DIR/dist"
|
||||||
|
|
||||||
|
MVN_BIN=""
|
||||||
|
if command -v mvn >/dev/null 2>&1; then
|
||||||
|
MVN_BIN="mvn"
|
||||||
|
else
|
||||||
|
# Auto-provision Maven for developer convenience.
|
||||||
|
# This is only used to build the jar once in CI/dev; Burp users don't need to run this.
|
||||||
|
MAVEN_VERSION="3.9.6"
|
||||||
|
BASE_DIR="${HOME}/.cache/cyberstrikeai-burp-extension"
|
||||||
|
MAVEN_DIR="$BASE_DIR/apache-maven-$MAVEN_VERSION"
|
||||||
|
MAVEN_TGZ="$BASE_DIR/apache-maven-$MAVEN_VERSION-bin.tar.gz"
|
||||||
|
MAVEN_URL="https://archive.apache.org/dist/maven/maven-3/$MAVEN_VERSION/binaries/apache-maven-$MAVEN_VERSION-bin.tar.gz"
|
||||||
|
|
||||||
|
if [[ -x "$MAVEN_DIR/bin/mvn" ]]; then
|
||||||
|
MVN_BIN="$MAVEN_DIR/bin/mvn"
|
||||||
|
else
|
||||||
|
echo "[*] Maven not found. Downloading Maven $MAVEN_VERSION ..."
|
||||||
|
mkdir -p "$BASE_DIR"
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
curl -fsSL "$MAVEN_URL" -o "$MAVEN_TGZ"
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
wget -q "$MAVEN_URL" -O "$MAVEN_TGZ"
|
||||||
|
else
|
||||||
|
echo "Missing: curl/wget (needed to download Maven)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
tar -xzf "$MAVEN_TGZ" -C "$BASE_DIR"
|
||||||
|
MVN_BIN="$MAVEN_DIR/bin/mvn"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$DIST_DIR"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
echo "[*] Building with Maven (downloads Burp API from Maven Central)..."
|
||||||
|
(cd "$ROOT_DIR" && "$MVN_BIN" -q -DskipTests package)
|
||||||
|
|
||||||
|
cp "$ROOT_DIR/target/cyberstrikeai-burp-extension-1.0.0.jar" "$DIST_DIR/cyberstrikeai-burp-extension.jar"
|
||||||
|
echo "[+] Done: $DIST_DIR/cyberstrikeai-burp-extension.jar"
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
id 'com.github.johnrengelman.shadow' version '8.1.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'ai.cyberstrike'
|
||||||
|
version = '1.0.0'
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion = JavaLanguageVersion.of(11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Burp Extender API (legacy). Burp will provide the interfaces at runtime, but we compile against it.
|
||||||
|
implementation 'net.portswigger.burp.extender:burp-extender-api:2.3'
|
||||||
|
|
||||||
|
// JSON parsing for SSE payloads.
|
||||||
|
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType(JavaCompile).configureEach {
|
||||||
|
options.encoding = 'UTF-8'
|
||||||
|
options.release = 11
|
||||||
|
}
|
||||||
|
|
||||||
|
jar {
|
||||||
|
manifest {
|
||||||
|
attributes(
|
||||||
|
'Main-Class': 'burp.BurpExtender'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shadowJar {
|
||||||
|
archiveBaseName.set('cyberstrikeai-burp-extension')
|
||||||
|
archiveClassifier.set('all')
|
||||||
|
archiveVersion.set('')
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
LIB_DIR="$ROOT_DIR/lib"
|
||||||
|
DIST_DIR="$ROOT_DIR/dist"
|
||||||
|
BUILD_DIR="$ROOT_DIR/.build"
|
||||||
|
|
||||||
|
API_JAR="$LIB_DIR/burp-extender-api.jar"
|
||||||
|
|
||||||
|
if [[ ! -f "$API_JAR" ]]; then
|
||||||
|
echo "Missing: $API_JAR"
|
||||||
|
echo "Please copy Burp's burp-extender-api.jar into plugins/burp-suite/cyberstrikeai-burp-extension/lib/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$BUILD_DIR" "$DIST_DIR"
|
||||||
|
mkdir -p "$BUILD_DIR" "$DIST_DIR"
|
||||||
|
|
||||||
|
SRC_FILES=$(find "$ROOT_DIR/src/main/java" -name "*.java")
|
||||||
|
|
||||||
|
echo "[*] Compiling..."
|
||||||
|
javac \
|
||||||
|
-encoding UTF-8 \
|
||||||
|
--release 11 \
|
||||||
|
-cp "$API_JAR" \
|
||||||
|
-d "$BUILD_DIR" \
|
||||||
|
$SRC_FILES
|
||||||
|
|
||||||
|
echo "[*] Packaging..."
|
||||||
|
JAR_OUT="$DIST_DIR/cyberstrikeai-burp-extension.jar"
|
||||||
|
jar --create --file "$JAR_OUT" --main-class burp.BurpExtender -C "$BUILD_DIR" .
|
||||||
|
|
||||||
|
echo "[+] Done: $JAR_OUT"
|
||||||
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>ai.cyberstrike</groupId>
|
||||||
|
<artifactId>cyberstrikeai-burp-extension</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>CyberStrikeAI Burp Suite Extension</name>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>11</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Compile-only: Burp provides these classes at runtime -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.portswigger.burp.extender</groupId>
|
||||||
|
<artifactId>burp-extender-api</artifactId>
|
||||||
|
<version>2.3</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<version>3.4.2</version>
|
||||||
|
<configuration>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>burp.BurpExtender</mainClass>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
rootProject.name = "cyberstrikeai-burp-extension"
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package burp;
|
||||||
|
|
||||||
|
import javax.swing.*;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||||
|
private IBurpExtenderCallbacks callbacks;
|
||||||
|
private IExtensionHelpers helpers;
|
||||||
|
|
||||||
|
private CyberStrikeAITab tab;
|
||||||
|
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerExtenderCallbacks(IBurpExtenderCallbacks callbacks) {
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
this.helpers = callbacks.getHelpers();
|
||||||
|
|
||||||
|
callbacks.setExtensionName("CyberStrikeAI Extension");
|
||||||
|
|
||||||
|
this.tab = new CyberStrikeAITab();
|
||||||
|
callbacks.addSuiteTab(tab);
|
||||||
|
|
||||||
|
callbacks.registerContextMenuFactory(this);
|
||||||
|
|
||||||
|
callbacks.printOutput("CyberStrikeAI extension loaded.");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<JMenuItem> createMenuItems(IContextMenuInvocation invocation) {
|
||||||
|
List<JMenuItem> items = new ArrayList<>();
|
||||||
|
|
||||||
|
JMenuItem sendItem = new JMenuItem("Send to CyberStrikeAI (stream test)");
|
||||||
|
sendItem.addActionListener(e -> {
|
||||||
|
IHttpRequestResponse[] selected = invocation.getSelectedMessages();
|
||||||
|
if (selected == null || selected.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CyberStrikeAIClient.Config cfg = tab.currentConfig();
|
||||||
|
String token = tab.getToken();
|
||||||
|
if (token == null || token.trim().isEmpty()) {
|
||||||
|
JOptionPane.showMessageDialog(tab.getUiComponent(),
|
||||||
|
"Please click Validate first to obtain a token.",
|
||||||
|
"CyberStrikeAI", JOptionPane.WARNING_MESSAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String prompt = HttpMessageFormatter.toPrompt(helpers, selected[0]);
|
||||||
|
String title = HttpMessageFormatter.getRequestTitle(helpers, selected[0]);
|
||||||
|
String agentModeStr = (cfg.agentMode == CyberStrikeAIClient.AgentMode.MULTI) ? "Multi Agent" : "Single Agent";
|
||||||
|
String runId = tab.startNewRun(title, agentModeStr, selected[0]);
|
||||||
|
tab.appendProgressToRun(runId, "\n[server] " + cfg.baseUrl + "\n\n");
|
||||||
|
|
||||||
|
client.streamTest(cfg, token, prompt, new CyberStrikeAIClient.StreamListener() {
|
||||||
|
@Override
|
||||||
|
public void onEvent(String type, String message, String rawJson) {
|
||||||
|
if (type == null) type = "";
|
||||||
|
switch (type) {
|
||||||
|
case "response_delta":
|
||||||
|
case "eino_agent_reply_stream_delta":
|
||||||
|
// delta chunk (content only)
|
||||||
|
tab.appendFinalToRun(runId, message);
|
||||||
|
break;
|
||||||
|
case "response":
|
||||||
|
// final response (full)
|
||||||
|
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
|
||||||
|
tab.appendFinalToRun(runId, message);
|
||||||
|
tab.setFinalResponse(runId, message);
|
||||||
|
break;
|
||||||
|
case "progress":
|
||||||
|
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, "running");
|
||||||
|
break;
|
||||||
|
case "cancelled":
|
||||||
|
tab.appendProgressToRun(runId, "\n[cancelled] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, "cancelled");
|
||||||
|
break;
|
||||||
|
case "error":
|
||||||
|
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, "error");
|
||||||
|
break;
|
||||||
|
case "thinking_stream_start":
|
||||||
|
if (tab.isShowDebugEvents()) {
|
||||||
|
tab.resetThinkingStream(runId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "thinking_stream_delta":
|
||||||
|
case "tool_call":
|
||||||
|
case "tool_result":
|
||||||
|
case "tool_result_delta":
|
||||||
|
// debug; hide by default
|
||||||
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||||
|
if ("thinking_stream_delta".equals(type)) {
|
||||||
|
tab.appendThinkingDelta(runId, message);
|
||||||
|
} else {
|
||||||
|
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "conversation":
|
||||||
|
// Capture conversationId for stop/cancel.
|
||||||
|
if (rawJson != null) {
|
||||||
|
String convId = SimpleJson.extractStringField(rawJson, "conversationId");
|
||||||
|
if (convId != null && !convId.trim().isEmpty()) {
|
||||||
|
tab.setRunConversationId(runId, convId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "done":
|
||||||
|
// handled in onDone too
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message, Exception e) {
|
||||||
|
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, "error");
|
||||||
|
callbacks.printError("CyberStrikeAI stream error: " + message);
|
||||||
|
if (e != null) {
|
||||||
|
callbacks.printError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDone() {
|
||||||
|
tab.appendProgressToRun(runId, "\n\n[done]\n");
|
||||||
|
tab.setRunStatus(runId, "done");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
items.add(sendItem);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
package burp;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
final class CyberStrikeAIClient {
|
||||||
|
|
||||||
|
static final class Config {
|
||||||
|
final String baseUrl; // e.g. http://127.0.0.1:8080
|
||||||
|
final String password;
|
||||||
|
final AgentMode agentMode;
|
||||||
|
|
||||||
|
Config(String baseUrl, String password, AgentMode agentMode) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.password = password;
|
||||||
|
this.agentMode = agentMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AgentMode {
|
||||||
|
SINGLE,
|
||||||
|
MULTI
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamListener {
|
||||||
|
void onEvent(String type, String message, String rawJson);
|
||||||
|
void onError(String message, Exception e);
|
||||||
|
void onDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
String loginAndValidate(Config cfg) throws IOException {
|
||||||
|
String token = login(cfg.baseUrl, cfg.password);
|
||||||
|
validate(cfg.baseUrl, token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String login(String baseUrl, String password) throws IOException {
|
||||||
|
URL url = new URL(baseUrl + "/api/auth/login");
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
conn.setRequestProperty("Accept", "application/json");
|
||||||
|
String body = "{\"password\":\"" + escapeJson(password) + "\"}";
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
String contentType = conn.getHeaderField("Content-Type");
|
||||||
|
String resp = readAll(code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream());
|
||||||
|
|
||||||
|
// Friendly diagnosis: HTML usually means wrong host/port (e.g., hit Burp UI/proxy page).
|
||||||
|
if (looksLikeHtml(resp) || (contentType != null && contentType.toLowerCase().contains("text/html"))) {
|
||||||
|
throw new IOException("Login failed: server returned HTML, not API JSON. Check IP/Port and ensure you point to CyberStrikeAI backend.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String serverError = SimpleJson.extractStringField(resp, "error");
|
||||||
|
if (code < 200 || code >= 300) {
|
||||||
|
if (!serverError.isEmpty()) {
|
||||||
|
throw new IOException("Login failed (" + code + "): " + serverError);
|
||||||
|
}
|
||||||
|
throw new IOException("Login failed (" + code + ").");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverError.isEmpty()) {
|
||||||
|
throw new IOException("Login failed: " + serverError);
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = SimpleJson.extractStringField(resp, "token");
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
throw new IOException("Login response missing token. Check backend address and credentials.");
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(String baseUrl, String token) throws IOException {
|
||||||
|
URL url = new URL(baseUrl + "/api/auth/validate");
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("GET");
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
String resp = readAll(code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream());
|
||||||
|
if (code < 200 || code >= 300) {
|
||||||
|
throw new IOException("Validate failed (" + code + "): " + resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void streamTest(Config cfg, String token, String message, StreamListener listener) {
|
||||||
|
String path = (cfg.agentMode == AgentMode.MULTI) ? "/api/multi-agent/stream" : "/api/agent-loop/stream";
|
||||||
|
String urlStr = cfg.baseUrl + path;
|
||||||
|
|
||||||
|
Map<String, Object> payload = new HashMap<>();
|
||||||
|
payload.put("message", message);
|
||||||
|
payload.put("conversationId", "");
|
||||||
|
payload.put("role", "");
|
||||||
|
|
||||||
|
new Thread(() -> {
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
try {
|
||||||
|
URL url = new URL(urlStr);
|
||||||
|
conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
conn.setRequestProperty("Accept", "text/event-stream");
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
|
||||||
|
String body = toJson(payload);
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();
|
||||||
|
if (is == null) {
|
||||||
|
throw new IOException("No response body (HTTP " + code + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) {
|
||||||
|
// SSE format: "data: {json}"
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
String json = line.substring("data:".length()).trim();
|
||||||
|
if (!json.isEmpty()) {
|
||||||
|
String type = SimpleJson.extractStringField(json, "type");
|
||||||
|
String msg = SimpleJson.extractStringField(json, "message");
|
||||||
|
listener.onEvent(type, msg, json);
|
||||||
|
if ("done".equals(type)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listener.onDone();
|
||||||
|
} catch (Exception e) {
|
||||||
|
listener.onError(e.getMessage(), e);
|
||||||
|
} finally {
|
||||||
|
if (conn != null) {
|
||||||
|
conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "CyberStrikeAI-Stream").start();
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelByConversationId(String baseUrl, String token, String conversationId) throws IOException {
|
||||||
|
if (conversationId == null || conversationId.trim().isEmpty()) {
|
||||||
|
throw new IOException("Missing conversationId.");
|
||||||
|
}
|
||||||
|
URL url = new URL(baseUrl + "/api/agent-loop/cancel");
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
conn.setRequestProperty("Accept", "application/json");
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
|
|
||||||
|
String body = "{\"conversationId\":\"" + escapeJson(conversationId.trim()) + "\"}";
|
||||||
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
|
os.write(body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
String resp = readAll(code >= 200 && code < 300 ? conn.getInputStream() : conn.getErrorStream());
|
||||||
|
if (code < 200 || code >= 300) {
|
||||||
|
String serverError = SimpleJson.extractStringField(resp, "error");
|
||||||
|
if (!serverError.isEmpty()) {
|
||||||
|
throw new IOException("Cancel failed (" + code + "): " + serverError);
|
||||||
|
}
|
||||||
|
throw new IOException("Cancel failed (" + code + ").");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toJson(Map<String, Object> payload) {
|
||||||
|
String message = payload.get("message") != null ? String.valueOf(payload.get("message")) : "";
|
||||||
|
String conversationId = payload.get("conversationId") != null ? String.valueOf(payload.get("conversationId")) : "";
|
||||||
|
String role = payload.get("role") != null ? String.valueOf(payload.get("role")) : "";
|
||||||
|
return "{"
|
||||||
|
+ "\"message\":\"" + escapeJson(message) + "\","
|
||||||
|
+ "\"conversationId\":\"" + escapeJson(conversationId) + "\","
|
||||||
|
+ "\"role\":\"" + escapeJson(role) + "\""
|
||||||
|
+ "}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeJson(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
StringBuilder sb = new StringBuilder(s.length() + 16);
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
switch (c) {
|
||||||
|
case '\\': sb.append("\\\\"); break;
|
||||||
|
case '"': sb.append("\\\""); break;
|
||||||
|
case '\n': sb.append("\\n"); break;
|
||||||
|
case '\r': sb.append("\\r"); break;
|
||||||
|
case '\t': sb.append("\\t"); break;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) {
|
||||||
|
sb.append(String.format("\\u%04x", (int) c));
|
||||||
|
} else {
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readAll(InputStream is) throws IOException {
|
||||||
|
if (is == null) return "";
|
||||||
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
String line;
|
||||||
|
while ((line = br.readLine()) != null) {
|
||||||
|
sb.append(line).append('\n');
|
||||||
|
}
|
||||||
|
return sb.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean looksLikeHtml(String s) {
|
||||||
|
if (s == null) return false;
|
||||||
|
String t = s.trim().toLowerCase();
|
||||||
|
return t.startsWith("<!doctype html") || t.startsWith("<html") || t.contains("<head>") || t.contains("<body");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+762
@@ -0,0 +1,762 @@
|
|||||||
|
package burp;
|
||||||
|
|
||||||
|
import javax.swing.*;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.datatransfer.StringSelection;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
final class CyberStrikeAITab implements ITab {
|
||||||
|
private final JPanel root = new JPanel(new BorderLayout());
|
||||||
|
|
||||||
|
private final JTextField hostField = new JTextField("127.0.0.1");
|
||||||
|
private final JTextField portField = new JTextField("8080");
|
||||||
|
private final JPasswordField passwordField = new JPasswordField();
|
||||||
|
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{"Single Agent", "Multi Agent"});
|
||||||
|
private final JButton validateButton = new JButton("Validate");
|
||||||
|
private final JButton clearButton = new JButton("Clear Output");
|
||||||
|
private final JButton stopButton = new JButton("Stop");
|
||||||
|
private final JButton copyButton = new JButton("Copy");
|
||||||
|
private final JButton clearAllButton = new JButton("Clear All");
|
||||||
|
private final JLabel statusLabel = new JLabel("Not validated");
|
||||||
|
private final JCheckBox showDebugEventsBox = new JCheckBox("Show debug events", false);
|
||||||
|
private final JCheckBox renderMarkdownBox = new JCheckBox("Render Markdown", true);
|
||||||
|
|
||||||
|
private final JTextArea progressArea = new JTextArea();
|
||||||
|
private final JTextArea finalRawArea = new JTextArea(); // raw final stream / final response
|
||||||
|
private final JEditorPane markdownPane = new JEditorPane("text/html", "");
|
||||||
|
private final CardLayout outputCardsLayout = new CardLayout();
|
||||||
|
private final JPanel outputCards = new JPanel(outputCardsLayout);
|
||||||
|
private final JPanel outputRoot = new JPanel(new BorderLayout());
|
||||||
|
private final JPanel progressContainer = new JPanel(new CardLayout());
|
||||||
|
private final JToggleButton progressToggle = new JToggleButton("Progress ▾", true);
|
||||||
|
private final JTextArea requestArea = new JTextArea();
|
||||||
|
private final JTextArea responseArea = new JTextArea();
|
||||||
|
private final JTabbedPane rightTabs = new JTabbedPane();
|
||||||
|
|
||||||
|
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
||||||
|
private final AtomicReference<String> tokenRef = new AtomicReference<>("");
|
||||||
|
|
||||||
|
private final DefaultListModel<TestRun> testListModel = new DefaultListModel<>();
|
||||||
|
private final JList<TestRun> testList = new JList<>(testListModel);
|
||||||
|
private final DefaultListModel<TestRun> filteredListModel = new DefaultListModel<>();
|
||||||
|
private final JList<TestRun> filteredList = new JList<>(filteredListModel);
|
||||||
|
private final JTextField searchField = new JTextField();
|
||||||
|
private final Map<String, TestRun> runs = new HashMap<>();
|
||||||
|
private final Map<String, Integer> runIdToIndex = new HashMap<>();
|
||||||
|
private final AtomicInteger runSeq = new AtomicInteger(1);
|
||||||
|
private String selectedRunId = null;
|
||||||
|
|
||||||
|
private static final class TestRun {
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String agentMode;
|
||||||
|
final StringBuilder buffer = new StringBuilder();
|
||||||
|
final StringBuilder progressBuffer = new StringBuilder();
|
||||||
|
final StringBuilder finalBuffer = new StringBuilder();
|
||||||
|
final StringBuilder thinkingPending = new StringBuilder();
|
||||||
|
String status;
|
||||||
|
String conversationId;
|
||||||
|
String requestRaw;
|
||||||
|
String responseRaw;
|
||||||
|
String finalResponse;
|
||||||
|
|
||||||
|
TestRun(String id, String title, String agentMode) {
|
||||||
|
this.id = id;
|
||||||
|
this.title = title;
|
||||||
|
this.agentMode = agentMode;
|
||||||
|
this.status = "running";
|
||||||
|
this.conversationId = "";
|
||||||
|
this.requestRaw = "";
|
||||||
|
this.responseRaw = "";
|
||||||
|
this.finalResponse = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CyberStrikeAITab() {
|
||||||
|
root.add(buildConfigPanel(), BorderLayout.NORTH);
|
||||||
|
root.add(buildMainPane(), BorderLayout.CENTER);
|
||||||
|
wireActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JComponent buildConfigPanel() {
|
||||||
|
// Best-practice toolbar layout:
|
||||||
|
// Row 1 = connection settings
|
||||||
|
// Row 2 = run controls + view options
|
||||||
|
JPanel rootPanel = new JPanel();
|
||||||
|
rootPanel.setLayout(new BoxLayout(rootPanel, BoxLayout.Y_AXIS));
|
||||||
|
rootPanel.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6));
|
||||||
|
|
||||||
|
hostField.setColumns(14);
|
||||||
|
portField.setColumns(6);
|
||||||
|
passwordField.setColumns(12);
|
||||||
|
agentModeBox.setPreferredSize(new Dimension(160, agentModeBox.getPreferredSize().height));
|
||||||
|
|
||||||
|
JPanel row1 = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
|
||||||
|
row1.add(new JLabel("Host"));
|
||||||
|
row1.add(hostField);
|
||||||
|
row1.add(new JLabel("Port"));
|
||||||
|
row1.add(portField);
|
||||||
|
row1.add(new JLabel("Password"));
|
||||||
|
row1.add(passwordField);
|
||||||
|
row1.add(validateButton);
|
||||||
|
row1.add(statusLabel);
|
||||||
|
|
||||||
|
JPanel row2 = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 2));
|
||||||
|
row2.add(new JLabel("Agent"));
|
||||||
|
row2.add(agentModeBox);
|
||||||
|
row2.add(stopButton);
|
||||||
|
row2.add(copyButton);
|
||||||
|
row2.add(clearButton);
|
||||||
|
row2.add(showDebugEventsBox);
|
||||||
|
row2.add(renderMarkdownBox);
|
||||||
|
|
||||||
|
rootPanel.add(row1);
|
||||||
|
rootPanel.add(row2);
|
||||||
|
return rootPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JComponent buildMainPane() {
|
||||||
|
JPanel sidebarPanel = buildSidebarPanel();
|
||||||
|
JComponent right = buildRightPanel();
|
||||||
|
|
||||||
|
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, sidebarPanel, right);
|
||||||
|
split.setResizeWeight(0.25);
|
||||||
|
split.setBorder(null);
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JPanel buildSidebarPanel() {
|
||||||
|
JPanel p = new JPanel(new BorderLayout());
|
||||||
|
filteredList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
|
||||||
|
|
||||||
|
filteredList.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
|
||||||
|
filteredList.setCellRenderer(new TestRunCellRenderer());
|
||||||
|
filteredList.addListSelectionListener(e -> {
|
||||||
|
if (!e.getValueIsAdjusting()) {
|
||||||
|
String id = getSelectedRunIdFromList();
|
||||||
|
if (id != null) {
|
||||||
|
setLogAreaToRun(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
JLabel title = new JLabel("Test History");
|
||||||
|
title.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||||
|
|
||||||
|
JPanel top = new JPanel(new BorderLayout(8, 6));
|
||||||
|
top.setBorder(BorderFactory.createEmptyBorder(0, 8, 0, 8));
|
||||||
|
top.add(title, BorderLayout.NORTH);
|
||||||
|
searchField.setToolTipText("Search runs (title)");
|
||||||
|
top.add(searchField, BorderLayout.SOUTH);
|
||||||
|
|
||||||
|
JScrollPane sp = new JScrollPane(filteredList);
|
||||||
|
sp.setBorder(BorderFactory.createTitledBorder("Runs"));
|
||||||
|
|
||||||
|
clearAllButton.addActionListener(e -> clearAllRuns());
|
||||||
|
JPanel bottom = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 6));
|
||||||
|
bottom.add(clearAllButton);
|
||||||
|
|
||||||
|
p.add(top, BorderLayout.NORTH);
|
||||||
|
p.add(sp, BorderLayout.CENTER);
|
||||||
|
p.add(bottom, BorderLayout.SOUTH);
|
||||||
|
p.setPreferredSize(new Dimension(320, 200));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JComponent buildRightPanel() {
|
||||||
|
configureTextArea(progressArea, true);
|
||||||
|
configureTextArea(finalRawArea, true);
|
||||||
|
markdownPane.setEditable(false);
|
||||||
|
markdownPane.putClientProperty(JEditorPane.HONOR_DISPLAY_PROPERTIES, Boolean.TRUE);
|
||||||
|
markdownPane.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 12));
|
||||||
|
markdownPane.setOpaque(true);
|
||||||
|
markdownPane.setBackground(Color.WHITE);
|
||||||
|
|
||||||
|
configureTextArea(requestArea, false);
|
||||||
|
configureTextArea(responseArea, false);
|
||||||
|
|
||||||
|
outputCards.add(new JScrollPane(finalRawArea), "raw");
|
||||||
|
outputCards.add(new JScrollPane(markdownPane), "md");
|
||||||
|
|
||||||
|
outputRoot.add(buildOutputHeader(), BorderLayout.NORTH);
|
||||||
|
outputRoot.add(buildOutputBody(), BorderLayout.CENTER);
|
||||||
|
|
||||||
|
rightTabs.addTab("Output", outputRoot);
|
||||||
|
rightTabs.addTab("Request", new JScrollPane(requestArea));
|
||||||
|
rightTabs.addTab("Response", new JScrollPane(responseArea));
|
||||||
|
return rightTabs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JComponent buildOutputHeader() {
|
||||||
|
JPanel header = new JPanel(new BorderLayout(8, 0));
|
||||||
|
header.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||||
|
|
||||||
|
JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 0));
|
||||||
|
left.add(progressToggle);
|
||||||
|
header.add(left, BorderLayout.WEST);
|
||||||
|
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JComponent buildOutputBody() {
|
||||||
|
JScrollPane progressScroll = new JScrollPane(progressArea);
|
||||||
|
progressScroll.setBorder(BorderFactory.createTitledBorder("Progress"));
|
||||||
|
progressScroll.getVerticalScrollBar().setUnitIncrement(16);
|
||||||
|
|
||||||
|
JPanel empty = new JPanel();
|
||||||
|
progressContainer.add(progressScroll, "show");
|
||||||
|
progressContainer.add(empty, "hide");
|
||||||
|
((CardLayout) progressContainer.getLayout()).show(progressContainer, "show");
|
||||||
|
|
||||||
|
JPanel finalPanel = new JPanel(new BorderLayout());
|
||||||
|
finalPanel.add(outputCards, BorderLayout.CENTER);
|
||||||
|
finalPanel.setBorder(BorderFactory.createTitledBorder("Final Response"));
|
||||||
|
|
||||||
|
JSplitPane split = new JSplitPane(JSplitPane.VERTICAL_SPLIT, progressContainer, finalPanel);
|
||||||
|
split.setResizeWeight(0.15);
|
||||||
|
split.setBorder(null);
|
||||||
|
split.setDividerSize(6);
|
||||||
|
|
||||||
|
final int[] lastDividerLocation = new int[]{140}; // sensible default
|
||||||
|
|
||||||
|
progressToggle.addActionListener(e -> {
|
||||||
|
boolean show = progressToggle.isSelected();
|
||||||
|
progressToggle.setText(show ? "Progress ▾" : "Progress ▸");
|
||||||
|
CardLayout cl = (CardLayout) progressContainer.getLayout();
|
||||||
|
cl.show(progressContainer, show ? "show" : "hide");
|
||||||
|
if (!show) {
|
||||||
|
int current = split.getDividerLocation();
|
||||||
|
if (current > 0) {
|
||||||
|
lastDividerLocation[0] = current;
|
||||||
|
}
|
||||||
|
split.setDividerLocation(0);
|
||||||
|
split.setDividerSize(0);
|
||||||
|
} else {
|
||||||
|
split.setDividerSize(6);
|
||||||
|
// Restore previous divider location (or fallback to 20% of height)
|
||||||
|
int restore = lastDividerLocation[0];
|
||||||
|
if (restore <= 0) {
|
||||||
|
int h = split.getHeight();
|
||||||
|
restore = (h > 0) ? Math.max(80, (int) (h * 0.2)) : 140;
|
||||||
|
}
|
||||||
|
split.setDividerLocation(restore);
|
||||||
|
}
|
||||||
|
split.revalidate();
|
||||||
|
split.repaint();
|
||||||
|
});
|
||||||
|
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void configureTextArea(JTextArea area, boolean monospaced) {
|
||||||
|
area.setEditable(false);
|
||||||
|
area.setLineWrap(false);
|
||||||
|
area.setWrapStyleWord(false);
|
||||||
|
if (monospaced) {
|
||||||
|
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
|
||||||
|
} else {
|
||||||
|
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Color colorForStatus(String status) {
|
||||||
|
if (status == null) return new Color(120, 120, 120);
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return new Color(33, 150, 243);
|
||||||
|
case "done":
|
||||||
|
return new Color(76, 175, 80);
|
||||||
|
case "error":
|
||||||
|
return new Color(244, 67, 54);
|
||||||
|
case "cancelled":
|
||||||
|
case "cancelling":
|
||||||
|
return new Color(255, 152, 0);
|
||||||
|
default:
|
||||||
|
return new Color(120, 120, 120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class DotIcon implements Icon {
|
||||||
|
private final int size;
|
||||||
|
private Color color;
|
||||||
|
|
||||||
|
DotIcon(int size, Color color) {
|
||||||
|
this.size = size;
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setColor(Color color) {
|
||||||
|
this.color = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getIconWidth() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getIconHeight() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void paintIcon(Component c, Graphics g, int x, int y) {
|
||||||
|
Graphics2D g2 = (Graphics2D) g.create();
|
||||||
|
try {
|
||||||
|
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g2.setColor(color != null ? color : Color.GRAY);
|
||||||
|
g2.fillOval(x, y, size, size);
|
||||||
|
} finally {
|
||||||
|
g2.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class TestRunCellRenderer implements ListCellRenderer<TestRun> {
|
||||||
|
private final JPanel panel = new JPanel(new BorderLayout(8, 0));
|
||||||
|
private final JLabel dotLabel = new JLabel();
|
||||||
|
private final JLabel titleLabel = new JLabel();
|
||||||
|
private final JLabel metaLabel = new JLabel();
|
||||||
|
private final JPanel textPanel = new JPanel();
|
||||||
|
private final DotIcon dotIcon = new DotIcon(10, new Color(120, 120, 120));
|
||||||
|
|
||||||
|
TestRunCellRenderer() {
|
||||||
|
panel.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||||
|
dotLabel.setIcon(dotIcon);
|
||||||
|
|
||||||
|
textPanel.setLayout(new BoxLayout(textPanel, BoxLayout.Y_AXIS));
|
||||||
|
titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD));
|
||||||
|
metaLabel.setFont(metaLabel.getFont().deriveFont(Font.PLAIN, 11f));
|
||||||
|
metaLabel.setForeground(new Color(102, 102, 102));
|
||||||
|
textPanel.add(titleLabel);
|
||||||
|
textPanel.add(metaLabel);
|
||||||
|
|
||||||
|
panel.add(dotLabel, BorderLayout.WEST);
|
||||||
|
panel.add(textPanel, BorderLayout.CENTER);
|
||||||
|
panel.setOpaque(true);
|
||||||
|
textPanel.setOpaque(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Component getListCellRendererComponent(JList<? extends TestRun> list, TestRun value, int index, boolean isSelected, boolean cellHasFocus) {
|
||||||
|
String titleText = value != null ? value.title : "";
|
||||||
|
String modeText = value != null ? value.agentMode : "";
|
||||||
|
String statusText = value != null ? value.status : "";
|
||||||
|
|
||||||
|
String shownTitle = titleText;
|
||||||
|
if (shownTitle.length() > 80) {
|
||||||
|
shownTitle = shownTitle.substring(0, 77) + "...";
|
||||||
|
}
|
||||||
|
titleLabel.setText(shownTitle);
|
||||||
|
metaLabel.setText(modeText + " · " + statusText);
|
||||||
|
|
||||||
|
dotIcon.setColor(colorForStatus(statusText));
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
panel.setBackground(list.getSelectionBackground());
|
||||||
|
titleLabel.setForeground(list.getSelectionForeground());
|
||||||
|
metaLabel.setForeground(list.getSelectionForeground());
|
||||||
|
} else {
|
||||||
|
panel.setBackground(list.getBackground());
|
||||||
|
titleLabel.setForeground(list.getForeground());
|
||||||
|
metaLabel.setForeground(new Color(102, 102, 102));
|
||||||
|
}
|
||||||
|
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// right panel builds scroll panes for each tab
|
||||||
|
|
||||||
|
private void wireActions() {
|
||||||
|
validateButton.addActionListener(e -> {
|
||||||
|
validateButton.setEnabled(false);
|
||||||
|
statusLabel.setText("Validating...");
|
||||||
|
log("Validating connection...");
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
CyberStrikeAIClient.Config cfg = currentConfig();
|
||||||
|
String token = client.loginAndValidate(cfg);
|
||||||
|
tokenRef.set(token);
|
||||||
|
SwingUtilities.invokeLater(() -> statusLabel.setText("OK (token saved)"));
|
||||||
|
log("Validation OK.");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
tokenRef.set("");
|
||||||
|
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
|
||||||
|
log("Validation failed: " + ex.getMessage());
|
||||||
|
} finally {
|
||||||
|
SwingUtilities.invokeLater(() -> validateButton.setEnabled(true));
|
||||||
|
}
|
||||||
|
}, "CyberStrikeAI-Validate").start();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearButton.addActionListener(e -> {
|
||||||
|
if (selectedRunId == null) {
|
||||||
|
progressArea.setText("");
|
||||||
|
finalRawArea.setText("");
|
||||||
|
markdownPane.setText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TestRun run = runs.get(selectedRunId);
|
||||||
|
if (run == null) return;
|
||||||
|
synchronized (run) {
|
||||||
|
run.buffer.setLength(0);
|
||||||
|
run.progressBuffer.setLength(0);
|
||||||
|
run.finalBuffer.setLength(0);
|
||||||
|
}
|
||||||
|
progressArea.setText("");
|
||||||
|
finalRawArea.setText("");
|
||||||
|
markdownPane.setText("");
|
||||||
|
});
|
||||||
|
|
||||||
|
copyButton.addActionListener(e -> {
|
||||||
|
String text;
|
||||||
|
int idx = rightTabs.getSelectedIndex();
|
||||||
|
String tabName = idx >= 0 ? rightTabs.getTitleAt(idx) : "";
|
||||||
|
if ("Request".equals(tabName)) {
|
||||||
|
text = requestArea.getText();
|
||||||
|
} else if ("Response".equals(tabName)) {
|
||||||
|
text = responseArea.getText();
|
||||||
|
} else {
|
||||||
|
text = finalRawArea.getText();
|
||||||
|
}
|
||||||
|
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(text == null ? "" : text), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
stopButton.addActionListener(e -> {
|
||||||
|
String runId = selectedRunId;
|
||||||
|
if (runId == null) return;
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
String token = getToken();
|
||||||
|
if (token == null || token.trim().isEmpty()) {
|
||||||
|
appendProgressToRun(runId, "\n[error] Not validated.\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String convId;
|
||||||
|
synchronized (run) {
|
||||||
|
convId = run.conversationId;
|
||||||
|
}
|
||||||
|
if (convId == null || convId.trim().isEmpty()) {
|
||||||
|
appendProgressToRun(runId, "\n[info] conversationId not available yet (wait for server to create session).\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopButton.setEnabled(false);
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
CyberStrikeAIClient.Config cfg = currentConfig();
|
||||||
|
client.cancelByConversationId(cfg.baseUrl, token, convId);
|
||||||
|
appendProgressToRun(runId, "\n[info] Cancel requested.\n");
|
||||||
|
setRunStatus(runId, "cancelling");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
appendProgressToRun(runId, "\n[error] Cancel failed: " + ex.getMessage() + "\n");
|
||||||
|
} finally {
|
||||||
|
SwingUtilities.invokeLater(() -> stopButton.setEnabled(true));
|
||||||
|
}
|
||||||
|
}, "CyberStrikeAI-Cancel").start();
|
||||||
|
});
|
||||||
|
|
||||||
|
searchField.getDocument().addDocumentListener(new javax.swing.event.DocumentListener() {
|
||||||
|
@Override public void insertUpdate(javax.swing.event.DocumentEvent e) { applyFilter(); }
|
||||||
|
@Override public void removeUpdate(javax.swing.event.DocumentEvent e) { applyFilter(); }
|
||||||
|
@Override public void changedUpdate(javax.swing.event.DocumentEvent e) { applyFilter(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
renderMarkdownBox.addActionListener(e -> refreshOutputView());
|
||||||
|
}
|
||||||
|
|
||||||
|
CyberStrikeAIClient.Config currentConfig() {
|
||||||
|
String host = hostField.getText().trim();
|
||||||
|
String port = portField.getText().trim();
|
||||||
|
String password = new String(passwordField.getPassword());
|
||||||
|
String baseUrl = "http://" + host + ":" + port;
|
||||||
|
CyberStrikeAIClient.AgentMode mode = agentModeBox.getSelectedIndex() == 1
|
||||||
|
? CyberStrikeAIClient.AgentMode.MULTI
|
||||||
|
: CyberStrikeAIClient.AgentMode.SINGLE;
|
||||||
|
return new CyberStrikeAIClient.Config(baseUrl, password, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
String getToken() {
|
||||||
|
return tokenRef.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isShowDebugEvents() {
|
||||||
|
return showDebugEventsBox.isSelected();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nextRunId() {
|
||||||
|
return "run_" + runSeq.getAndIncrement();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatRunDisplay(String title, String agentMode, String status) {
|
||||||
|
return title + " [" + agentMode + "] - " + status;
|
||||||
|
}
|
||||||
|
|
||||||
|
String startNewRun(String title, String agentMode, IHttpRequestResponse msg) {
|
||||||
|
String id = nextRunId();
|
||||||
|
TestRun run = new TestRun(id, title, agentMode);
|
||||||
|
if (msg != null) {
|
||||||
|
run.requestRaw = bytesToString(msg.getRequest());
|
||||||
|
run.responseRaw = bytesToString(msg.getResponse());
|
||||||
|
}
|
||||||
|
runs.put(id, run);
|
||||||
|
|
||||||
|
int index = testListModel.getSize();
|
||||||
|
runIdToIndex.put(id, index);
|
||||||
|
testListModel.addElement(run);
|
||||||
|
filteredListModel.addElement(run);
|
||||||
|
|
||||||
|
selectedRunId = id;
|
||||||
|
filteredList.setSelectedIndex(filteredListModel.getSize() - 1);
|
||||||
|
progressArea.setText("");
|
||||||
|
finalRawArea.setText("");
|
||||||
|
markdownPane.setText("");
|
||||||
|
requestArea.setText(run.requestRaw);
|
||||||
|
responseArea.setText(run.responseRaw);
|
||||||
|
refreshOutputView();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRunStatus(String runId, String status) {
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
synchronized (run) {
|
||||||
|
run.status = status;
|
||||||
|
}
|
||||||
|
Integer index = runIdToIndex.get(runId);
|
||||||
|
if (index != null) {
|
||||||
|
SwingUtilities.invokeLater(() -> filteredList.repaint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setRunConversationId(String runId, String conversationId) {
|
||||||
|
if (runId == null) return;
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
synchronized (run) {
|
||||||
|
run.conversationId = conversationId == null ? "" : conversationId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendToRun(String runId, String s) {
|
||||||
|
// Backward compatibility: default to progress bucket
|
||||||
|
appendProgressToRun(runId, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendProgressToRun(String runId, String s) {
|
||||||
|
if (runId == null || s == null) return;
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
synchronized (run) {
|
||||||
|
run.buffer.append(s);
|
||||||
|
run.progressBuffer.append(s);
|
||||||
|
}
|
||||||
|
if (runId.equals(selectedRunId)) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
progressArea.append(s);
|
||||||
|
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetThinkingStream(String runId) {
|
||||||
|
if (runId == null) return;
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
synchronized (run) {
|
||||||
|
run.thinkingPending.setLength(0);
|
||||||
|
}
|
||||||
|
appendProgressToRun(runId, "\n[thinking]\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendThinkingDelta(String runId, String delta) {
|
||||||
|
if (runId == null || delta == null) return;
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
|
||||||
|
StringBuilder toAppend = new StringBuilder();
|
||||||
|
synchronized (run) {
|
||||||
|
for (int i = 0; i < delta.length(); i++) {
|
||||||
|
char c = delta.charAt(i);
|
||||||
|
if (c == '\n') {
|
||||||
|
if (run.thinkingPending.length() > 0) {
|
||||||
|
toAppend.append(" ").append(run.thinkingPending).append("\n");
|
||||||
|
run.thinkingPending.setLength(0);
|
||||||
|
} else {
|
||||||
|
toAppend.append("\n");
|
||||||
|
}
|
||||||
|
} else if (c != '\r') {
|
||||||
|
run.thinkingPending.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toAppend.length() > 0) {
|
||||||
|
appendProgressToRun(runId, toAppend.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendFinalToRun(String runId, String s) {
|
||||||
|
if (runId == null || s == null) return;
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
synchronized (run) {
|
||||||
|
run.buffer.append(s);
|
||||||
|
run.finalBuffer.append(s);
|
||||||
|
}
|
||||||
|
if (runId.equals(selectedRunId)) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
finalRawArea.append(s);
|
||||||
|
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFinalResponse(String runId, String finalResponse) {
|
||||||
|
if (runId == null) return;
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
synchronized (run) {
|
||||||
|
run.finalResponse = finalResponse == null ? "" : finalResponse;
|
||||||
|
}
|
||||||
|
if (runId.equals(selectedRunId)) {
|
||||||
|
SwingUtilities.invokeLater(this::refreshOutputView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getSelectedRunIdFromList() {
|
||||||
|
TestRun run = filteredList.getSelectedValue();
|
||||||
|
return run == null ? null : run.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setLogAreaToRun(String runId) {
|
||||||
|
TestRun run = runs.get(runId);
|
||||||
|
if (run == null) return;
|
||||||
|
selectedRunId = runId;
|
||||||
|
String progress;
|
||||||
|
String fin;
|
||||||
|
synchronized (run) {
|
||||||
|
progress = run.progressBuffer.toString();
|
||||||
|
fin = run.finalBuffer.toString();
|
||||||
|
}
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
progressArea.setText(progress);
|
||||||
|
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||||
|
finalRawArea.setText(fin);
|
||||||
|
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
|
||||||
|
requestArea.setText(run.requestRaw == null ? "" : run.requestRaw);
|
||||||
|
responseArea.setText(run.responseRaw == null ? "" : run.responseRaw);
|
||||||
|
refreshOutputView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearAllRuns() {
|
||||||
|
runs.clear();
|
||||||
|
runIdToIndex.clear();
|
||||||
|
testListModel.clear();
|
||||||
|
filteredListModel.clear();
|
||||||
|
selectedRunId = null;
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
progressArea.setText("");
|
||||||
|
finalRawArea.setText("");
|
||||||
|
markdownPane.setText("");
|
||||||
|
requestArea.setText("");
|
||||||
|
responseArea.setText("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearAndShowStreamHeader(String title) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
progressArea.setText("");
|
||||||
|
finalRawArea.setText(title + "\n\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy helpers kept for Validate logging
|
||||||
|
void appendStreamLine(String s) {
|
||||||
|
if (s == null) return;
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
progressArea.append(s);
|
||||||
|
progressArea.append("\n");
|
||||||
|
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void log(String s) {
|
||||||
|
appendStreamLine("[*] " + s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyFilter() {
|
||||||
|
String q = searchField.getText();
|
||||||
|
if (q == null) q = "";
|
||||||
|
String query = q.trim().toLowerCase();
|
||||||
|
filteredListModel.clear();
|
||||||
|
for (int i = 0; i < testListModel.size(); i++) {
|
||||||
|
TestRun r = testListModel.getElementAt(i);
|
||||||
|
if (query.isEmpty() || (r.title != null && r.title.toLowerCase().contains(query))) {
|
||||||
|
filteredListModel.addElement(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filteredListModel.size() > 0 && filteredList.getSelectedIndex() < 0) {
|
||||||
|
filteredList.setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshOutputView() {
|
||||||
|
if (!renderMarkdownBox.isSelected()) {
|
||||||
|
outputCardsLayout.show(outputCards, "raw");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedRunId == null) {
|
||||||
|
outputCardsLayout.show(outputCards, "raw");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TestRun run = runs.get(selectedRunId);
|
||||||
|
if (run == null) {
|
||||||
|
outputCardsLayout.show(outputCards, "raw");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String finalResp;
|
||||||
|
synchronized (run) {
|
||||||
|
finalResp = run.finalResponse;
|
||||||
|
}
|
||||||
|
if (finalResp == null || finalResp.trim().isEmpty()) {
|
||||||
|
// while streaming, stick to raw for performance
|
||||||
|
outputCardsLayout.show(outputCards, "raw");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String html = MarkdownRenderer.toHtml(finalResp);
|
||||||
|
markdownPane.setText(html);
|
||||||
|
markdownPane.setCaretPosition(0);
|
||||||
|
outputCardsLayout.show(outputCards, "md");
|
||||||
|
}
|
||||||
|
private static String bytesToString(byte[] bytes) {
|
||||||
|
if (bytes == null || bytes.length == 0) return "";
|
||||||
|
return new String(bytes, StandardCharsets.ISO_8859_1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getTabCaption() {
|
||||||
|
return "CyberStrikeAI";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Component getUiComponent() {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package burp;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class HttpMessageFormatter {
|
||||||
|
private HttpMessageFormatter() {}
|
||||||
|
|
||||||
|
static String getRequestTitle(IExtensionHelpers helpers, IHttpRequestResponse msg) {
|
||||||
|
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
|
||||||
|
String method = reqInfo.getMethod();
|
||||||
|
if (reqInfo.getUrl() == null) {
|
||||||
|
return method + " (unknown)";
|
||||||
|
}
|
||||||
|
String host = reqInfo.getUrl().getHost();
|
||||||
|
String path = reqInfo.getUrl().getPath();
|
||||||
|
if (path == null || path.isEmpty()) path = "/";
|
||||||
|
String query = reqInfo.getUrl().getQuery();
|
||||||
|
String shortPath = path;
|
||||||
|
if (shortPath.length() > 80) shortPath = shortPath.substring(0, 77) + "...";
|
||||||
|
String q = (query != null && !query.isEmpty()) ? "?" : "";
|
||||||
|
return method + " " + host + shortPath + q;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String toPrompt(IExtensionHelpers helpers, IHttpRequestResponse msg) {
|
||||||
|
IRequestInfo reqInfo = helpers.analyzeRequest(msg);
|
||||||
|
String method = reqInfo.getMethod();
|
||||||
|
String url = reqInfo.getUrl() != null ? reqInfo.getUrl().toString() : "(unknown)";
|
||||||
|
|
||||||
|
byte[] reqBytes = msg.getRequest();
|
||||||
|
int bodyOffset = reqInfo.getBodyOffset();
|
||||||
|
String headers = String.join("\n", reqInfo.getHeaders());
|
||||||
|
String body = "";
|
||||||
|
if (reqBytes != null && reqBytes.length > bodyOffset) {
|
||||||
|
body = new String(reqBytes, bodyOffset, reqBytes.length - bodyOffset, StandardCharsets.ISO_8859_1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include response summary if available
|
||||||
|
String respSnippet = "";
|
||||||
|
byte[] respBytes = msg.getResponse();
|
||||||
|
if (respBytes != null && respBytes.length > 0) {
|
||||||
|
IResponseInfo respInfo = helpers.analyzeResponse(respBytes);
|
||||||
|
List<String> respHeaders = respInfo.getHeaders();
|
||||||
|
int respBodyOffset = respInfo.getBodyOffset();
|
||||||
|
String respBody = "";
|
||||||
|
if (respBytes.length > respBodyOffset) {
|
||||||
|
int max = Math.min(respBytes.length - respBodyOffset, 4096);
|
||||||
|
respBody = new String(respBytes, respBodyOffset, max, StandardCharsets.ISO_8859_1);
|
||||||
|
}
|
||||||
|
respSnippet = "\n\n[Optional: Response (truncated)]\n"
|
||||||
|
+ String.join("\n", respHeaders)
|
||||||
|
+ "\n\n"
|
||||||
|
+ respBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
+ "针对该流量做web渗透测试,并输出测试结果,要求:只针对该接口流量做测试,切勿拓展其他接口\n\n"
|
||||||
|
+ "[Target]\n"
|
||||||
|
+ method + " " + url + "\n\n"
|
||||||
|
+ "[Request]\n"
|
||||||
|
+ headers + "\n\n"
|
||||||
|
+ body
|
||||||
|
+ respSnippet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
package burp;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Markdown -> HTML renderer for Burp UI.
|
||||||
|
* Supports: headings (#..######), fenced code blocks (```), inline code (`),
|
||||||
|
* bold (**), lists (-/*), paragraphs, and basic escaping.
|
||||||
|
*
|
||||||
|
* Not a full CommonMark implementation; kept dependency-free on purpose.
|
||||||
|
*/
|
||||||
|
final class MarkdownRenderer {
|
||||||
|
private MarkdownRenderer() {}
|
||||||
|
|
||||||
|
static String toHtml(String markdown) {
|
||||||
|
if (markdown == null) markdown = "";
|
||||||
|
|
||||||
|
List<String> lines = splitLines(markdown);
|
||||||
|
StringBuilder out = new StringBuilder(4096);
|
||||||
|
out.append("<html><head><meta charset='utf-8'>")
|
||||||
|
.append("<style>")
|
||||||
|
// Swing's HTML renderer does not reliably apply default heading sizes,
|
||||||
|
// so we explicitly define font sizes to keep a clear hierarchy.
|
||||||
|
.append("body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif;font-size:13px;line-height:1.45;margin:10px;color:#111;}")
|
||||||
|
.append("code,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}")
|
||||||
|
// Keep inline code readable (Swing may render it too small otherwise).
|
||||||
|
.append("code{font-size:0.95em;background:#f6f8fa;border:1px solid #e5e7eb;border-radius:4px;padding:0 4px;}")
|
||||||
|
.append("pre{font-size:0.95em;background:#f6f8fa;border:1px solid #e5e7eb;border-radius:6px;padding:10px;overflow:auto;}")
|
||||||
|
.append("pre code{font-size:1em;background:transparent;border:none;padding:0;}")
|
||||||
|
.append("p{margin:0.55em 0;}")
|
||||||
|
.append("h1{font-size:20px;margin:0.85em 0 0.45em 0;}")
|
||||||
|
.append("h2{font-size:18px;margin:0.85em 0 0.45em 0;}")
|
||||||
|
.append("h3{font-size:16px;margin:0.8em 0 0.4em 0;}")
|
||||||
|
.append("h4{font-size:14px;margin:0.8em 0 0.4em 0;}")
|
||||||
|
.append("h5{font-size:13px;margin:0.75em 0 0.35em 0;}")
|
||||||
|
.append("h6{font-size:13px;margin:0.75em 0 0.35em 0;}")
|
||||||
|
.append("ul{margin:0.4em 0 0.6em 1.2em;padding:0;}")
|
||||||
|
.append("</style></head><body>");
|
||||||
|
|
||||||
|
boolean inCode = false;
|
||||||
|
boolean inList = false;
|
||||||
|
StringBuilder codeBuf = new StringBuilder();
|
||||||
|
|
||||||
|
for (String raw : lines) {
|
||||||
|
String line = raw == null ? "" : raw;
|
||||||
|
|
||||||
|
if (line.trim().startsWith("```")) {
|
||||||
|
if (!inCode) {
|
||||||
|
inCode = true;
|
||||||
|
codeBuf.setLength(0);
|
||||||
|
} else {
|
||||||
|
// close code
|
||||||
|
out.append("<pre><code>")
|
||||||
|
.append(escapeHtml(codeBuf.toString()))
|
||||||
|
.append("</code></pre>");
|
||||||
|
inCode = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCode) {
|
||||||
|
codeBuf.append(line).append("\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
if (inList) {
|
||||||
|
out.append("</ul>");
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
out.append("<div style='height:6px'></div>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// headings
|
||||||
|
int h = headingLevel(trimmed);
|
||||||
|
if (h > 0) {
|
||||||
|
if (inList) {
|
||||||
|
out.append("</ul>");
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
String text = trimmed.substring(h).trim();
|
||||||
|
out.append("<h").append(h).append(">")
|
||||||
|
.append(inlineFormat(text))
|
||||||
|
.append("</h").append(h).append(">");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// list items
|
||||||
|
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
||||||
|
if (!inList) {
|
||||||
|
out.append("<ul>");
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
String item = trimmed.substring(2).trim();
|
||||||
|
out.append("<li>").append(inlineFormat(item)).append("</li>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// normal paragraph
|
||||||
|
if (inList) {
|
||||||
|
out.append("</ul>");
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
out.append("<p>").append(inlineFormat(trimmed)).append("</p>");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inCode) {
|
||||||
|
out.append("<pre><code>")
|
||||||
|
.append(escapeHtml(codeBuf.toString()))
|
||||||
|
.append("</code></pre>");
|
||||||
|
}
|
||||||
|
if (inList) {
|
||||||
|
out.append("</ul>");
|
||||||
|
}
|
||||||
|
|
||||||
|
out.append("</body></html>");
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int headingLevel(String s) {
|
||||||
|
int i = 0;
|
||||||
|
while (i < s.length() && s.charAt(i) == '#') i++;
|
||||||
|
if (i >= 1 && i <= 6 && i < s.length() && Character.isWhitespace(s.charAt(i))) return i;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String inlineFormat(String text) {
|
||||||
|
// escape first, then apply simple replacements using placeholders
|
||||||
|
String escaped = escapeHtml(text);
|
||||||
|
|
||||||
|
// inline code: `code`
|
||||||
|
escaped = replaceInlineCode(escaped);
|
||||||
|
|
||||||
|
// bold: **text**
|
||||||
|
escaped = replaceBold(escaped);
|
||||||
|
|
||||||
|
return escaped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String replaceInlineCode(String s) {
|
||||||
|
StringBuilder out = new StringBuilder(s.length() + 16);
|
||||||
|
boolean in = false;
|
||||||
|
StringBuilder buf = new StringBuilder();
|
||||||
|
for (int i = 0; i < s.length(); i++) {
|
||||||
|
char c = s.charAt(i);
|
||||||
|
if (c == '`') {
|
||||||
|
if (!in) {
|
||||||
|
in = true;
|
||||||
|
buf.setLength(0);
|
||||||
|
} else {
|
||||||
|
out.append("<code>").append(buf).append("</code>");
|
||||||
|
in = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (in) buf.append(c);
|
||||||
|
else out.append(c);
|
||||||
|
}
|
||||||
|
if (in) {
|
||||||
|
// unmatched backtick: keep as literal
|
||||||
|
out.append("`").append(buf);
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String replaceBold(String s) {
|
||||||
|
// simple non-nested **...**
|
||||||
|
StringBuilder out = new StringBuilder(s.length() + 16);
|
||||||
|
int i = 0;
|
||||||
|
while (i < s.length()) {
|
||||||
|
int start = s.indexOf("**", i);
|
||||||
|
if (start < 0) {
|
||||||
|
out.append(s.substring(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
int end = s.indexOf("**", start + 2);
|
||||||
|
if (end < 0) {
|
||||||
|
out.append(s.substring(i));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.append(s.substring(i, start));
|
||||||
|
out.append("<b>").append(s, start + 2, end).append("</b>");
|
||||||
|
i = end + 2;
|
||||||
|
}
|
||||||
|
return out.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String escapeHtml(String s) {
|
||||||
|
if (s == null) return "";
|
||||||
|
return s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> splitLines(String s) {
|
||||||
|
String[] parts = s.split("\\r?\\n", -1);
|
||||||
|
List<String> lines = new ArrayList<>(parts.length);
|
||||||
|
for (String p : parts) lines.add(p);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package burp;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal JSON extractor for the SSE payloads we emit:
|
||||||
|
* {"type":"...","message":"...","data":...}
|
||||||
|
*
|
||||||
|
* This is NOT a general-purpose JSON parser; it's intentionally small to avoid external deps.
|
||||||
|
*/
|
||||||
|
final class SimpleJson {
|
||||||
|
private SimpleJson() {}
|
||||||
|
|
||||||
|
static Map<String, String> extractTopLevelStringFields(String json, String... keys) {
|
||||||
|
Map<String, String> out = new HashMap<>();
|
||||||
|
if (json == null) return out;
|
||||||
|
for (String key : keys) {
|
||||||
|
out.put(key, extractStringField(json, key));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String extractStringField(String json, String key) {
|
||||||
|
if (json == null || key == null) return "";
|
||||||
|
String needle = "\"" + key + "\"";
|
||||||
|
int k = json.indexOf(needle);
|
||||||
|
if (k < 0) return "";
|
||||||
|
int colon = json.indexOf(':', k + needle.length());
|
||||||
|
if (colon < 0) return "";
|
||||||
|
int i = colon + 1;
|
||||||
|
while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++;
|
||||||
|
if (i >= json.length() || json.charAt(i) != '"') return "";
|
||||||
|
i++; // after opening quote
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
boolean esc = false;
|
||||||
|
while (i < json.length()) {
|
||||||
|
char c = json.charAt(i++);
|
||||||
|
if (esc) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': sb.append('"'); break;
|
||||||
|
case '\\': sb.append('\\'); break;
|
||||||
|
case '/': sb.append('/'); break;
|
||||||
|
case 'b': sb.append('\b'); break;
|
||||||
|
case 'f': sb.append('\f'); break;
|
||||||
|
case 'n': sb.append('\n'); break;
|
||||||
|
case 'r': sb.append('\r'); break;
|
||||||
|
case 't': sb.append('\t'); break;
|
||||||
|
case 'u':
|
||||||
|
if (i + 3 < json.length()) {
|
||||||
|
String hex = json.substring(i, i + 4);
|
||||||
|
try {
|
||||||
|
sb.append((char) Integer.parseInt(hex, 16));
|
||||||
|
i += 4;
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
// best-effort: keep raw
|
||||||
|
sb.append("\\u").append(hex);
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
esc = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '\\') {
|
||||||
|
esc = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '"') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sb.append(c);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,3 @@
|
|||||||
|
artifactId=cyberstrikeai-burp-extension
|
||||||
|
groupId=ai.cyberstrike
|
||||||
|
version=1.0.0
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
burp/CyberStrikeAIClient$StreamListener.class
|
||||||
|
burp/CyberStrikeAIClient$Config.class
|
||||||
|
burp/CyberStrikeAIClient$AgentMode.class
|
||||||
|
burp/MarkdownRenderer.class
|
||||||
|
burp/SimpleJson.class
|
||||||
|
burp/CyberStrikeAIClient.class
|
||||||
|
burp/CyberStrikeAITab$DotIcon.class
|
||||||
|
burp/CyberStrikeAITab.class
|
||||||
|
burp/CyberStrikeAITab$1.class
|
||||||
|
burp/BurpExtender$1.class
|
||||||
|
burp/BurpExtender.class
|
||||||
|
burp/CyberStrikeAITab$TestRun.class
|
||||||
|
burp/CyberStrikeAITab$TestRunCellRenderer.class
|
||||||
|
burp/HttpMessageFormatter.class
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/BurpExtender.java
|
||||||
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/CyberStrikeAIClient.java
|
||||||
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/CyberStrikeAITab.java
|
||||||
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/HttpMessageFormatter.java
|
||||||
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/MarkdownRenderer.java
|
||||||
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SimpleJson.java
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
requests>=2.32.3
|
requests>=2.32.3
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
charset-normalizer>=3.3.2
|
charset-normalizer>=3.3.2
|
||||||
chardet>=5.2.0
|
chardet>=5.2.0,<6
|
||||||
|
|
||||||
# Python exploitation / analysis frameworks referenced by tool recipes
|
# Python exploitation / analysis frameworks referenced by tool recipes
|
||||||
# angr>=9.2.96
|
# angr>=9.2.96
|
||||||
|
|||||||
+76
-28
@@ -9264,9 +9264,8 @@ header {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.webshell-col-owner,
|
.webshell-col-owner {
|
||||||
.webshell-col-group {
|
width: 150px;
|
||||||
width: 110px;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -10359,54 +10358,93 @@ header {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.webshell-db-profile-modal-content {
|
||||||
|
max-width: 840px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||||
|
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.18);
|
||||||
|
}
|
||||||
|
#webshell-db-profile-modal .modal-header {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
#webshell-db-profile-modal .modal-header h2 {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: none;
|
||||||
|
-webkit-text-fill-color: currentColor;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
#webshell-db-profile-modal .modal-body {
|
||||||
|
padding: 14px 18px 10px;
|
||||||
|
}
|
||||||
|
#webshell-db-profile-modal .modal-footer {
|
||||||
|
padding: 10px 18px 14px;
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
#webshell-db-profile-modal .modal-footer .btn-secondary,
|
||||||
|
#webshell-db-profile-modal .modal-footer .btn-primary {
|
||||||
|
min-width: 78px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
.webshell-db-toolbar {
|
.webshell-db-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
grid-template-columns: repeat(4, minmax(130px, 1fr));
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.96) 100%);
|
background: #f8fafc;
|
||||||
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06), inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.webshell-db-toolbar label {
|
.webshell-db-toolbar label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 8px 10px;
|
padding: 7px 9px;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.82);
|
background: #fff;
|
||||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
.webshell-db-toolbar label:focus-within {
|
.webshell-db-toolbar label:focus-within {
|
||||||
border-color: rgba(0, 102, 255, 0.38);
|
border-color: rgba(0, 102, 255, 0.32);
|
||||||
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12);
|
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
.webshell-db-toolbar label span {
|
.webshell-db-toolbar label span {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-secondary);
|
color: #64748b;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.01em;
|
||||||
text-transform: uppercase;
|
text-transform: none;
|
||||||
}
|
}
|
||||||
.webshell-db-toolbar .form-control {
|
.webshell-db-toolbar .form-control {
|
||||||
height: 36px;
|
height: 34px;
|
||||||
border-radius: 8px;
|
border-radius: 7px;
|
||||||
border: 1px solid rgba(15, 23, 42, 0.16);
|
border: 1px solid rgba(15, 23, 42, 0.14);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-size: 0.9rem;
|
font-size: 0.88rem;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
.webshell-db-toolbar .form-control:focus {
|
.webshell-db-toolbar .form-control:focus {
|
||||||
border-color: var(--accent-color);
|
border-color: var(--accent-color);
|
||||||
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
|
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
.webshell-db-toolbar select.form-control {
|
||||||
|
appearance: auto;
|
||||||
|
-webkit-appearance: menulist;
|
||||||
|
-moz-appearance: menulist;
|
||||||
|
padding-right: 8px;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
#webshell-db-sqlite-row {
|
#webshell-db-sqlite-row {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
@@ -10522,7 +10560,7 @@ header {
|
|||||||
grid-template-columns: 240px minmax(0, 1fr);
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
.webshell-db-toolbar {
|
.webshell-db-toolbar {
|
||||||
grid-template-columns: repeat(3, minmax(140px, 1fr));
|
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
@@ -10533,13 +10571,23 @@ header {
|
|||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
.webshell-db-toolbar {
|
.webshell-db-toolbar {
|
||||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
grid-template-columns: repeat(2, minmax(120px, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.webshell-db-toolbar {
|
.webshell-db-toolbar {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
#webshell-db-profile-modal .modal-content {
|
||||||
|
width: calc(100% - 24px);
|
||||||
|
margin: 32px auto;
|
||||||
|
}
|
||||||
|
#webshell-db-profile-modal .modal-header,
|
||||||
|
#webshell-db-profile-modal .modal-body,
|
||||||
|
#webshell-db-profile-modal .modal-footer {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 仪表盘页面样式(最佳实践布局 + 视觉增强) */
|
/* 仪表盘页面样式(最佳实践布局 + 视觉增强) */
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
|
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
|
||||||
"deleteConversationConfirm": "Are you sure you want to delete this conversation?",
|
"deleteConversationConfirm": "Are you sure you want to delete this conversation?",
|
||||||
"renameFailed": "Rename failed",
|
"renameFailed": "Rename failed",
|
||||||
|
"downloadConversationFailed": "Failed to download conversation",
|
||||||
"viewAttackChainSelectConv": "Please select a conversation to view attack chain",
|
"viewAttackChainSelectConv": "Please select a conversation to view attack chain",
|
||||||
"viewAttackChainCurrentConv": "View attack chain of current conversation",
|
"viewAttackChainCurrentConv": "View attack chain of current conversation",
|
||||||
"executeFailed": "Execution failed",
|
"executeFailed": "Execution failed",
|
||||||
@@ -402,7 +403,10 @@
|
|||||||
"dbRows": "rows",
|
"dbRows": "rows",
|
||||||
"dbColumns": "columns",
|
"dbColumns": "columns",
|
||||||
"dbSchemaFailed": "Failed to load schema",
|
"dbSchemaFailed": "Failed to load schema",
|
||||||
|
"dbSchemaLoaded": "Schema loaded successfully",
|
||||||
"dbAddProfile": "Add connection",
|
"dbAddProfile": "Add connection",
|
||||||
|
"dbExecSuccess": "SQL executed successfully",
|
||||||
|
"dbNoOutput": "Execution completed (no output)",
|
||||||
"dbRenameProfile": "Rename",
|
"dbRenameProfile": "Rename",
|
||||||
"dbDeleteProfile": "Delete connection",
|
"dbDeleteProfile": "Delete connection",
|
||||||
"dbDeleteProfileConfirm": "Delete this database connection profile?",
|
"dbDeleteProfileConfirm": "Delete this database connection profile?",
|
||||||
@@ -458,6 +462,7 @@
|
|||||||
"searchPlaceholder": "Search connections...",
|
"searchPlaceholder": "Search connections...",
|
||||||
"noMatchConnections": "No matching connections",
|
"noMatchConnections": "No matching connections",
|
||||||
"breadcrumbHome": "Root",
|
"breadcrumbHome": "Root",
|
||||||
|
"dirTree": "Directory tree",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"moreActions": "More actions",
|
"moreActions": "More actions",
|
||||||
"batchProbe": "Batch probe",
|
"batchProbe": "Batch probe",
|
||||||
@@ -1231,6 +1236,15 @@
|
|||||||
"fofaApiKeyHint": "Stored in server config (config.yaml) only.",
|
"fofaApiKeyHint": "Stored in server config (config.yaml) only.",
|
||||||
"maxIterations": "Max iterations",
|
"maxIterations": "Max iterations",
|
||||||
"iterationsPlaceholder": "30",
|
"iterationsPlaceholder": "30",
|
||||||
|
"enableMultiAgent": "Enable Eino multi-agent (DeepAgent)",
|
||||||
|
"enableMultiAgentHint": "After enabling, the chat page can use multi-agent mode; sub-agents are configured in config.yaml under multi_agent.sub_agents.",
|
||||||
|
"multiAgentDefaultMode": "Default mode on chat page",
|
||||||
|
"multiAgentModeSingle": "Single-agent (ReAct)",
|
||||||
|
"multiAgentModeMulti": "Multi-agent (Eino)",
|
||||||
|
"multiAgentRobotUse": "Use multi-agent for WeCom / DingTalk / Lark bots",
|
||||||
|
"multiAgentRobotUseHint": "Requires 'Enable multi-agent' to be checked; usage and cost will be higher.",
|
||||||
|
"multiAgentBatchUse": "Use multi-agent for batch task queues",
|
||||||
|
"multiAgentBatchUseHint": "When enabled, each sub-task executed by queue in Task Management will run through Eino DeepAgent (requires multi-agent).",
|
||||||
"enableKnowledge": "Enable knowledge retrieval",
|
"enableKnowledge": "Enable knowledge retrieval",
|
||||||
"knowledgeBasePath": "Knowledge base path",
|
"knowledgeBasePath": "Knowledge base path",
|
||||||
"knowledgeBasePathPlaceholder": "knowledge_base",
|
"knowledgeBasePathPlaceholder": "knowledge_base",
|
||||||
@@ -1437,6 +1451,9 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"viewAttackChain": "View attack chain",
|
"viewAttackChain": "View attack chain",
|
||||||
|
"downloadMarkdown": "Download Markdown",
|
||||||
|
"downloadMarkdownSummary": "Summary",
|
||||||
|
"downloadMarkdownFull": "Full",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"pinConversation": "Pin conversation",
|
"pinConversation": "Pin conversation",
|
||||||
"unpinConversation": "Unpin",
|
"unpinConversation": "Unpin",
|
||||||
|
|||||||
@@ -138,6 +138,7 @@
|
|||||||
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
|
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
|
||||||
"deleteConversationConfirm": "确定要删除此对话吗?",
|
"deleteConversationConfirm": "确定要删除此对话吗?",
|
||||||
"renameFailed": "重命名失败",
|
"renameFailed": "重命名失败",
|
||||||
|
"downloadConversationFailed": "下载对话失败",
|
||||||
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
|
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
|
||||||
"viewAttackChainCurrentConv": "查看当前对话的攻击链",
|
"viewAttackChainCurrentConv": "查看当前对话的攻击链",
|
||||||
"executeFailed": "执行失败",
|
"executeFailed": "执行失败",
|
||||||
@@ -402,11 +403,15 @@
|
|||||||
"dbRows": "行",
|
"dbRows": "行",
|
||||||
"dbColumns": "列",
|
"dbColumns": "列",
|
||||||
"dbSchemaFailed": "加载数据库结构失败",
|
"dbSchemaFailed": "加载数据库结构失败",
|
||||||
|
"dbSchemaLoaded": "结构加载完成",
|
||||||
"dbAddProfile": "新增连接",
|
"dbAddProfile": "新增连接",
|
||||||
|
"dbExecSuccess": "SQL 执行成功",
|
||||||
|
"dbNoOutput": "执行完成(无输出)",
|
||||||
"dbRenameProfile": "重命名",
|
"dbRenameProfile": "重命名",
|
||||||
"dbDeleteProfile": "删除连接",
|
"dbDeleteProfile": "删除连接",
|
||||||
"dbDeleteProfileConfirm": "确定删除该数据库连接配置吗?",
|
"dbDeleteProfileConfirm": "确定删除该数据库连接配置吗?",
|
||||||
"dbProfileNamePrompt": "请输入连接名称",
|
"dbProfileNamePrompt": "请输入连接名称",
|
||||||
|
"dbProfileName": "连接名称",
|
||||||
"dbProfiles": "数据库连接",
|
"dbProfiles": "数据库连接",
|
||||||
"aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
|
"aiSystemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||||
"aiNewConversation": "新对话",
|
"aiNewConversation": "新对话",
|
||||||
@@ -1231,6 +1236,15 @@
|
|||||||
"fofaApiKeyHint": "仅保存在服务器配置中(`config.yaml`)。",
|
"fofaApiKeyHint": "仅保存在服务器配置中(`config.yaml`)。",
|
||||||
"maxIterations": "最大迭代次数",
|
"maxIterations": "最大迭代次数",
|
||||||
"iterationsPlaceholder": "30",
|
"iterationsPlaceholder": "30",
|
||||||
|
"enableMultiAgent": "启用 Eino 多代理(DeepAgent)",
|
||||||
|
"enableMultiAgentHint": "开启后对话页可选「多代理」模式;子代理在 config.yaml 的 multi_agent.sub_agents 中配置。",
|
||||||
|
"multiAgentDefaultMode": "对话页默认模式",
|
||||||
|
"multiAgentModeSingle": "单代理(ReAct)",
|
||||||
|
"multiAgentModeMulti": "多代理(Eino)",
|
||||||
|
"multiAgentRobotUse": "企业微信 / 钉钉 / 飞书机器人也使用多代理",
|
||||||
|
"multiAgentRobotUseHint": "需同时勾选「启用多代理」;调用量与成本更高。",
|
||||||
|
"multiAgentBatchUse": "批量任务队列也使用多代理",
|
||||||
|
"multiAgentBatchUseHint": "开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。",
|
||||||
"enableKnowledge": "启用知识检索功能",
|
"enableKnowledge": "启用知识检索功能",
|
||||||
"knowledgeBasePath": "知识库路径",
|
"knowledgeBasePath": "知识库路径",
|
||||||
"knowledgeBasePathPlaceholder": "knowledge_base",
|
"knowledgeBasePathPlaceholder": "knowledge_base",
|
||||||
@@ -1437,6 +1451,9 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"viewAttackChain": "查看攻击链",
|
"viewAttackChain": "查看攻击链",
|
||||||
|
"downloadMarkdown": "下载 Markdown",
|
||||||
|
"downloadMarkdownSummary": "简版",
|
||||||
|
"downloadMarkdownFull": "完整版",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
"pinConversation": "置顶此对话",
|
"pinConversation": "置顶此对话",
|
||||||
"unpinConversation": "取消置顶",
|
"unpinConversation": "取消置顶",
|
||||||
|
|||||||
+244
-14
@@ -4076,6 +4076,7 @@ let contextMenuGroupId = null;
|
|||||||
let groupsCache = [];
|
let groupsCache = [];
|
||||||
let conversationGroupMappingCache = {};
|
let conversationGroupMappingCache = {};
|
||||||
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
||||||
|
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
||||||
|
|
||||||
// 加载分组列表
|
// 加载分组列表
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
@@ -4170,11 +4171,14 @@ async function loadGroups() {
|
|||||||
|
|
||||||
// 加载对话列表(修改为支持分组和置顶)
|
// 加载对话列表(修改为支持分组和置顶)
|
||||||
async function loadConversationsWithGroups(searchQuery = '') {
|
async function loadConversationsWithGroups(searchQuery = '') {
|
||||||
|
const loadSeq = ++conversationsListLoadSeq;
|
||||||
try {
|
try {
|
||||||
// 总是重新加载分组列表和分组映射,确保缓存是最新的
|
// 总是重新加载分组列表和分组映射,确保缓存是最新的
|
||||||
// 这样可以正确处理分组被删除后的情况
|
// 这样可以正确处理分组被删除后的情况
|
||||||
await loadGroups();
|
await loadGroups();
|
||||||
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
await loadConversationGroupMapping();
|
await loadConversationGroupMapping();
|
||||||
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
|
|
||||||
// 如果有搜索关键词,使用更大的limit以获取所有匹配结果
|
// 如果有搜索关键词,使用更大的limit以获取所有匹配结果
|
||||||
const limit = (searchQuery && searchQuery.trim()) ? 1000 : 100;
|
const limit = (searchQuery && searchQuery.trim()) ? 1000 : 100;
|
||||||
@@ -4183,6 +4187,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
||||||
}
|
}
|
||||||
const response = await apiFetch(url);
|
const response = await apiFetch(url);
|
||||||
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
|
|
||||||
const listContainer = document.getElementById('conversations-list');
|
const listContainer = document.getElementById('conversations-list');
|
||||||
if (!listContainer) {
|
if (!listContainer) {
|
||||||
@@ -4204,8 +4209,20 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const conversations = await response.json();
|
const conversations = await response.json();
|
||||||
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
|
|
||||||
if (!Array.isArray(conversations) || conversations.length === 0) {
|
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
|
||||||
|
const uniqueConversations = [];
|
||||||
|
const seenConversationIds = new Set();
|
||||||
|
(Array.isArray(conversations) ? conversations : []).forEach(conv => {
|
||||||
|
if (!conv || !conv.id || seenConversationIds.has(conv.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seenConversationIds.add(conv.id);
|
||||||
|
uniqueConversations.push(conv);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uniqueConversations.length === 0) {
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
listContainer.innerHTML = emptyStateHtml;
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
return;
|
return;
|
||||||
@@ -4216,7 +4233,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
const normalConvs = [];
|
const normalConvs = [];
|
||||||
const hasSearchQuery = searchQuery && searchQuery.trim();
|
const hasSearchQuery = searchQuery && searchQuery.trim();
|
||||||
|
|
||||||
conversations.forEach(conv => {
|
uniqueConversations.forEach(conv => {
|
||||||
// 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的)
|
// 如果有搜索关键词,显示所有匹配的对话(全局搜索,包括分组中的)
|
||||||
if (hasSearchQuery) {
|
if (hasSearchQuery) {
|
||||||
// 搜索时显示所有匹配的对话,不管是否在分组中
|
// 搜索时显示所有匹配的对话,不管是否在分组中
|
||||||
@@ -4273,6 +4290,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
listContainer.appendChild(fragment);
|
listContainer.appendChild(fragment);
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
|
|
||||||
@@ -4280,10 +4298,13 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
if (sidebarContent) {
|
if (sidebarContent) {
|
||||||
// 使用 requestAnimationFrame 确保 DOM 已经更新
|
// 使用 requestAnimationFrame 确保 DOM 已经更新
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
sidebarContent.scrollTop = savedScrollTop;
|
if (loadSeq === conversationsListLoadSeq) {
|
||||||
|
sidebarContent.scrollTop = savedScrollTop;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
console.error('加载对话列表失败:', error);
|
console.error('加载对话列表失败:', error);
|
||||||
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
|
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
|
||||||
const listContainer = document.getElementById('conversations-list');
|
const listContainer = document.getElementById('conversations-list');
|
||||||
@@ -4383,9 +4404,14 @@ async function showConversationContextMenu(event) {
|
|||||||
submenu.style.display = 'none';
|
submenu.style.display = 'none';
|
||||||
submenuVisible = false;
|
submenuVisible = false;
|
||||||
}
|
}
|
||||||
|
const downloadSubmenu = document.getElementById('download-markdown-submenu');
|
||||||
|
if (downloadSubmenu) {
|
||||||
|
downloadSubmenu.style.display = 'none';
|
||||||
|
}
|
||||||
// 清除所有定时器
|
// 清除所有定时器
|
||||||
clearSubmenuHideTimeout();
|
clearSubmenuHideTimeout();
|
||||||
clearSubmenuShowTimeout();
|
clearSubmenuShowTimeout();
|
||||||
|
clearDownloadMarkdownSubmenuHideTimeout();
|
||||||
submenuLoading = false;
|
submenuLoading = false;
|
||||||
|
|
||||||
const convId = contextMenuConversationId;
|
const convId = contextMenuConversationId;
|
||||||
@@ -4516,26 +4542,44 @@ async function showConversationContextMenu(event) {
|
|||||||
menu.style.top = top + 'px';
|
menu.style.top = top + 'px';
|
||||||
|
|
||||||
// 如果菜单在右侧,子菜单应该在左侧显示
|
// 如果菜单在右侧,子菜单应该在左侧显示
|
||||||
if (submenu && left < event.clientX) {
|
if (left < event.clientX) {
|
||||||
submenu.style.left = 'auto';
|
if (submenu) {
|
||||||
submenu.style.right = '100%';
|
submenu.style.left = 'auto';
|
||||||
submenu.style.marginLeft = '0';
|
submenu.style.right = '100%';
|
||||||
submenu.style.marginRight = '4px';
|
submenu.style.marginLeft = '0';
|
||||||
} else if (submenu) {
|
submenu.style.marginRight = '4px';
|
||||||
submenu.style.left = '100%';
|
}
|
||||||
submenu.style.right = 'auto';
|
if (downloadSubmenu) {
|
||||||
submenu.style.marginLeft = '4px';
|
downloadSubmenu.style.left = 'auto';
|
||||||
submenu.style.marginRight = '0';
|
downloadSubmenu.style.right = '100%';
|
||||||
|
downloadSubmenu.style.marginLeft = '0';
|
||||||
|
downloadSubmenu.style.marginRight = '4px';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (submenu) {
|
||||||
|
submenu.style.left = '100%';
|
||||||
|
submenu.style.right = 'auto';
|
||||||
|
submenu.style.marginLeft = '4px';
|
||||||
|
submenu.style.marginRight = '0';
|
||||||
|
}
|
||||||
|
if (downloadSubmenu) {
|
||||||
|
downloadSubmenu.style.left = '100%';
|
||||||
|
downloadSubmenu.style.right = 'auto';
|
||||||
|
downloadSubmenu.style.marginLeft = '4px';
|
||||||
|
downloadSubmenu.style.marginRight = '0';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击外部关闭菜单
|
// 点击外部关闭菜单
|
||||||
const closeMenu = (e) => {
|
const closeMenu = (e) => {
|
||||||
// 检查点击是否在主菜单或子菜单内
|
// 检查点击是否在主菜单或子菜单内
|
||||||
const moveToGroupSubmenuEl = document.getElementById('move-to-group-submenu');
|
const moveToGroupSubmenuEl = document.getElementById('move-to-group-submenu');
|
||||||
|
const downloadMarkdownSubmenuEl = document.getElementById('download-markdown-submenu');
|
||||||
const clickedInMenu = menu.contains(e.target);
|
const clickedInMenu = menu.contains(e.target);
|
||||||
const clickedInSubmenu = moveToGroupSubmenuEl && moveToGroupSubmenuEl.contains(e.target);
|
const clickedInSubmenu = moveToGroupSubmenuEl && moveToGroupSubmenuEl.contains(e.target);
|
||||||
|
const clickedInDownloadSubmenu = downloadMarkdownSubmenuEl && downloadMarkdownSubmenuEl.contains(e.target);
|
||||||
|
|
||||||
if (!clickedInMenu && !clickedInSubmenu) {
|
if (!clickedInMenu && !clickedInSubmenu && !clickedInDownloadSubmenu) {
|
||||||
// 使用 closeContextMenu 确保同时关闭主菜单和子菜单
|
// 使用 closeContextMenu 确保同时关闭主菜单和子菜单
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
document.removeEventListener('click', closeMenu);
|
document.removeEventListener('click', closeMenu);
|
||||||
@@ -4929,6 +4973,8 @@ let submenuShowTimeout = null;
|
|||||||
let submenuLoading = false;
|
let submenuLoading = false;
|
||||||
// 子菜单是否已显示
|
// 子菜单是否已显示
|
||||||
let submenuVisible = false;
|
let submenuVisible = false;
|
||||||
|
// 下载Markdown子菜单隐藏定时器
|
||||||
|
let downloadMarkdownSubmenuHideTimeout = null;
|
||||||
|
|
||||||
// 隐藏移动到分组子菜单
|
// 隐藏移动到分组子菜单
|
||||||
function hideMoveToGroupSubmenu() {
|
function hideMoveToGroupSubmenu() {
|
||||||
@@ -4955,6 +5001,45 @@ function clearSubmenuShowTimeout() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearDownloadMarkdownSubmenuHideTimeout() {
|
||||||
|
if (downloadMarkdownSubmenuHideTimeout) {
|
||||||
|
clearTimeout(downloadMarkdownSubmenuHideTimeout);
|
||||||
|
downloadMarkdownSubmenuHideTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDownloadMarkdownSubmenu() {
|
||||||
|
const submenu = document.getElementById('download-markdown-submenu');
|
||||||
|
if (!submenu) return;
|
||||||
|
clearDownloadMarkdownSubmenuHideTimeout();
|
||||||
|
submenu.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDownloadMarkdownSubmenu() {
|
||||||
|
const submenu = document.getElementById('download-markdown-submenu');
|
||||||
|
if (!submenu) return;
|
||||||
|
submenu.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadMarkdownSubmenuEnter() {
|
||||||
|
clearDownloadMarkdownSubmenuHideTimeout();
|
||||||
|
showDownloadMarkdownSubmenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadMarkdownSubmenuLeave(event) {
|
||||||
|
const submenu = document.getElementById('download-markdown-submenu');
|
||||||
|
if (!submenu) return;
|
||||||
|
const relatedTarget = event.relatedTarget;
|
||||||
|
if (relatedTarget && submenu.contains(relatedTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearDownloadMarkdownSubmenuHideTimeout();
|
||||||
|
downloadMarkdownSubmenuHideTimeout = setTimeout(() => {
|
||||||
|
hideDownloadMarkdownSubmenu();
|
||||||
|
downloadMarkdownSubmenuHideTimeout = null;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
// 处理鼠标进入"移动到分组"菜单项(带防抖)
|
// 处理鼠标进入"移动到分组"菜单项(带防抖)
|
||||||
function handleMoveToGroupSubmenuEnter() {
|
function handleMoveToGroupSubmenuEnter() {
|
||||||
// 清除隐藏定时器
|
// 清除隐藏定时器
|
||||||
@@ -5157,6 +5242,146 @@ function showAttackChainFromContext() {
|
|||||||
showAttackChain(convId);
|
showAttackChain(convId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatConversationDateForMarkdown(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const d = new Date(value);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||||
|
return d.toLocaleString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConversationRoleLabel(role) {
|
||||||
|
switch (role) {
|
||||||
|
case 'assistant':
|
||||||
|
return 'Assistant';
|
||||||
|
case 'user':
|
||||||
|
return 'User';
|
||||||
|
case 'system':
|
||||||
|
return 'System';
|
||||||
|
default:
|
||||||
|
return role || 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConversationAsMarkdown(conversation, options = {}) {
|
||||||
|
const includeToolDetails = !!options.includeToolDetails;
|
||||||
|
const title = (conversation && conversation.title ? String(conversation.title) : '').trim() || 'Untitled Conversation';
|
||||||
|
const createdAt = formatConversationDateForMarkdown(conversation && conversation.createdAt);
|
||||||
|
const updatedAt = formatConversationDateForMarkdown(conversation && conversation.updatedAt);
|
||||||
|
const messages = Array.isArray(conversation && conversation.messages) ? conversation.messages : [];
|
||||||
|
|
||||||
|
let markdown = `# ${title}\n\n`;
|
||||||
|
markdown += `- Conversation ID: \`${conversation && conversation.id ? conversation.id : ''}\`\n`;
|
||||||
|
if (createdAt) markdown += `- Created At: ${createdAt}\n`;
|
||||||
|
if (updatedAt) markdown += `- Updated At: ${updatedAt}\n`;
|
||||||
|
markdown += `- Message Count: ${messages.length}\n\n`;
|
||||||
|
markdown += '---\n\n';
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
markdown += '_No messages in this conversation._\n';
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.forEach((msg, index) => {
|
||||||
|
const role = getConversationRoleLabel(msg && msg.role);
|
||||||
|
const timestamp = formatConversationDateForMarkdown(msg && msg.createdAt);
|
||||||
|
const content = msg && typeof msg.content === 'string' ? msg.content : '';
|
||||||
|
|
||||||
|
markdown += `## ${index + 1}. ${role}`;
|
||||||
|
if (timestamp) markdown += ` (${timestamp})`;
|
||||||
|
markdown += '\n\n';
|
||||||
|
markdown += content ? `${content}\n\n` : '_[Empty message]_\n\n';
|
||||||
|
|
||||||
|
if (Array.isArray(msg && msg.processDetails) && msg.processDetails.length > 0) {
|
||||||
|
markdown += '### Process Details\n\n';
|
||||||
|
msg.processDetails.forEach((detail) => {
|
||||||
|
const detailTime = formatConversationDateForMarkdown(detail && detail.timestamp);
|
||||||
|
const eventType = detail && detail.eventType ? detail.eventType : 'event';
|
||||||
|
const detailMsg = detail && detail.message ? detail.message : '';
|
||||||
|
// Avoid "[label]:" pattern because some Markdown parsers treat it as link reference definition.
|
||||||
|
markdown += `- \`${eventType}\``;
|
||||||
|
if (detailTime) markdown += ` ${detailTime}`;
|
||||||
|
if (detailMsg) markdown += `: ${detailMsg}`;
|
||||||
|
markdown += '\n';
|
||||||
|
|
||||||
|
if (includeToolDetails && detail && detail.data && (eventType === 'tool_call' || eventType === 'tool_result')) {
|
||||||
|
const pretty = JSON.stringify(detail.data, null, 2);
|
||||||
|
markdown += '\n```json\n';
|
||||||
|
markdown += pretty || '{}';
|
||||||
|
markdown += '\n```\n';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
markdown += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(msg && msg.mcpExecutionIds) && msg.mcpExecutionIds.length > 0) {
|
||||||
|
markdown += `- MCP Execution IDs: ${msg.mcpExecutionIds.join(', ')}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
markdown += '---\n\n';
|
||||||
|
});
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConversationMarkdownFileName(conversation, options = {}) {
|
||||||
|
const includeToolDetails = !!options.includeToolDetails;
|
||||||
|
const title = (conversation && conversation.title ? String(conversation.title) : '').trim() || 'conversation';
|
||||||
|
const safeTitle = title
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.slice(0, 60) || 'conversation';
|
||||||
|
const idPart = (conversation && conversation.id ? String(conversation.id) : '').slice(0, 8) || 'export';
|
||||||
|
const modePart = includeToolDetails ? 'full' : 'summary';
|
||||||
|
return `${safeTitle}_${idPart}_${modePart}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从上下文菜单下载对话 Markdown
|
||||||
|
async function downloadConversationMarkdownFromContext(includeToolDetails = false) {
|
||||||
|
const convId = contextMenuConversationId;
|
||||||
|
if (!convId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/api/conversations/${convId}`);
|
||||||
|
let conversation = null;
|
||||||
|
try {
|
||||||
|
conversation = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
conversation = null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMsg = conversation && conversation.error ? conversation.error : 'unknown error';
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = formatConversationAsMarkdown(conversation || {}, { includeToolDetails });
|
||||||
|
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = buildConversationMarkdownFileName(conversation || {}, { includeToolDetails });
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载对话 Markdown 失败:', error);
|
||||||
|
const failedLabel = typeof window.t === 'function' ? window.t('chat.downloadConversationFailed') : '下载失败';
|
||||||
|
const errMsg = error && error.message ? error.message : 'unknown error';
|
||||||
|
alert(failedLabel + ': ' + errMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
// 从上下文菜单删除对话
|
// 从上下文菜单删除对话
|
||||||
function deleteConversationFromContext() {
|
function deleteConversationFromContext() {
|
||||||
const convId = contextMenuConversationId;
|
const convId = contextMenuConversationId;
|
||||||
@@ -5180,9 +5405,14 @@ function closeContextMenu() {
|
|||||||
submenu.style.display = 'none';
|
submenu.style.display = 'none';
|
||||||
submenuVisible = false;
|
submenuVisible = false;
|
||||||
}
|
}
|
||||||
|
const downloadSubmenu = document.getElementById('download-markdown-submenu');
|
||||||
|
if (downloadSubmenu) {
|
||||||
|
downloadSubmenu.style.display = 'none';
|
||||||
|
}
|
||||||
// 清除所有定时器
|
// 清除所有定时器
|
||||||
clearSubmenuHideTimeout();
|
clearSubmenuHideTimeout();
|
||||||
clearSubmenuShowTimeout();
|
clearSubmenuShowTimeout();
|
||||||
|
clearDownloadMarkdownSubmenuHideTimeout();
|
||||||
submenuLoading = false;
|
submenuLoading = false;
|
||||||
contextMenuConversationId = null;
|
contextMenuConversationId = null;
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-64
@@ -629,6 +629,29 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const timeline = document.getElementById(progressId + '-timeline');
|
const timeline = document.getElementById(progressId + '-timeline');
|
||||||
if (!timeline) return;
|
if (!timeline) return;
|
||||||
|
|
||||||
|
// 终态事件(error/cancelled)优先复用现有助手消息,避免重复追加相同报错
|
||||||
|
const upsertTerminalAssistantMessage = (message, preferredMessageId = null) => {
|
||||||
|
const preferredIds = [];
|
||||||
|
if (preferredMessageId) preferredIds.push(preferredMessageId);
|
||||||
|
const existingAssistantId = typeof getAssistantId === 'function' ? getAssistantId() : null;
|
||||||
|
if (existingAssistantId && !preferredIds.includes(existingAssistantId)) {
|
||||||
|
preferredIds.push(existingAssistantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of preferredIds) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) {
|
||||||
|
updateAssistantBubbleContent(id, message, true);
|
||||||
|
setAssistantId(id);
|
||||||
|
return { assistantId: id, assistantElement: element };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantId = addMessage('assistant', message, null, progressId);
|
||||||
|
setAssistantId(assistantId);
|
||||||
|
return { assistantId: assistantId, assistantElement: document.getElementById(assistantId) };
|
||||||
|
};
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'conversation':
|
case 'conversation':
|
||||||
if (event.data && event.data.conversationId) {
|
if (event.data && event.data.conversationId) {
|
||||||
@@ -1033,47 +1056,19 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCancelled') : '已取消');
|
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCancelled') : '已取消');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果取消事件包含messageId,说明有助手消息,需要显示取消内容
|
// 复用已有助手消息(若有),避免终态事件重复插入消息
|
||||||
if (event.data && event.data.messageId) {
|
{
|
||||||
// 检查助手消息是否已存在
|
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
|
||||||
let assistantId = event.data.messageId;
|
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
|
||||||
let assistantElement = document.getElementById(assistantId);
|
|
||||||
|
|
||||||
// 如果助手消息不存在,创建它
|
|
||||||
if (!assistantElement) {
|
|
||||||
assistantId = addMessage('assistant', event.message, null, progressId);
|
|
||||||
setAssistantId(assistantId);
|
|
||||||
assistantElement = document.getElementById(assistantId);
|
|
||||||
} else {
|
|
||||||
// 如果已存在,更新内容
|
|
||||||
const bubble = assistantElement.querySelector('.message-bubble');
|
|
||||||
if (bubble) {
|
|
||||||
bubble.innerHTML = escapeHtml(event.message).replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将进度详情集成到工具调用区域(如果还没有)
|
|
||||||
if (assistantElement) {
|
if (assistantElement) {
|
||||||
const detailsId = 'process-details-' + assistantId;
|
const detailsId = 'process-details-' + assistantId;
|
||||||
if (!document.getElementById(detailsId)) {
|
if (!document.getElementById(detailsId)) {
|
||||||
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
|
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
|
||||||
}
|
}
|
||||||
// 立即折叠详情(取消时应该默认折叠)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
collapseAllProgressDetails(assistantId, progressId);
|
collapseAllProgressDetails(assistantId, progressId);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 如果没有messageId,创建助手消息并集成详情
|
|
||||||
const assistantId = addMessage('assistant', event.message, null, progressId);
|
|
||||||
setAssistantId(assistantId);
|
|
||||||
|
|
||||||
// 将进度详情集成到工具调用区域
|
|
||||||
setTimeout(() => {
|
|
||||||
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
|
|
||||||
// 确保详情默认折叠
|
|
||||||
collapseAllProgressDetails(assistantId, progressId);
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 立即刷新任务状态
|
// 立即刷新任务状态
|
||||||
@@ -1232,47 +1227,19 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusFailed') : '执行失败');
|
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusFailed') : '执行失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果错误事件包含messageId,说明有助手消息,需要显示错误内容
|
// 复用已有助手消息(若有),避免终态事件重复插入消息
|
||||||
if (event.data && event.data.messageId) {
|
{
|
||||||
// 检查助手消息是否已存在
|
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
|
||||||
let assistantId = event.data.messageId;
|
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
|
||||||
let assistantElement = document.getElementById(assistantId);
|
|
||||||
|
|
||||||
// 如果助手消息不存在,创建它
|
|
||||||
if (!assistantElement) {
|
|
||||||
assistantId = addMessage('assistant', event.message, null, progressId);
|
|
||||||
setAssistantId(assistantId);
|
|
||||||
assistantElement = document.getElementById(assistantId);
|
|
||||||
} else {
|
|
||||||
// 如果已存在,更新内容
|
|
||||||
const bubble = assistantElement.querySelector('.message-bubble');
|
|
||||||
if (bubble) {
|
|
||||||
bubble.innerHTML = escapeHtml(event.message).replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将进度详情集成到工具调用区域(如果还没有)
|
|
||||||
if (assistantElement) {
|
if (assistantElement) {
|
||||||
const detailsId = 'process-details-' + assistantId;
|
const detailsId = 'process-details-' + assistantId;
|
||||||
if (!document.getElementById(detailsId)) {
|
if (!document.getElementById(detailsId)) {
|
||||||
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
|
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
|
||||||
}
|
}
|
||||||
// 立即折叠详情(错误时应该默认折叠)
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
collapseAllProgressDetails(assistantId, progressId);
|
collapseAllProgressDetails(assistantId, progressId);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 如果没有messageId(比如任务已运行时的错误),创建助手消息并集成详情
|
|
||||||
const assistantId = addMessage('assistant', event.message, null, progressId);
|
|
||||||
setAssistantId(assistantId);
|
|
||||||
|
|
||||||
// 将进度详情集成到工具调用区域
|
|
||||||
setTimeout(() => {
|
|
||||||
integrateProgressToMCPSection(progressId, assistantId, typeof getMcpIds === 'function' ? (getMcpIds() || []) : []);
|
|
||||||
// 确保详情默认折叠
|
|
||||||
collapseAllProgressDetails(assistantId, progressId);
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 立即刷新任务状态(执行失败时任务状态会更新)
|
// 立即刷新任务状态(执行失败时任务状态会更新)
|
||||||
|
|||||||
@@ -115,11 +115,15 @@ function updateRoleSelectorDisplay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
roleSelectorIcon.textContent = icon;
|
roleSelectorIcon.textContent = icon;
|
||||||
const displayName = (selectedRole.name === '默认' || !selectedRole.name) && typeof window.t === 'function'
|
const isDefaultRole = selectedRole.name === '默认' || !selectedRole.name;
|
||||||
|
const displayName = isDefaultRole && typeof window.t === 'function'
|
||||||
? window.t('chat.defaultRole') : (selectedRole.name || (typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认'));
|
? window.t('chat.defaultRole') : (selectedRole.name || (typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认'));
|
||||||
|
// 非默认角色时避免被 i18n 的 data-i18n 覆盖成“默认”
|
||||||
|
roleSelectorText.setAttribute('data-i18n-skip-text', isDefaultRole ? 'false' : 'true');
|
||||||
roleSelectorText.textContent = displayName;
|
roleSelectorText.textContent = displayName;
|
||||||
} else {
|
} else {
|
||||||
// 默认角色
|
// 默认角色
|
||||||
|
roleSelectorText.setAttribute('data-i18n-skip-text', 'false');
|
||||||
roleSelectorIcon.textContent = '🔵';
|
roleSelectorIcon.textContent = '🔵';
|
||||||
roleSelectorText.textContent = typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认';
|
roleSelectorText.textContent = typeof window.t === 'function' ? window.t('chat.defaultRole') : '默认';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ let currentEditingSkillName = null;
|
|||||||
let isSavingSkill = false; // 防止重复提交
|
let isSavingSkill = false; // 防止重复提交
|
||||||
let skillsSearchKeyword = '';
|
let skillsSearchKeyword = '';
|
||||||
let skillsSearchTimeout = null; // 搜索防抖定时器
|
let skillsSearchTimeout = null; // 搜索防抖定时器
|
||||||
|
let skillsAutoRefreshTimer = null;
|
||||||
|
let isAutoRefreshingSkills = false;
|
||||||
|
const SKILLS_AUTO_REFRESH_INTERVAL_MS = 5000;
|
||||||
let skillsPagination = {
|
let skillsPagination = {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pageSize: 20, // 每页20条(默认值,实际从localStorage读取)
|
pageSize: 20, // 每页20条(默认值,实际从localStorage读取)
|
||||||
@@ -21,6 +24,49 @@ let skillsStats = {
|
|||||||
stats: []
|
stats: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isSkillsManagementPageActive() {
|
||||||
|
const page = document.getElementById('page-skills-management');
|
||||||
|
return !!(page && page.classList.contains('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipSkillsAutoRefresh() {
|
||||||
|
if (isSavingSkill || currentEditingSkillName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('skill-modal');
|
||||||
|
if (modal && modal.style.display === 'flex') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('skills-search');
|
||||||
|
if (skillsSearchKeyword || (searchInput && searchInput.value.trim())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSkillsAutoRefresh() {
|
||||||
|
if (skillsAutoRefreshTimer) return;
|
||||||
|
|
||||||
|
skillsAutoRefreshTimer = setInterval(async () => {
|
||||||
|
if (!isSkillsManagementPageActive() || shouldSkipSkillsAutoRefresh()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isAutoRefreshingSkills) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAutoRefreshingSkills = true;
|
||||||
|
try {
|
||||||
|
await loadSkills(skillsPagination.currentPage, skillsPagination.pageSize);
|
||||||
|
} finally {
|
||||||
|
isAutoRefreshingSkills = false;
|
||||||
|
}
|
||||||
|
}, SKILLS_AUTO_REFRESH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
// 获取保存的每页显示数量
|
// 获取保存的每页显示数量
|
||||||
function getSkillsPageSize() {
|
function getSkillsPageSize() {
|
||||||
try {
|
try {
|
||||||
@@ -750,3 +796,7 @@ document.addEventListener('languagechange', function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
startSkillsAutoRefresh();
|
||||||
|
});
|
||||||
|
|||||||
+92
-27
@@ -127,11 +127,15 @@ function wsT(key) {
|
|||||||
'webshell.dbRows': '行',
|
'webshell.dbRows': '行',
|
||||||
'webshell.dbColumns': '列',
|
'webshell.dbColumns': '列',
|
||||||
'webshell.dbSchemaFailed': '加载数据库结构失败',
|
'webshell.dbSchemaFailed': '加载数据库结构失败',
|
||||||
|
'webshell.dbSchemaLoaded': '结构加载完成',
|
||||||
'webshell.dbAddProfile': '新增连接',
|
'webshell.dbAddProfile': '新增连接',
|
||||||
|
'webshell.dbExecSuccess': 'SQL 执行成功',
|
||||||
|
'webshell.dbNoOutput': '执行完成(无输出)',
|
||||||
'webshell.dbRenameProfile': '重命名',
|
'webshell.dbRenameProfile': '重命名',
|
||||||
'webshell.dbDeleteProfile': '删除连接',
|
'webshell.dbDeleteProfile': '删除连接',
|
||||||
'webshell.dbDeleteProfileConfirm': '确定删除该数据库连接配置吗?',
|
'webshell.dbDeleteProfileConfirm': '确定删除该数据库连接配置吗?',
|
||||||
'webshell.dbProfileNamePrompt': '请输入连接名称',
|
'webshell.dbProfileNamePrompt': '请输入连接名称',
|
||||||
|
'webshell.dbProfileName': '连接名称',
|
||||||
'webshell.dbProfiles': '数据库连接',
|
'webshell.dbProfiles': '数据库连接',
|
||||||
'webshell.aiSystemReadyMessage': '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。',
|
'webshell.aiSystemReadyMessage': '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。',
|
||||||
'webshell.aiPlaceholder': '例如:列出当前目录下的文件',
|
'webshell.aiPlaceholder': '例如:列出当前目录下的文件',
|
||||||
@@ -864,14 +868,17 @@ function webshellDbGetFieldValue(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function webshellDbCollectConfig(conn) {
|
function webshellDbCollectConfig(conn) {
|
||||||
|
var curr = getWebshellDbConfig(conn) || {};
|
||||||
|
var nameVal = webshellDbGetFieldValue('webshell-db-profile-name');
|
||||||
var cfg = {
|
var cfg = {
|
||||||
|
name: nameVal || curr.name || 'DB-1',
|
||||||
type: webshellDbGetFieldValue('webshell-db-type') || 'mysql',
|
type: webshellDbGetFieldValue('webshell-db-type') || 'mysql',
|
||||||
host: webshellDbGetFieldValue('webshell-db-host') || '127.0.0.1',
|
host: webshellDbGetFieldValue('webshell-db-host') || '127.0.0.1',
|
||||||
port: webshellDbGetFieldValue('webshell-db-port') || '',
|
port: webshellDbGetFieldValue('webshell-db-port') || '',
|
||||||
username: webshellDbGetFieldValue('webshell-db-user') || '',
|
username: webshellDbGetFieldValue('webshell-db-user') || '',
|
||||||
password: (document.getElementById('webshell-db-pass') || {}).value || '',
|
password: (document.getElementById('webshell-db-pass') || {}).value || '',
|
||||||
database: webshellDbGetFieldValue('webshell-db-name') || '',
|
database: webshellDbGetFieldValue('webshell-db-name') || '',
|
||||||
selectedDatabase: getWebshellDbConfig(conn).selectedDatabase || '',
|
selectedDatabase: curr.selectedDatabase || '',
|
||||||
sqlitePath: webshellDbGetFieldValue('webshell-db-sqlite-path') || '/tmp/test.db',
|
sqlitePath: webshellDbGetFieldValue('webshell-db-sqlite-path') || '/tmp/test.db',
|
||||||
sql: (document.getElementById('webshell-db-sql') || {}).value || ''
|
sql: (document.getElementById('webshell-db-sql') || {}).value || ''
|
||||||
};
|
};
|
||||||
@@ -1574,7 +1581,19 @@ function selectWebshell(id, stateReady) {
|
|||||||
'<div class="webshell-db-sidebar-hint">' + (wsT('webshell.dbSelectTableHint') || '点击表名可生成查询 SQL') + '</div>' +
|
'<div class="webshell-db-sidebar-hint">' + (wsT('webshell.dbSelectTableHint') || '点击表名可生成查询 SQL') + '</div>' +
|
||||||
'</aside>' +
|
'</aside>' +
|
||||||
'<section class="webshell-db-main">' +
|
'<section class="webshell-db-main">' +
|
||||||
|
'<div class="webshell-db-sql-tools"><button type="button" class="btn-ghost btn-sm" id="webshell-db-template-btn">' + (wsT('webshell.dbTemplateSql') || '示例 SQL') + '</button><button type="button" class="btn-ghost btn-sm" id="webshell-db-clear-btn">' + (wsT('webshell.dbClearSql') || '清空 SQL') + '</button></div>' +
|
||||||
|
'<textarea id="webshell-db-sql" class="webshell-db-sql form-control" rows="8" placeholder="' + (wsT('webshell.dbSqlPlaceholder') || '输入 SQL,例如:SELECT version();') + '"></textarea>' +
|
||||||
|
'<div class="webshell-db-actions">' +
|
||||||
|
'<button type="button" class="btn-ghost" id="webshell-db-test-btn">' + (wsT('webshell.dbTest') || '测试连接') + '</button>' +
|
||||||
|
'<button type="button" class="btn-primary" id="webshell-db-run-btn">' + (wsT('webshell.dbRunSql') || '执行 SQL') + '</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="webshell-db-output-wrap"><div class="webshell-db-output-title">' + (wsT('webshell.dbOutput') || '执行输出') + '</div><div id="webshell-db-result-table" class="webshell-db-result-table"></div><pre id="webshell-db-output" class="webshell-db-output"></pre><div class="webshell-db-hint">' + (wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd)') + '</div></div>' +
|
||||||
|
'<div id="webshell-db-profile-modal" class="modal">' +
|
||||||
|
'<div class="modal-content webshell-db-profile-modal-content">' +
|
||||||
|
'<div class="modal-header"><h2 id="webshell-db-profile-modal-title">' + (wsT('webshell.editConnectionTitle') || '编辑连接') + '</h2><span class="modal-close" id="webshell-db-profile-modal-close">×</span></div>' +
|
||||||
|
'<div class="modal-body">' +
|
||||||
'<div class="webshell-db-toolbar">' +
|
'<div class="webshell-db-toolbar">' +
|
||||||
|
'<label><span>' + (wsT('webshell.dbProfileName') || '连接名称') + '</span><input id="webshell-db-profile-name" class="form-control" type="text" maxlength="30" /></label>' +
|
||||||
'<label><span>' + (wsT('webshell.dbType') || '数据库类型') + '</span><select id="webshell-db-type" class="form-control"><option value="mysql">MySQL</option><option value="pgsql">PostgreSQL</option><option value="sqlite">SQLite</option><option value="mssql">SQL Server</option></select></label>' +
|
'<label><span>' + (wsT('webshell.dbType') || '数据库类型') + '</span><select id="webshell-db-type" class="form-control"><option value="mysql">MySQL</option><option value="pgsql">PostgreSQL</option><option value="sqlite">SQLite</option><option value="mssql">SQL Server</option></select></label>' +
|
||||||
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbHost') || '主机') + '</span><input id="webshell-db-host" class="form-control" type="text" value="127.0.0.1" /></label>' +
|
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbHost') || '主机') + '</span><input id="webshell-db-host" class="form-control" type="text" value="127.0.0.1" /></label>' +
|
||||||
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbPort') || '端口') + '</span><input id="webshell-db-port" class="form-control" type="text" /></label>' +
|
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbPort') || '端口') + '</span><input id="webshell-db-port" class="form-control" type="text" /></label>' +
|
||||||
@@ -1583,13 +1602,10 @@ function selectWebshell(id, stateReady) {
|
|||||||
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbName') || '数据库名') + '</span><input id="webshell-db-name" class="form-control" type="text" /></label>' +
|
'<label class="webshell-db-common-field"><span>' + (wsT('webshell.dbName') || '数据库名') + '</span><input id="webshell-db-name" class="form-control" type="text" /></label>' +
|
||||||
'<label id="webshell-db-sqlite-row"><span>' + (wsT('webshell.dbSqlitePath') || 'SQLite 文件路径') + '</span><input id="webshell-db-sqlite-path" class="form-control" type="text" value="/tmp/test.db" /></label>' +
|
'<label id="webshell-db-sqlite-row"><span>' + (wsT('webshell.dbSqlitePath') || 'SQLite 文件路径') + '</span><input id="webshell-db-sqlite-path" class="form-control" type="text" value="/tmp/test.db" /></label>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="webshell-db-sql-tools"><button type="button" class="btn-ghost btn-sm" id="webshell-db-template-btn">' + (wsT('webshell.dbTemplateSql') || '示例 SQL') + '</button><button type="button" class="btn-ghost btn-sm" id="webshell-db-clear-btn">' + (wsT('webshell.dbClearSql') || '清空 SQL') + '</button></div>' +
|
|
||||||
'<textarea id="webshell-db-sql" class="webshell-db-sql form-control" rows="8" placeholder="' + (wsT('webshell.dbSqlPlaceholder') || '输入 SQL,例如:SELECT version();') + '"></textarea>' +
|
|
||||||
'<div class="webshell-db-actions">' +
|
|
||||||
'<button type="button" class="btn-ghost" id="webshell-db-test-btn">' + (wsT('webshell.dbTest') || '测试连接') + '</button>' +
|
|
||||||
'<button type="button" class="btn-primary" id="webshell-db-run-btn">' + (wsT('webshell.dbRunSql') || '执行 SQL') + '</button>' +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="webshell-db-output-wrap"><div class="webshell-db-output-title">' + (wsT('webshell.dbOutput') || '执行输出') + '</div><div id="webshell-db-result-table" class="webshell-db-result-table"></div><pre id="webshell-db-output" class="webshell-db-output"></pre><div class="webshell-db-hint">' + (wsT('webshell.dbCliHint') || '如果提示命令不存在,请先在目标主机安装对应客户端(mysql/psql/sqlite3/sqlcmd)') + '</div></div>' +
|
'<div class="modal-footer"><button type="button" class="btn-secondary" id="webshell-db-profile-cancel-btn">取消</button><button type="button" class="btn-primary" id="webshell-db-profile-save-btn">保存</button></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
'</section>' +
|
'</section>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -1770,6 +1786,12 @@ function selectWebshell(id, stateReady) {
|
|||||||
var dbSchemaTreeEl = document.getElementById('webshell-db-schema-tree');
|
var dbSchemaTreeEl = document.getElementById('webshell-db-schema-tree');
|
||||||
var dbProfilesEl = document.getElementById('webshell-db-profiles');
|
var dbProfilesEl = document.getElementById('webshell-db-profiles');
|
||||||
var dbAddProfileBtn = document.getElementById('webshell-db-add-profile-btn');
|
var dbAddProfileBtn = document.getElementById('webshell-db-add-profile-btn');
|
||||||
|
var dbProfileModalEl = document.getElementById('webshell-db-profile-modal');
|
||||||
|
var dbProfileModalTitleEl = document.getElementById('webshell-db-profile-modal-title');
|
||||||
|
var dbProfileModalCloseBtn = document.getElementById('webshell-db-profile-modal-close');
|
||||||
|
var dbProfileModalCancelBtn = document.getElementById('webshell-db-profile-cancel-btn');
|
||||||
|
var dbProfileModalSaveBtn = document.getElementById('webshell-db-profile-save-btn');
|
||||||
|
var dbProfileNameEl = document.getElementById('webshell-db-profile-name');
|
||||||
var dbHostEl = document.getElementById('webshell-db-host');
|
var dbHostEl = document.getElementById('webshell-db-host');
|
||||||
var dbPortEl = document.getElementById('webshell-db-port');
|
var dbPortEl = document.getElementById('webshell-db-port');
|
||||||
var dbUserEl = document.getElementById('webshell-db-user');
|
var dbUserEl = document.getElementById('webshell-db-user');
|
||||||
@@ -1793,9 +1815,19 @@ function selectWebshell(id, stateReady) {
|
|||||||
if (dbAddProfileBtn) dbAddProfileBtn.disabled = disabled;
|
if (dbAddProfileBtn) dbAddProfileBtn.disabled = disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDbProfileModalVisible(visible, mode) {
|
||||||
|
if (!dbProfileModalEl) return;
|
||||||
|
dbProfileModalEl.style.display = visible ? 'block' : 'none';
|
||||||
|
if (dbProfileModalTitleEl) {
|
||||||
|
if (mode === 'add') dbProfileModalTitleEl.textContent = wsT('webshell.dbAddProfile') || '新增连接';
|
||||||
|
else dbProfileModalTitleEl.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyActiveDbProfileToForm() {
|
function applyActiveDbProfileToForm() {
|
||||||
var dbCfg = getWebshellDbConfig(conn);
|
var dbCfg = getWebshellDbConfig(conn);
|
||||||
if (!dbCfg) return;
|
if (!dbCfg) return;
|
||||||
|
if (dbProfileNameEl) dbProfileNameEl.value = dbCfg.name || 'DB-1';
|
||||||
if (dbTypeEl) dbTypeEl.value = dbCfg.type || 'mysql';
|
if (dbTypeEl) dbTypeEl.value = dbCfg.type || 'mysql';
|
||||||
if (dbHostEl) dbHostEl.value = dbCfg.host || '127.0.0.1';
|
if (dbHostEl) dbHostEl.value = dbCfg.host || '127.0.0.1';
|
||||||
if (dbPortEl) dbPortEl.value = dbCfg.port || '';
|
if (dbPortEl) dbPortEl.value = dbCfg.port || '';
|
||||||
@@ -1817,7 +1849,7 @@ function selectWebshell(id, stateReady) {
|
|||||||
var active = p.id === state.activeProfileId;
|
var active = p.id === state.activeProfileId;
|
||||||
html += '<div class="webshell-db-profile-tab' + (active ? ' active' : '') + '" data-id="' + escapeHtml(p.id) + '">' +
|
html += '<div class="webshell-db-profile-tab' + (active ? ' active' : '') + '" data-id="' + escapeHtml(p.id) + '">' +
|
||||||
'<button type="button" class="webshell-db-profile-main" data-action="switch" data-id="' + escapeHtml(p.id) + '">' + escapeHtml(p.name || 'DB') + '</button>' +
|
'<button type="button" class="webshell-db-profile-main" data-action="switch" data-id="' + escapeHtml(p.id) + '">' + escapeHtml(p.name || 'DB') + '</button>' +
|
||||||
'<button type="button" class="webshell-db-profile-menu" data-action="rename" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.dbRenameProfile') || '重命名') + '">✎</button>' +
|
'<button type="button" class="webshell-db-profile-menu" data-action="edit" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.editConnection') || '编辑') + '">⚙</button>' +
|
||||||
'<button type="button" class="webshell-db-profile-menu" data-action="delete" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.dbDeleteProfile') || '删除连接') + '">×</button>' +
|
'<button type="button" class="webshell-db-profile-menu" data-action="delete" data-id="' + escapeHtml(p.id) + '" title="' + escapeHtml(wsT('webshell.dbDeleteProfile') || '删除连接') + '">×</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
});
|
});
|
||||||
@@ -1839,15 +1871,12 @@ function selectWebshell(id, stateReady) {
|
|||||||
renderDbSchemaTree();
|
renderDbSchemaTree();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'rename') {
|
if (action === 'edit') {
|
||||||
var curr = state.profiles[idx].name || '';
|
state.activeProfileId = id;
|
||||||
var next = prompt(wsT('webshell.dbProfileNamePrompt') || '请输入连接名称', curr);
|
|
||||||
if (next == null) return;
|
|
||||||
next = String(next || '').trim();
|
|
||||||
if (!next) return;
|
|
||||||
state.profiles[idx].name = next.slice(0, 30);
|
|
||||||
saveWebshellDbState(conn, state);
|
saveWebshellDbState(conn, state);
|
||||||
|
applyActiveDbProfileToForm();
|
||||||
renderDbProfileTabs();
|
renderDbProfileTabs();
|
||||||
|
setDbProfileModalVisible(true, 'edit');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
@@ -2090,11 +2119,11 @@ function selectWebshell(id, stateReady) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cfg.schema = parseWebshellDbSchema(parsed.output);
|
cfg.schema = parseWebshellDbSchema(parsed.output);
|
||||||
cfg.output = '结构加载完成';
|
cfg.output = wsT('webshell.dbSchemaLoaded') || '结构加载完成';
|
||||||
cfg.outputIsError = false;
|
cfg.outputIsError = false;
|
||||||
saveWebshellDbConfig(conn, cfg);
|
saveWebshellDbConfig(conn, cfg);
|
||||||
renderDbSchemaTree();
|
renderDbSchemaTree();
|
||||||
webshellDbSetOutput('结构加载完成', false);
|
webshellDbSetOutput(wsT('webshell.dbSchemaLoaded') || '结构加载完成', false);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
webshellDbSetOutput((wsT('webshell.dbSchemaFailed') || '加载数据库结构失败') + ': ' + (err && err.message ? err.message : String(err)), true);
|
webshellDbSetOutput((wsT('webshell.dbSchemaFailed') || '加载数据库结构失败') + ': ' + (err && err.message ? err.message : String(err)), true);
|
||||||
}).finally(function () {
|
}).finally(function () {
|
||||||
@@ -2149,12 +2178,12 @@ function selectWebshell(id, stateReady) {
|
|||||||
}
|
}
|
||||||
var hasTable = webshellDbRenderTable(content);
|
var hasTable = webshellDbRenderTable(content);
|
||||||
if (hasTable) {
|
if (hasTable) {
|
||||||
cfg.output = 'SQL 执行成功';
|
cfg.output = wsT('webshell.dbExecSuccess') || 'SQL 执行成功';
|
||||||
cfg.outputIsError = false;
|
cfg.outputIsError = false;
|
||||||
saveWebshellDbConfig(conn, cfg);
|
saveWebshellDbConfig(conn, cfg);
|
||||||
webshellDbSetOutput(cfg.output, false);
|
webshellDbSetOutput(cfg.output, false);
|
||||||
} else {
|
} else {
|
||||||
cfg.output = content || '执行完成(无输出)';
|
cfg.output = content || (wsT('webshell.dbNoOutput') || '执行完成(无输出)');
|
||||||
cfg.outputIsError = false;
|
cfg.outputIsError = false;
|
||||||
saveWebshellDbConfig(conn, cfg);
|
saveWebshellDbConfig(conn, cfg);
|
||||||
webshellDbSetOutput(cfg.output, false);
|
webshellDbSetOutput(cfg.output, false);
|
||||||
@@ -2179,11 +2208,12 @@ function selectWebshell(id, stateReady) {
|
|||||||
resetDbColumnLoadCache();
|
resetDbColumnLoadCache();
|
||||||
renderDbSchemaTree();
|
renderDbSchemaTree();
|
||||||
});
|
});
|
||||||
['webshell-db-host', 'webshell-db-port', 'webshell-db-user', 'webshell-db-pass', 'webshell-db-name', 'webshell-db-sqlite-path'].forEach(function (id) {
|
['webshell-db-profile-name', 'webshell-db-host', 'webshell-db-port', 'webshell-db-user', 'webshell-db-pass', 'webshell-db-name', 'webshell-db-sqlite-path'].forEach(function (id) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
if (el) el.addEventListener('change', function () {
|
if (el) el.addEventListener('change', function () {
|
||||||
webshellDbCollectConfig(conn);
|
webshellDbCollectConfig(conn);
|
||||||
resetDbColumnLoadCache();
|
resetDbColumnLoadCache();
|
||||||
|
renderDbProfileTabs();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if (dbSqlEl) dbSqlEl.addEventListener('change', function () { webshellDbCollectConfig(conn); });
|
if (dbSqlEl) dbSqlEl.addEventListener('change', function () { webshellDbCollectConfig(conn); });
|
||||||
@@ -2203,6 +2233,25 @@ function selectWebshell(id, stateReady) {
|
|||||||
if (dbSqlEl) dbSqlEl.value = '';
|
if (dbSqlEl) dbSqlEl.value = '';
|
||||||
webshellDbCollectConfig(conn);
|
webshellDbCollectConfig(conn);
|
||||||
});
|
});
|
||||||
|
if (dbProfileModalCloseBtn) dbProfileModalCloseBtn.addEventListener('click', function () {
|
||||||
|
setDbProfileModalVisible(false);
|
||||||
|
});
|
||||||
|
if (dbProfileModalCancelBtn) dbProfileModalCancelBtn.addEventListener('click', function () {
|
||||||
|
applyActiveDbProfileToForm();
|
||||||
|
setDbProfileModalVisible(false);
|
||||||
|
});
|
||||||
|
if (dbProfileModalSaveBtn) dbProfileModalSaveBtn.addEventListener('click', function () {
|
||||||
|
webshellDbCollectConfig(conn);
|
||||||
|
renderDbProfileTabs();
|
||||||
|
resetDbColumnLoadCache();
|
||||||
|
setDbProfileModalVisible(false);
|
||||||
|
});
|
||||||
|
if (dbProfileModalEl) dbProfileModalEl.addEventListener('click', function (evt) {
|
||||||
|
if (evt.target === dbProfileModalEl) {
|
||||||
|
applyActiveDbProfileToForm();
|
||||||
|
setDbProfileModalVisible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
if (dbAddProfileBtn) dbAddProfileBtn.addEventListener('click', function () {
|
if (dbAddProfileBtn) dbAddProfileBtn.addEventListener('click', function () {
|
||||||
var state = getWebshellDbState(conn);
|
var state = getWebshellDbState(conn);
|
||||||
var name = 'DB-' + (state.profiles.length + 1);
|
var name = 'DB-' + (state.profiles.length + 1);
|
||||||
@@ -2213,10 +2262,12 @@ function selectWebshell(id, stateReady) {
|
|||||||
applyActiveDbProfileToForm();
|
applyActiveDbProfileToForm();
|
||||||
renderDbProfileTabs();
|
renderDbProfileTabs();
|
||||||
renderDbSchemaTree();
|
renderDbSchemaTree();
|
||||||
|
setDbProfileModalVisible(true, 'add');
|
||||||
});
|
});
|
||||||
renderDbProfileTabs();
|
renderDbProfileTabs();
|
||||||
applyActiveDbProfileToForm();
|
applyActiveDbProfileToForm();
|
||||||
renderDbSchemaTree();
|
renderDbSchemaTree();
|
||||||
|
setDbProfileModalVisible(false);
|
||||||
|
|
||||||
initWebshellTerminal(conn);
|
initWebshellTerminal(conn);
|
||||||
}
|
}
|
||||||
@@ -2977,6 +3028,9 @@ function parseWebshellListItems(rawOutput) {
|
|||||||
var items = [];
|
var items = [];
|
||||||
for (var i = 0; i < lines.length; i++) {
|
for (var i = 0; i < lines.length; i++) {
|
||||||
var line = lines[i];
|
var line = lines[i];
|
||||||
|
var trimmedLine = String(line || '').trim();
|
||||||
|
// `ls -la` 首行常见 "total 12"(中文环境为 "总计 12"),不是文件项。
|
||||||
|
if (/^(total|总计)\s+\d+$/i.test(trimmedLine)) continue;
|
||||||
var name = '';
|
var name = '';
|
||||||
var isDir = false;
|
var isDir = false;
|
||||||
var size = '';
|
var size = '';
|
||||||
@@ -3059,14 +3113,14 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
|
|||||||
if (rawOutput.trim() && !nameFilter) {
|
if (rawOutput.trim() && !nameFilter) {
|
||||||
html = '<pre class="webshell-file-raw">' + escapeHtml(rawOutput) + '</pre>';
|
html = '<pre class="webshell-file-raw">' + escapeHtml(rawOutput) + '</pre>';
|
||||||
} else {
|
} else {
|
||||||
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-group">' + (wsT('webshell.colGroup') || '用户组') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>' +
|
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>' +
|
||||||
'<tr><td colspan="8" class="webshell-file-empty-state">' + (wsT('common.noData') || '暂无文件') + '</td></tr>' +
|
'<tr><td colspan="7" class="webshell-file-empty-state">' + (wsT('common.noData') || '暂无文件') + '</td></tr>' +
|
||||||
'</tbody></table>';
|
'</tbody></table>';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-group">' + (wsT('webshell.colGroup') || '用户组') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>';
|
html = '<table class="webshell-file-table"><thead><tr><th class="webshell-col-check"><input type="checkbox" id="webshell-file-select-all" title="' + (wsT('webshell.selectAll') || '全选') + '" /></th><th>' + wsT('webshell.filePath') + '</th><th class="webshell-col-size">大小</th><th class="webshell-col-mtime">' + (wsT('webshell.colModifiedAt') || '修改时间') + '</th><th class="webshell-col-owner">' + (wsT('webshell.colOwner') || '所有者') + '</th><th class="webshell-col-perms">' + (wsT('webshell.colPerms') || '权限') + '</th><th class="webshell-col-actions"></th></tr></thead><tbody>';
|
||||||
if (currentPath !== '.' && currentPath !== '') {
|
if (currentPath !== '.' && currentPath !== '') {
|
||||||
html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(currentPath.replace(/\/[^/]+$/, '') || '.') + '" data-isdir="1">..</a></td><td></td><td></td><td></td><td></td><td></td><td></td></tr>';
|
html += '<tr><td></td><td><a href="#" class="webshell-file-link" data-path="' + escapeHtml(currentPath.replace(/\/[^/]+$/, '') || '.') + '" data-isdir="1">..</a></td><td></td><td></td><td></td><td></td><td></td></tr>';
|
||||||
}
|
}
|
||||||
items.forEach(function (item) {
|
items.forEach(function (item) {
|
||||||
var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name;
|
var pathNext = currentPath === '.' ? item.name : currentPath + '/' + item.name;
|
||||||
@@ -3077,7 +3131,6 @@ function renderFileList(listEl, currentPath, rawOutput, conn, nameFilter) {
|
|||||||
html += '<td class="webshell-col-size">' + escapeHtml(item.size) + '</td>';
|
html += '<td class="webshell-col-size">' + escapeHtml(item.size) + '</td>';
|
||||||
html += '<td class="webshell-col-mtime">' + escapeHtml(item.mtime || '') + '</td>';
|
html += '<td class="webshell-col-mtime">' + escapeHtml(item.mtime || '') + '</td>';
|
||||||
html += '<td class="webshell-col-owner">' + escapeHtml(item.owner || '') + '</td>';
|
html += '<td class="webshell-col-owner">' + escapeHtml(item.owner || '') + '</td>';
|
||||||
html += '<td class="webshell-col-group">' + escapeHtml(item.group || '') + '</td>';
|
|
||||||
html += '<td class="webshell-col-perms">' + escapeHtml(item.mode || '') + '</td>';
|
html += '<td class="webshell-col-perms">' + escapeHtml(item.mode || '') + '</td>';
|
||||||
html += '<td class="webshell-col-actions">';
|
html += '<td class="webshell-col-actions">';
|
||||||
if (item.isDir) {
|
if (item.isDir) {
|
||||||
@@ -3657,9 +3710,13 @@ function refreshWebshellUIOnLanguageChange() {
|
|||||||
setWebshellTerminalStatus(webshellTerminalRunning);
|
setWebshellTerminalStatus(webshellTerminalRunning);
|
||||||
if (webshellCurrentConn) renderWebshellTerminalSessions(webshellCurrentConn);
|
if (webshellCurrentConn) renderWebshellTerminalSessions(webshellCurrentConn);
|
||||||
var pathLabel = workspace.querySelector('.webshell-file-toolbar label span');
|
var pathLabel = workspace.querySelector('.webshell-file-toolbar label span');
|
||||||
|
var fileSidebarTitle = workspace.querySelector('.webshell-file-sidebar-title');
|
||||||
|
var fileMoreActionsBtn = workspace.querySelector('.webshell-toolbar-actions-btn');
|
||||||
var listDirBtn = document.getElementById('webshell-list-dir');
|
var listDirBtn = document.getElementById('webshell-list-dir');
|
||||||
var parentDirBtn = document.getElementById('webshell-parent-dir');
|
var parentDirBtn = document.getElementById('webshell-parent-dir');
|
||||||
if (pathLabel) pathLabel.textContent = wsT('webshell.filePath');
|
if (pathLabel) pathLabel.textContent = wsT('webshell.filePath');
|
||||||
|
if (fileSidebarTitle) fileSidebarTitle.textContent = wsT('webshell.dirTree') || '目录列表';
|
||||||
|
if (fileMoreActionsBtn) fileMoreActionsBtn.textContent = wsT('webshell.moreActions') || '更多操作';
|
||||||
if (listDirBtn) listDirBtn.textContent = wsT('webshell.listDir');
|
if (listDirBtn) listDirBtn.textContent = wsT('webshell.listDir');
|
||||||
if (parentDirBtn) parentDirBtn.textContent = wsT('webshell.parentDir');
|
if (parentDirBtn) parentDirBtn.textContent = wsT('webshell.parentDir');
|
||||||
// 文件管理工具栏按钮(红框区域):切换语言时立即更新
|
// 文件管理工具栏按钮(红框区域):切换语言时立即更新
|
||||||
@@ -3699,6 +3756,8 @@ function refreshWebshellUIOnLanguageChange() {
|
|||||||
}
|
}
|
||||||
var dbTypeLabel = document.querySelector('#webshell-db-type') ? document.querySelector('#webshell-db-type').closest('label') : null;
|
var dbTypeLabel = document.querySelector('#webshell-db-type') ? document.querySelector('#webshell-db-type').closest('label') : null;
|
||||||
if (dbTypeLabel && dbTypeLabel.querySelector('span')) dbTypeLabel.querySelector('span').textContent = wsT('webshell.dbType') || '数据库类型';
|
if (dbTypeLabel && dbTypeLabel.querySelector('span')) dbTypeLabel.querySelector('span').textContent = wsT('webshell.dbType') || '数据库类型';
|
||||||
|
var dbProfileNameLabel = document.querySelector('#webshell-db-profile-name') ? document.querySelector('#webshell-db-profile-name').closest('label') : null;
|
||||||
|
if (dbProfileNameLabel && dbProfileNameLabel.querySelector('span')) dbProfileNameLabel.querySelector('span').textContent = wsT('webshell.dbProfileName') || '连接名称';
|
||||||
var dbHostLabel = document.querySelector('#webshell-db-host') ? document.querySelector('#webshell-db-host').closest('label') : null;
|
var dbHostLabel = document.querySelector('#webshell-db-host') ? document.querySelector('#webshell-db-host').closest('label') : null;
|
||||||
if (dbHostLabel && dbHostLabel.querySelector('span')) dbHostLabel.querySelector('span').textContent = wsT('webshell.dbHost') || '主机';
|
if (dbHostLabel && dbHostLabel.querySelector('span')) dbHostLabel.querySelector('span').textContent = wsT('webshell.dbHost') || '主机';
|
||||||
var dbPortLabel = document.querySelector('#webshell-db-port') ? document.querySelector('#webshell-db-port').closest('label') : null;
|
var dbPortLabel = document.querySelector('#webshell-db-port') ? document.querySelector('#webshell-db-port').closest('label') : null;
|
||||||
@@ -3733,8 +3792,14 @@ function refreshWebshellUIOnLanguageChange() {
|
|||||||
if (dbTreeHint) dbTreeHint.textContent = wsT('webshell.dbSelectTableHint') || '点击表名可生成查询 SQL';
|
if (dbTreeHint) dbTreeHint.textContent = wsT('webshell.dbSelectTableHint') || '点击表名可生成查询 SQL';
|
||||||
var dbAddProfileBtn = document.getElementById('webshell-db-add-profile-btn');
|
var dbAddProfileBtn = document.getElementById('webshell-db-add-profile-btn');
|
||||||
if (dbAddProfileBtn) dbAddProfileBtn.textContent = '+ ' + (wsT('webshell.dbAddProfile') || '新增连接');
|
if (dbAddProfileBtn) dbAddProfileBtn.textContent = '+ ' + (wsT('webshell.dbAddProfile') || '新增连接');
|
||||||
document.querySelectorAll('.webshell-db-profile-menu[data-action="rename"]').forEach(function (el) {
|
var dbProfileModalTitle = document.getElementById('webshell-db-profile-modal-title');
|
||||||
el.title = wsT('webshell.dbRenameProfile') || '重命名';
|
if (dbProfileModalTitle) dbProfileModalTitle.textContent = wsT('webshell.editConnectionTitle') || '编辑连接';
|
||||||
|
var dbProfileCancelBtn = document.getElementById('webshell-db-profile-cancel-btn');
|
||||||
|
if (dbProfileCancelBtn) dbProfileCancelBtn.textContent = '取消';
|
||||||
|
var dbProfileSaveBtn = document.getElementById('webshell-db-profile-save-btn');
|
||||||
|
if (dbProfileSaveBtn) dbProfileSaveBtn.textContent = '保存';
|
||||||
|
document.querySelectorAll('.webshell-db-profile-menu[data-action="edit"]').forEach(function (el) {
|
||||||
|
el.title = wsT('webshell.editConnection') || '编辑';
|
||||||
});
|
});
|
||||||
document.querySelectorAll('.webshell-db-profile-menu[data-action="delete"]').forEach(function (el) {
|
document.querySelectorAll('.webshell-db-profile-menu[data-action="delete"]').forEach(function (el) {
|
||||||
el.title = wsT('webshell.dbDeleteProfile') || '删除连接';
|
el.title = wsT('webshell.dbDeleteProfile') || '删除连接';
|
||||||
|
|||||||
@@ -1399,32 +1399,32 @@
|
|||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" id="multi-agent-enabled" class="modern-checkbox" />
|
<input type="checkbox" id="multi-agent-enabled" class="modern-checkbox" />
|
||||||
<span class="checkbox-custom"></span>
|
<span class="checkbox-custom"></span>
|
||||||
<span class="checkbox-text">启用 Eino 多代理(DeepAgent)</span>
|
<span class="checkbox-text" data-i18n="settingsBasic.enableMultiAgent">启用 Eino 多代理(DeepAgent)</span>
|
||||||
</label>
|
</label>
|
||||||
<small class="form-hint">开启后对话页可选「多代理」模式;子代理在 config.yaml 的 multi_agent.sub_agents 中配置。</small>
|
<small class="form-hint" data-i18n="settingsBasic.enableMultiAgentHint">开启后对话页可选「多代理」模式;子代理在 config.yaml 的 multi_agent.sub_agents 中配置。</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="multi-agent-default-mode">对话页默认模式</label>
|
<label for="multi-agent-default-mode" data-i18n="settingsBasic.multiAgentDefaultMode">对话页默认模式</label>
|
||||||
<select id="multi-agent-default-mode">
|
<select id="multi-agent-default-mode">
|
||||||
<option value="single">单代理(ReAct)</option>
|
<option value="single" data-i18n="settingsBasic.multiAgentModeSingle">单代理(ReAct)</option>
|
||||||
<option value="multi">多代理(Eino)</option>
|
<option value="multi" data-i18n="settingsBasic.multiAgentModeMulti">多代理(Eino)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" id="multi-agent-robot-use" class="modern-checkbox" />
|
<input type="checkbox" id="multi-agent-robot-use" class="modern-checkbox" />
|
||||||
<span class="checkbox-custom"></span>
|
<span class="checkbox-custom"></span>
|
||||||
<span class="checkbox-text">企业微信 / 钉钉 / 飞书机器人也使用多代理</span>
|
<span class="checkbox-text" data-i18n="settingsBasic.multiAgentRobotUse">企业微信 / 钉钉 / 飞书机器人也使用多代理</span>
|
||||||
</label>
|
</label>
|
||||||
<small class="form-hint">需同时勾选「启用多代理」;调用量与成本更高。</small>
|
<small class="form-hint" data-i18n="settingsBasic.multiAgentRobotUseHint">需同时勾选「启用多代理」;调用量与成本更高。</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" id="multi-agent-batch-use" class="modern-checkbox" />
|
<input type="checkbox" id="multi-agent-batch-use" class="modern-checkbox" />
|
||||||
<span class="checkbox-custom"></span>
|
<span class="checkbox-custom"></span>
|
||||||
<span class="checkbox-text">批量任务队列也使用多代理</span>
|
<span class="checkbox-text" data-i18n="settingsBasic.multiAgentBatchUse">批量任务队列也使用多代理</span>
|
||||||
</label>
|
</label>
|
||||||
<small class="form-hint">开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。</small>
|
<small class="form-hint" data-i18n="settingsBasic.multiAgentBatchUseHint">开启后,任务管理中按队列执行的每个子任务将走 Eino DeepAgent(需启用多代理)。</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2205,6 +2205,24 @@ version: 1.0.0<br>
|
|||||||
</svg>
|
</svg>
|
||||||
<span data-i18n="contextMenu.viewAttackChain">查看攻击链</span>
|
<span data-i18n="contextMenu.viewAttackChain">查看攻击链</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item context-menu-item-has-submenu" onmouseenter="handleDownloadMarkdownSubmenuEnter()" onmouseleave="handleDownloadMarkdownSubmenuLeave(event)">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 3v12m0 0l-4-4m4 4l4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span data-i18n="contextMenu.downloadMarkdown">下载 Markdown</span>
|
||||||
|
<svg class="submenu-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<div id="download-markdown-submenu" class="context-submenu" style="display: none;" onmouseenter="clearDownloadMarkdownSubmenuHideTimeout()" onmouseleave="hideDownloadMarkdownSubmenu()">
|
||||||
|
<div class="context-submenu-item" onclick="downloadConversationMarkdownFromContext(false)">
|
||||||
|
<span data-i18n="contextMenu.downloadMarkdownSummary">简版</span>
|
||||||
|
</div>
|
||||||
|
<div class="context-submenu-item" onclick="downloadConversationMarkdownFromContext(true)">
|
||||||
|
<span data-i18n="contextMenu.downloadMarkdownFull">完整版</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="context-menu-divider"></div>
|
<div class="context-menu-divider"></div>
|
||||||
<div class="context-menu-item" onclick="renameConversation()">
|
<div class="context-menu-item" onclick="renameConversation()">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
Reference in New Issue
Block a user