Compare commits

...

115 Commits

Author SHA1 Message Date
公明 70bf5c93bf Update config.yaml 2026-05-19 19:01:31 +08:00
公明 08bd278d8c Update config.yaml 2026-05-19 18:56:24 +08:00
公明 22746d64a3 Add files via upload 2026-05-19 18:53:46 +08:00
公明 199392a5d5 Add files via upload 2026-05-19 18:52:22 +08:00
公明 aafb4cb584 Add files via upload 2026-05-19 18:50:28 +08:00
公明 96e3dd397c Add files via upload 2026-05-19 18:48:17 +08:00
公明 ec0f17145b Add files via upload 2026-05-19 17:50:38 +08:00
公明 ed53da0999 Delete security directory 2026-05-19 17:49:21 +08:00
公明 dc440fc511 Delete robot directory 2026-05-19 17:49:10 +08:00
公明 009ae59033 Delete logger directory 2026-05-19 17:48:59 +08:00
公明 f348b3245a Delete knowledge directory 2026-05-19 17:48:44 +08:00
公明 0018c5219c Delete config directory 2026-05-19 17:48:33 +08:00
公明 01a3e3677a Delete c2 directory 2026-05-19 17:48:22 +08:00
公明 a12ecdb46f Add files via upload 2026-05-19 17:47:56 +08:00
公明 9f59230d74 Add files via upload 2026-05-19 17:46:33 +08:00
公明 085c6a1c72 Add files via upload 2026-05-19 17:43:45 +08:00
公明 7b3860971f Add files via upload 2026-05-19 17:42:12 +08:00
公明 f6f7b7b237 Add files via upload 2026-05-19 17:40:19 +08:00
公明 d5cf4b3b16 Add files via upload 2026-05-19 16:48:07 +08:00
公明 3e58d8355b Add files via upload 2026-05-19 16:32:38 +08:00
公明 eb01ade63b Add files via upload 2026-05-19 16:29:05 +08:00
公明 d1dc15fa44 Add files via upload 2026-05-19 16:27:29 +08:00
公明 73a39ef868 Add files via upload 2026-05-19 16:25:47 +08:00
公明 a022baef03 Add files via upload 2026-05-19 16:23:21 +08:00
公明 59312d428e Add files via upload 2026-05-19 14:53:07 +08:00
公明 951d14ef14 Update config.yaml 2026-05-18 23:51:19 +08:00
公明 0eb22da6e9 Add files via upload 2026-05-18 23:50:55 +08:00
公明 5fd9ef0514 Add files via upload 2026-05-18 23:47:10 +08:00
公明 9a4f3c7d35 Add files via upload 2026-05-18 17:37:29 +08:00
公明 ead2ce3ecc Add files via upload 2026-05-18 17:28:14 +08:00
公明 8733f3a2d2 Update config.yaml 2026-05-18 11:03:29 +08:00
公明 8642f3ba31 Add files via upload 2026-05-17 17:11:16 +08:00
公明 6a262a7367 Add files via upload 2026-05-17 17:09:16 +08:00
公明 eb9192ddb3 Add files via upload 2026-05-17 17:08:42 +08:00
公明 5587e75628 Add files via upload 2026-05-17 17:06:53 +08:00
公明 74bbb453e2 Add files via upload 2026-05-17 17:05:22 +08:00
公明 66842f6206 Add files via upload 2026-05-17 17:01:48 +08:00
公明 dc1779275d Add files via upload 2026-05-16 13:46:24 +08:00
公明 10dff937b1 Update config.yaml 2026-05-16 13:00:29 +08:00
公明 d4e1fe3bbe Add files via upload 2026-05-15 18:03:59 +08:00
公明 179976ae57 Add files via upload 2026-05-15 17:49:33 +08:00
公明 1c758bb98c Add files via upload 2026-05-15 17:34:25 +08:00
公明 17c4f38ee3 Add files via upload 2026-05-15 17:27:45 +08:00
公明 cd7e57d121 Add files via upload 2026-05-15 14:55:43 +08:00
公明 0f2c3f65cc Add files via upload 2026-05-15 14:21:40 +08:00
公明 7779666e27 Update config.yaml 2026-05-15 14:19:18 +08:00
公明 c74bd4403b Add files via upload 2026-05-15 14:16:04 +08:00
公明 04d23ddb43 Update config.yaml 2026-05-15 14:14:09 +08:00
公明 0874e84393 Add files via upload 2026-05-15 14:12:52 +08:00
公明 57f57f30b1 Add files via upload 2026-05-15 14:11:24 +08:00
公明 f37d613a0c Add files via upload 2026-05-15 14:09:37 +08:00
公明 87d0ff9154 Update config.yaml 2026-05-15 14:08:28 +08:00
公明 b3418f39b8 Update config.yaml 2026-05-15 11:53:07 +08:00
公明 f9e1ca0e2d Add files via upload 2026-05-15 11:49:53 +08:00
公明 2c45879669 Add files via upload 2026-05-15 11:48:58 +08:00
公明 1cdcfa2c2d Add files via upload 2026-05-15 11:47:34 +08:00
公明 eab5b73846 Add files via upload 2026-05-15 11:46:02 +08:00
公明 d961ba1ec7 Add files via upload 2026-05-15 11:43:33 +08:00
公明 1ba5e57ec6 Update config.yaml 2026-05-14 19:35:37 +08:00
公明 1216d25f96 Add files via upload 2026-05-14 19:33:15 +08:00
公明 fde693408e Add files via upload 2026-05-14 19:31:21 +08:00
公明 352a81a869 Add files via upload 2026-05-14 19:29:59 +08:00
公明 b2562b1010 Add files via upload 2026-05-14 19:28:37 +08:00
公明 0d8ba51087 Add files via upload 2026-05-14 19:26:23 +08:00
公明 0b847fcea3 Delete multiagent directory 2026-05-14 19:25:42 +08:00
公明 bf2f49fe62 Delete skillpackage directory 2026-05-14 19:25:19 +08:00
公明 75e64b1a86 Delete einomcp directory 2026-05-14 19:25:09 +08:00
公明 2167735022 Delete database directory 2026-05-14 19:24:58 +08:00
公明 4ee292cc1f Delete storage directory 2026-05-14 19:24:48 +08:00
公明 961205940f Delete agents directory 2026-05-14 19:24:19 +08:00
公明 ffe797bd06 Delete agent directory 2026-05-14 19:24:04 +08:00
公明 b6c864547e Delete mcp directory 2026-05-14 19:23:52 +08:00
公明 da369c2edc Add files via upload 2026-05-14 19:23:27 +08:00
公明 54dc31a616 Add files via upload 2026-05-14 19:21:35 +08:00
公明 9e0b985221 Add files via upload 2026-05-14 19:19:26 +08:00
公明 eb47077082 Update config.yaml 2026-05-14 14:59:27 +08:00
公明 f9a482857d Add files via upload 2026-05-14 11:57:00 +08:00
公明 679a68b12f Add files via upload 2026-05-14 11:55:47 +08:00
公明 840a26c7ef Add files via upload 2026-05-14 11:54:23 +08:00
公明 030e69c02d Add files via upload 2026-05-14 11:49:08 +08:00
公明 d9683cdb44 Add files via upload 2026-05-14 11:33:12 +08:00
公明 60a063dd7d Add files via upload 2026-05-14 11:31:56 +08:00
公明 5f0c1805a7 Add files via upload 2026-05-14 11:30:28 +08:00
公明 cb7e66001b Update config.yaml 2026-05-13 17:09:31 +08:00
公明 4ea838f1d7 Update config.yaml 2026-05-13 16:48:03 +08:00
公明 573648fc4b Add files via upload 2026-05-13 16:43:26 +08:00
公明 f0e090abea Add files via upload 2026-05-13 16:41:23 +08:00
公明 549dcf518c Add files via upload 2026-05-13 16:39:08 +08:00
公明 c74e20c54a Add files via upload 2026-05-13 16:36:09 +08:00
公明 c94a9fd9e9 Add files via upload 2026-05-13 15:26:02 +08:00
公明 ce9749a8ef Update config.yaml 2026-05-13 15:23:18 +08:00
公明 145da12017 Add files via upload 2026-05-13 12:33:23 +08:00
公明 5111f4c311 Add files via upload 2026-05-13 12:08:28 +08:00
公明 8f6384a083 Add files via upload 2026-05-13 12:06:56 +08:00
公明 762f778e1e Add files via upload 2026-05-13 12:05:12 +08:00
公明 4a11ba8f14 Add files via upload 2026-05-13 10:40:56 +08:00
公明 86090af4df Update config.yaml 2026-05-12 17:34:59 +08:00
公明 2dea6e36bd Add files via upload 2026-05-12 17:33:14 +08:00
公明 38ce695708 Update config.yaml 2026-05-12 17:29:45 +08:00
公明 41fe90faa3 Add files via upload 2026-05-12 17:23:57 +08:00
公明 9f54bdb1bf Add files via upload 2026-05-12 17:22:19 +08:00
公明 08e727aa41 Add files via upload 2026-05-12 17:19:51 +08:00
公明 176c17d630 Add files via upload 2026-05-12 17:17:36 +08:00
公明 62710f6619 Add files via upload 2026-05-12 16:42:43 +08:00
公明 e4dbb96b3e Add files via upload 2026-05-12 16:41:15 +08:00
公明 832532213a Add files via upload 2026-05-12 16:39:09 +08:00
公明 eb04ac0c3a Delete web/templates/index.html.bak 2026-05-12 16:36:51 +08:00
公明 1946508325 Add files via upload 2026-05-12 16:36:23 +08:00
公明 89d1c5124f Add files via upload 2026-05-12 14:57:04 +08:00
公明 1e7a3299a5 Merge pull request #118 from Dilligaf371/fix/mcp-stdio-init-result-storage
fix(mcp-stdio): initialize result storage so query tools work
2026-05-12 13:01:04 +08:00
公明 cae3a77331 Add files via upload 2026-05-12 12:56:11 +08:00
公明 2e1e57ce27 Add files via upload 2026-05-12 12:55:02 +08:00
公明 45b6ed2847 Add files via upload 2026-05-12 12:53:20 +08:00
公明 88eadf13a4 Add files via upload 2026-05-12 12:48:42 +08:00
Gilles Ceyssat dca5666b18 fix(mcp-stdio): initialize result storage so query tools work
The stdio MCP entrypoint (cmd/mcp-stdio/main.go) constructed the
security Executor without calling SetResultStorage, leaving it nil.
Any tool that goes through the query path — notably `exec` (the
generic shell tool) and the YAML wrappers that emit large results —
failed with:

    "错误: 结果存储未初始化"  (Error: result storage not initialized)

The full HTTP app at internal/app/app.go:118-147 initializes a
FileResultStorage from cfg.Agent.ResultStorageDir and wires it via
both agent.SetResultStorage and executor.SetResultStorage. The stdio
entrypoint needs the same wiring.

This replicates the storage init block in main.go so stdio-mode tool
execution stops failing on the query path.

Verified: before, `exec` calls returned the "结果存储未初始化" error.
After, `exec nmap -p 22,80,443 127.0.0.1` (bridged through an
external MCP client) returns the full nmap output as expected.
2026-05-12 08:13:13 +04:00
90 changed files with 9643 additions and 3083 deletions
+9 -5
View File
@@ -174,9 +174,11 @@ The `run.sh` script will automatically:
- ✅ Build the project - ✅ Build the project
- ✅ Start the server - ✅ Start the server
**Networking defaults:** `run.sh` starts the server with **`--https`** and the repo **`config.yaml`** (local self-signed TLS; better for many concurrent streams). Use **`./run.sh --http`** for plain HTTP. In production, set **`server.tls_cert_path`** / **`server.tls_key_path`** in **`config.yaml`** (see comments there). For manual runs, add **`--https`** or **`CYBERSTRIKE_HTTPS=1`**; if **`-config`** is wrong, the binary prints a short usage hint on stderr.
**First-Time Configuration:** **First-Time Configuration:**
1. **Configure OpenAI-compatible API** (required before first use) 1. **Configure OpenAI-compatible API** (required before first use)
- Open http://localhost:8080 after launch - After launch, open **`https://127.0.0.1:8080/`** (or **`https://localhost:8080/`**; replace **8080** with `server.port` in `config.yaml`) and accept the self-signed certificate warning once. If you used `./run.sh --http`, use **`http://`** instead.
- Go to `Settings` → Fill in your API credentials: - Go to `Settings` → Fill in your API credentials:
```yaml ```yaml
openai: openai:
@@ -197,21 +199,23 @@ The `run.sh` script will automatically:
**Alternative Launch Methods:** **Alternative Launch Methods:**
```bash ```bash
# Direct Go run (requires manual setup) # Direct Go run (set up env yourself); add --https to match run.sh defaults
go run cmd/server/main.go go run cmd/server/main.go --https
# Manual build # Manual build
go build -o cyberstrike-ai cmd/server/main.go go build -o cyberstrike-ai cmd/server/main.go
./cyberstrike-ai ./cyberstrike-ai --https
``` ```
If server logs show `client sent an HTTP request to an HTTPS server`, a client is still using **`http://`** on a TLS-only port—switch the URL to **`https://`**.
**Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment. **Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment.
### Version Update (No Breaking Changes) ### Version Update (No Breaking Changes)
**CyberStrikeAI one-click upgrade (recommended):** **CyberStrikeAI one-click upgrade (recommended):**
1. (First time) enable the script: `chmod +x upgrade.sh` 1. (First time) enable the script: `chmod +x upgrade.sh`
2. Upgrade with: `./upgrade.sh` (optional flags: `--tag vX.Y.Z`, `--no-venv`, `--preserve-custom`, `--yes`) 2. Upgrade with: `./upgrade.sh` (optional flags: `--tag vX.Y.Z`, `--no-venv`, `--yes`). Local `tools/`, `roles/`, and `skills/` are always preserved.
3. The script will back up your `config.yaml` and `data/`, upgrade the code from GitHub Release, update `config.yaml`'s `version`, then restart the server. 3. The script will back up your `config.yaml` and `data/`, upgrade the code from GitHub Release, update `config.yaml`'s `version`, then restart the server.
Recommended one-liner: Recommended one-liner:
+9 -5
View File
@@ -173,9 +173,11 @@ chmod +x run.sh && ./run.sh
- ✅ 编译构建项目 - ✅ 编译构建项目
- ✅ 启动服务器 - ✅ 启动服务器
**网络默认:** `run.sh` 会以 **`--https`** 并传入项目根 **`config.yaml`** 启动(本机自签证书,多路流式场景更稳)。只要明文 HTTP 用 **`./run.sh --http`**。生产环境在 **`config.yaml`** 的 **`server.tls_cert_path` / `server.tls_key_path`** 配正式证书(见文件内注释)。手动启动可加 **`--https`** 或环境变量 **`CYBERSTRIKE_HTTPS=1`**`-config` 写错时程序会在终端提示正确写法。
**首次配置:** **首次配置:**
1. **配置 AI 模型 API**(首次使用前必填) 1. **配置 AI 模型 API**(首次使用前必填)
- 启动后访问 http://localhost:8080 - 启动后在浏览器打开 **`https://127.0.0.1:8080/`**(或 **`https://localhost:8080/`**;端口以 `config.yaml`**`server.port`** 为准,默认 8080),并按提示信任自签证书。若使用 **`./run.sh --http`**,则改用 **`http://`** 访问。
- 进入 `设置` → 填写 API 配置信息: - 进入 `设置` → 填写 API 配置信息:
```yaml ```yaml
openai: openai:
@@ -196,20 +198,22 @@ chmod +x run.sh && ./run.sh
**其他启动方式:** **其他启动方式:**
```bash ```bash
# 直接运行(需手动配置环境) # 直接运行(需自行配环境);与 run.sh 默认一致可加 --https
go run cmd/server/main.go go run cmd/server/main.go --https
# 手动编译 # 手动编译
go build -o cyberstrike-ai cmd/server/main.go go build -o cyberstrike-ai cmd/server/main.go
./cyberstrike-ai ./cyberstrike-ai --https
``` ```
若日志出现 `client sent an HTTP request to an HTTPS server`,说明仍有客户端用 **`http://`** 访问只提供 HTTPS 的端口,请改为 **`https://`**。
**说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。 **说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。
### CyberStrikeAI 版本更新(无兼容性问题) ### CyberStrikeAI 版本更新(无兼容性问题)
1. (首次使用)启用脚本:`chmod +x upgrade.sh` 1. (首次使用)启用脚本:`chmod +x upgrade.sh`
2. 一键升级:`./upgrade.sh`(可选参数:`--tag vX.Y.Z`、`--no-venv`、`--preserve-custom`、`--yes` 2. 一键升级:`./upgrade.sh`(可选参数:`--tag vX.Y.Z`、`--no-venv`、`--yes`)。本地的 `tools/`、`roles/`、`skills/` 会始终保留不被覆盖。
3. 脚本会备份你的 `config.yaml` 和 `data/`,从 GitHub Release 升级代码,更新 `config.yaml` 的 `version` 字段后重启服务。 3. 脚本会备份你的 `config.yaml` 和 `data/`,从 GitHub Release 升级代码,更新 `config.yaml` 的 `version` 字段后重启服务。
推荐的一键指令: 推荐的一键指令:
+18
View File
@@ -5,6 +5,7 @@ import (
"cyberstrike-ai/internal/logger" "cyberstrike-ai/internal/logger"
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/security" "cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/storage"
"flag" "flag"
"fmt" "fmt"
"os" "os"
@@ -32,6 +33,23 @@ func main() {
// 创建安全工具执行器 // 创建安全工具执行器
executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger) executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger)
// 初始化结果存储(与 internal/app/app.go 同样的逻辑)。
// stdio 模式下原本不初始化,导致 'exec' 等查询型工具报"结果存储未初始化"。
resultStorageDir := "tmp"
if cfg.Agent.ResultStorageDir != "" {
resultStorageDir = cfg.Agent.ResultStorageDir
}
if err := os.MkdirAll(resultStorageDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "创建结果存储目录失败: %v\n", err)
os.Exit(1)
}
resultStorage, err := storage.NewFileResultStorage(resultStorageDir, log.Logger)
if err != nil {
fmt.Fprintf(os.Stderr, "初始化结果存储失败: %v\n", err)
os.Exit(1)
}
executor.SetResultStorage(resultStorage)
// 注册工具 // 注册工具
executor.RegisterTools(mcpServer) executor.RegisterTools(mcpServer)
+43 -3
View File
@@ -9,22 +9,62 @@ import (
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
) )
func main() { func main() {
var configPath = flag.String("config", "config.yaml", "配置文件路径") var configPath = flag.String("config", "config.yaml", "配置文件路径")
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
flag.Parse() flag.Parse()
// 环境变量兼容(便于 systemd/docker 等不传参场景)
if !*httpsBootstrap {
v := strings.TrimSpace(os.Getenv("CYBERSTRIKE_HTTPS"))
if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") {
*httpsBootstrap = true
}
}
// 加载配置 // 加载配置
cfg, err := config.Load(*configPath) cp := strings.TrimSpace(*configPath)
if cp == "" {
cp = "config.yaml"
}
if strings.HasPrefix(cp, "-") {
fmt.Fprintf(os.Stderr, "无效的 -config 路径 %q。\n若同时需要 HTTPS,请写成: ./cyberstrike-ai --https -config config.yaml-config 后必须是 yaml 文件路径)。\n", cp)
os.Exit(2)
}
cfg, err := config.Load(cp)
if err != nil { if err != nil {
fmt.Printf("加载配置失败: %v\n", err) fmt.Printf("加载配置失败: %v\n", err)
return return
} }
if *httpsBootstrap {
config.ApplyDevHTTPSBootstrap(cfg)
}
port := cfg.Server.Port
if port <= 0 {
port = 8080
}
scheme := "http"
if config.MainWebUIUsesHTTPS(&cfg.Server) {
scheme = "https"
}
fmt.Println()
fmt.Printf("→ Web 界面: %s://127.0.0.1:%d/\n", scheme, port)
if scheme == "https" && cfg.Server.TLSAutoSelfSign {
fmt.Println(" (内存自签证书:浏览器首次需确认「继续访问」)")
}
if scheme == "https" && config.ServerHTTPRedirectEnabled(&cfg.Server) {
fmt.Printf(" http://127.0.0.1:%d/ 将自动跳转到 HTTPS\n", port)
}
fmt.Println()
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置 // MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
if err := config.EnsureMCPAuth(*configPath, cfg); err != nil { if err := config.EnsureMCPAuth(cp, cfg); err != nil {
fmt.Printf("MCP 鉴权配置失败: %v\n", err) fmt.Printf("MCP 鉴权配置失败: %v\n", err)
return return
} }
@@ -44,7 +84,7 @@ func main() {
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// 创建应用 // 创建应用
application, err := app.New(cfg, log) application, err := app.New(cfg, log, cp)
if err != nil { if err != nil {
log.Fatal("应用初始化失败", "error", err) log.Fatal("应用初始化失败", "error", err)
} }
+39 -5
View File
@@ -10,11 +10,22 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.8" version: "v1.6.17"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
port: 8080 # HTTP 服务端口,可通过浏览器访问 http://localhost:8080 port: 8080 # 服务端口;未启用 TLS 时为 http://localhost:8080
# --- 可选:HTTPS + HTTP/2(缓解浏览器对同源 HTTP/1.1 的并发连接数限制,多路 Deep 流式更稳)---
# 启用 TLS 的条件(满足其一即可):tls_enabled: true,或 tls_auto_self_sign: true,或同时配置了 tls_cert_path + tls_key_path。
# 启用后请用 https://127.0.0.1:<本端口>/ 访问;若仍用 http:// 访问同端口,将自动 308 跳转到 HTTPS(可用 tls_http_redirect: false 关闭)。
tls_enabled: true
# 启用 HTTPS 时,明文 HTTP 是否自动跳转到 HTTPS(默认 true;同端口嗅探 TLS/HTTP 后分流)
# tls_http_redirect: true
# 方式 A(推荐生产):PEM 证书与私钥路径
# tls_cert_path: /path/to/fullchain.pem
# tls_key_path: /path/to/privkey.pem
# 方式 B(仅本地/测试):无证书文件时内存自签(浏览器会提示不受信任;SAN 含 localhost / 127.0.0.1
tls_auto_self_sign: true
# 认证配置 # 认证配置
auth: auth:
password: # Web 登录密码,请修改为强密码 password: # Web 登录密码,请修改为强密码
@@ -43,7 +54,7 @@ openai:
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置) max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinkingextended thinking),mode: off 关闭 # Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinkingextended thinking),mode: off 关闭
reasoning: reasoning:
mode: off # auto | on | offoff 时不附加任何推理扩展字段 mode: on # auto | on | offoff 时不附加任何推理扩展字段
effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展) effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展)
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准 allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
@@ -60,10 +71,10 @@ fofa:
# Agent 配置 # Agent 配置
# 达到最大迭代次数时,AI 会自动总结测试结果 # 达到最大迭代次数时,AI 会自动总结测试结果
agent: agent:
max_iterations: 120 # 最大迭代次数,AI 代理最多执行多少轮工具调用 max_iterations: 1200 # 最大迭代次数,AI 代理最多执行多少轮工具调用
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储 large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下 result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
tool_timeout_minutes: 30 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起) tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
# system_prompt_path: prompts/single-react.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示 # system_prompt_path: prompts/single-react.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
# 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。 # 人机协同(HITL)全局白名单:此处列出的工具始终免审批,与对话页「白名单工具(免审批,逗号分隔)」合并为并集;侧栏「应用」可合并写入本列表并立即生效。
hitl: hitl:
@@ -117,6 +128,21 @@ multi_agent:
deep_output_key: "" # 非空:将最终助手输出写入 adk session 的键名(Deep 与 Supervisor 主代理);空表示不写入 deep_output_key: "" # 非空:将最终助手输出写入 adk session 的键名(Deep 与 Supervisor 主代理);空表示不写入
deep_model_retry_max_retries: 0 # >0ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试 deep_model_retry_max_retries: 0 # >0ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑 task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
# Eino callbacks + OpenTelemetry:框架级 span(与 Zap 对齐);默认不向终端用户 UI 推 eino_trace_*(见 sse_trace_to_client
eino_callbacks:
enabled: true
# log_only=仅 Zap+OTel(推荐默认)| sse/full=才启用流式回调副本关闭等(full 含 stream hooks
mode: log_only
sse_trace_to_client: false # true:且 mode 为 sse/full 时,向前端时间线推送 eino_trace_*(排障/内网演示用)
max_input_summary_runes: 400
max_output_summary_runes: 400
zap_verbose: false # trueDebug 附带 input/output 摘要
otel:
enabled: true
service_name: cyberstrike-ai
exporter: stdout # none | stdout(开发/本机)| otlphttp(生产接 Collector
otlp_endpoint: localhost:4318 # otlphttp 时使用,host:port,路径固定 /v1/traces
sample_ratio: 1.0 # 0~1ParentBased+TraceIDRatio
# 数据库配置 # 数据库配置
database: database:
path: data/conversations.db # SQLite 数据库文件路径,用于存储对话历史和消息 path: data/conversations.db # SQLite 数据库文件路径,用于存储对话历史和消息
@@ -209,6 +235,14 @@ knowledge:
# 用于在手机端通过企业微信/钉钉/飞书与 CyberStrikeAI 对话,无需部署在服务器上也可使用 # 用于在手机端通过企业微信/钉钉/飞书与 CyberStrikeAI 对话,无需部署在服务器上也可使用
# 在系统设置 -> 机器人设置 中可配置 # 在系统设置 -> 机器人设置 中可配置
robots: robots:
wechat: # 微信 iLink(个人微信 ClawBot,扫码绑定)
enabled: false
bot_token: ""
ilink_bot_id: ""
ilink_user_id: ""
base_url: https://ilinkai.weixin.qq.com
bot_type: "3"
bot_agent: CyberStrikeAI/1.0
wecom: # 企业微信 wecom: # 企业微信
enabled: false enabled: false
token: "" token: ""
+18 -2
View File
@@ -27,7 +27,13 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/pkoukk/tiktoken-go v0.1.8 github.com/pkoukk/tiktoken-go v0.1.8
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
go.opentelemetry.io/otel v1.34.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0
go.opentelemetry.io/otel/sdk v1.34.0
go.opentelemetry.io/otel/trace v1.34.0
go.uber.org/zap v1.26.0 go.uber.org/zap v1.26.0
golang.org/x/net v0.35.0
golang.org/x/text v0.26.0 golang.org/x/text v0.26.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
@@ -39,6 +45,7 @@ require (
github.com/buger/jsonparser v1.1.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect github.com/dlclark/regexp2 v1.10.0 // indirect
@@ -46,6 +53,8 @@ require (
github.com/evanphx/json-patch v0.5.2 // indirect github.com/evanphx/json-patch v0.5.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
@@ -53,6 +62,7 @@ require (
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect github.com/google/jsonschema-go v0.3.0 // indirect
github.com/goph/emperror v0.17.2 // indirect github.com/goph/emperror v0.17.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect
@@ -71,14 +81,20 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.15.0 // indirect golang.org/x/arch v0.15.0 // indirect
golang.org/x/crypto v0.39.0 // indirect golang.org/x/crypto v0.39.0 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/grpc v1.69.4 // indirect
google.golang.org/protobuf v1.36.3 // indirect
) )
// 修复钉钉 Stream SDK 在长连接断开(熄屏/网络中断)后 "panic: send on closed channel" 问题 // 修复钉钉 Stream SDK 在长连接断开(熄屏/网络中断)后 "panic: send on closed channel" 问题
+41 -7
View File
@@ -17,6 +17,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
@@ -59,6 +61,11 @@ github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -75,8 +82,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -90,6 +97,8 @@ github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25d
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -191,6 +200,26 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 h1:BEj3SPM81McUZHYjRS5pEgNgnmzGJ5tRpU5krWnV8Bs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0/go.mod h1:9cKLGBDzI/F3NoHLQGm4ZrYdIHsvGt6ej6hUowxY0J4=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0 h1:jBpDk4HAUsrnVO1FsfCfCOTEc/MkInJmvfCHYLFiT80=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0/go.mod h1:H9LUIM1daaeZaz91vZcfeM0fejXPmgCYE8ZhzqfJuiU=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc=
go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
@@ -216,8 +245,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -251,9 +280,14 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f h1:gap6+3Gk41EItBuyi4XX/bp4oqJ3UwuIMl25yGinuAA=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:Ic02D47M+zbarjYYUlK57y316f2MoN0gjAwI3f2S95o=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+32 -8
View File
@@ -598,11 +598,17 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
thinkingStreamSeq++ thinkingStreamSeq++
thinkingStreamId := fmt.Sprintf("thinking-stream-%s-%d-%d", conversationID, i+1, thinkingStreamSeq) thinkingStreamId := fmt.Sprintf("thinking-stream-%s-%d-%d", conversationID, i+1, thinkingStreamSeq)
thinkingStreamStarted := false thinkingStreamStarted := false
var thinkingWire string
response, err := a.callOpenAIStreamWithToolCalls(ctx, messages, tools, func(delta string) error { response, err := a.callOpenAIStreamWithToolCalls(ctx, messages, tools, func(delta string) error {
if delta == "" { if delta == "" {
return nil return nil
} }
var deltaOut string
thinkingWire, deltaOut = openai.NormalizeStreamingDelta(thinkingWire, delta)
if deltaOut == "" {
return nil
}
if !thinkingStreamStarted { if !thinkingStreamStarted {
thinkingStreamStarted = true thinkingStreamStarted = true
sendProgress("thinking_stream_start", " ", map[string]interface{}{ sendProgress("thinking_stream_start", " ", map[string]interface{}{
@@ -611,10 +617,10 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
"toolStream": false, "toolStream": false,
}) })
} }
sendProgress("thinking_stream_delta", delta, map[string]interface{}{ sendProgress("thinking_stream_delta", deltaOut, openai.WithSSEAccumulated(map[string]interface{}{
"streamId": thinkingStreamId, "streamId": thinkingStreamId,
"iteration": i + 1, "iteration": i + 1,
}) }, thinkingWire))
return nil return nil
}) })
if err != nil { if err != nil {
@@ -827,10 +833,16 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
"mcpExecutionIds": result.MCPExecutionIDs, "mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "summary", "messageGeneratedBy": "summary",
}) })
var summaryWire string
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error { streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{ var deltaOut string
summaryWire, deltaOut = openai.NormalizeStreamingDelta(summaryWire, delta)
if deltaOut == "" {
return nil
}
sendProgress("response_delta", deltaOut, openai.WithSSEAccumulated(map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
}) }, summaryWire))
return nil return nil
}) })
if strings.TrimSpace(streamText) != "" { if strings.TrimSpace(streamText) != "" {
@@ -874,10 +886,16 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
"mcpExecutionIds": result.MCPExecutionIDs, "mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "summary", "messageGeneratedBy": "summary",
}) })
var summaryWire string
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error { streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{ var deltaOut string
summaryWire, deltaOut = openai.NormalizeStreamingDelta(summaryWire, delta)
if deltaOut == "" {
return nil
}
sendProgress("response_delta", deltaOut, openai.WithSSEAccumulated(map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
}) }, summaryWire))
return nil return nil
}) })
if strings.TrimSpace(streamText) != "" { if strings.TrimSpace(streamText) != "" {
@@ -921,10 +939,16 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
"mcpExecutionIds": result.MCPExecutionIDs, "mcpExecutionIds": result.MCPExecutionIDs,
"messageGeneratedBy": "max_iter_summary", "messageGeneratedBy": "max_iter_summary",
}) })
var summaryWire string
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error { streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
sendProgress("response_delta", delta, map[string]interface{}{ var deltaOut string
summaryWire, deltaOut = openai.NormalizeStreamingDelta(summaryWire, delta)
if deltaOut == "" {
return nil
}
sendProgress("response_delta", deltaOut, openai.WithSSEAccumulated(map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
}) }, summaryWire))
return nil return nil
}) })
if strings.TrimSpace(streamText) != "" { if strings.TrimSpace(streamText) != "" {
+167
View File
@@ -0,0 +1,167 @@
package agent
import (
"encoding/json"
"strings"
)
// ParseTraceMessages 解析落库的 last_react_inputOpenAI 风格 messages JSON 数组)。
func ParseTraceMessages(traceInputJSON string) ([]ChatMessage, error) {
traceInputJSON = strings.TrimSpace(traceInputJSON)
if traceInputJSON == "" {
return nil, nil
}
var raw []map[string]interface{}
if err := json.Unmarshal([]byte(traceInputJSON), &raw); err != nil {
return nil, err
}
out := make([]ChatMessage, 0, len(raw))
for _, msgMap := range raw {
msg := ChatMessage{}
role, _ := msgMap["role"].(string)
if role == "" {
continue
}
msg.Role = role
if content, ok := msgMap["content"].(string); ok {
msg.Content = content
}
if rc, ok := msgMap["reasoning_content"].(string); ok && strings.TrimSpace(rc) != "" {
msg.ReasoningContent = rc
}
if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil {
if toolCallsArray, ok := toolCallsRaw.([]interface{}); ok {
for _, tcRaw := range toolCallsArray {
tcMap, ok := tcRaw.(map[string]interface{})
if !ok {
continue
}
toolCall := ToolCall{}
if id, ok := tcMap["id"].(string); ok {
toolCall.ID = id
}
if toolType, ok := tcMap["type"].(string); ok {
toolCall.Type = toolType
}
if funcMap, ok := tcMap["function"].(map[string]interface{}); ok {
toolCall.Function = FunctionCall{}
if name, ok := funcMap["name"].(string); ok {
toolCall.Function.Name = name
}
if argsRaw, ok := funcMap["arguments"]; ok {
if argsStr, ok := argsRaw.(string); ok {
var argsMap map[string]interface{}
if err := json.Unmarshal([]byte(argsStr), &argsMap); err == nil {
toolCall.Function.Arguments = argsMap
}
} else if argsMap, ok := argsRaw.(map[string]interface{}); ok {
toolCall.Function.Arguments = argsMap
}
}
}
if toolCall.ID != "" {
msg.ToolCalls = append(msg.ToolCalls, toolCall)
}
}
}
}
if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
msg.ToolCallID = toolCallID
}
if tn, ok := msgMap["tool_name"].(string); ok && strings.TrimSpace(tn) != "" {
msg.ToolName = strings.TrimSpace(tn)
} else if tn, ok := msgMap["name"].(string); ok && strings.TrimSpace(tn) != "" && strings.EqualFold(msg.Role, "tool") {
msg.ToolName = strings.TrimSpace(tn)
}
out = append(out, msg)
}
return out, nil
}
// ExtractLastUserTurnMessages 仅保留最后一次 user 提问起的消息(不含更早的用户轮次;跳过 system)。
// 与「继续对话」续跑所用轨迹范围一致:当前任务轮次,而非整段多轮对话历史。
func ExtractLastUserTurnMessages(msgs []ChatMessage) []ChatMessage {
if len(msgs) == 0 {
return msgs
}
lastUser := -1
for i, m := range msgs {
if strings.EqualFold(m.Role, "user") {
lastUser = i
}
}
if lastUser < 0 {
return msgs
}
trimmed := msgs[lastUser:]
out := make([]ChatMessage, 0, len(trimmed))
for _, m := range trimmed {
if strings.EqualFold(m.Role, "system") {
continue
}
out = append(out, m)
}
return out
}
// ExtractLastUserTurnTraceJSON 在 JSON 轨迹上裁剪为最后一次 user 起的片段(供落库格式直接处理)。
func ExtractLastUserTurnTraceJSON(traceInputJSON string) string {
traceInputJSON = strings.TrimSpace(traceInputJSON)
if traceInputJSON == "" {
return traceInputJSON
}
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(traceInputJSON), &arr); err != nil {
return traceInputJSON
}
lastUser := -1
for i, m := range arr {
if r, _ := m["role"].(string); strings.EqualFold(r, "user") {
lastUser = i
}
}
if lastUser <= 0 {
return traceInputJSON
}
trimmed := arr[lastUser:]
b, err := json.Marshal(trimmed)
if err != nil {
return traceInputJSON
}
return string(b)
}
// MergeAssistantTraceOutput 将 last_react_output 合并进轨迹最后一条 assistant(与 loadHistoryFromAgentTrace 一致)。
func MergeAssistantTraceOutput(msgs []ChatMessage, assistantOut string) []ChatMessage {
assistantOut = strings.TrimSpace(assistantOut)
if assistantOut == "" || len(msgs) == 0 {
return msgs
}
out := append([]ChatMessage(nil), msgs...)
last := &out[len(out)-1]
if strings.EqualFold(last.Role, "assistant") && len(last.ToolCalls) == 0 {
last.Content = assistantOut
return out
}
out = append(out, ChatMessage{
Role: "assistant",
Content: assistantOut,
})
return out
}
// MessagesToTraceJSON 将消息带序列化为 JSON(跳过 system)。
func MessagesToTraceJSON(msgs []ChatMessage) (string, error) {
filtered := make([]ChatMessage, 0, len(msgs))
for _, m := range msgs {
if strings.EqualFold(m.Role, "system") {
continue
}
filtered = append(filtered, m)
}
b, err := json.Marshal(filtered)
if err != nil {
return "", err
}
return string(b), nil
}
+57
View File
@@ -0,0 +1,57 @@
package agent
import (
"encoding/json"
"testing"
)
func TestExtractLastUserTurnTraceJSON(t *testing.T) {
raw := []map[string]interface{}{
{"role": "user", "content": "old question"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "new target 1.1.1.1"},
{"role": "assistant", "tool_calls": []interface{}{map[string]interface{}{
"id": "c1", "type": "function",
"function": map[string]interface{}{"name": "nmap", "arguments": "{}"},
}}},
{"role": "tool", "tool_call_id": "c1", "content": "open ports"},
}
b, _ := json.Marshal(raw)
out := ExtractLastUserTurnTraceJSON(string(b))
var trimmed []map[string]interface{}
if err := json.Unmarshal([]byte(out), &trimmed); err != nil {
t.Fatal(err)
}
if len(trimmed) != 3 {
t.Fatalf("expected 3 messages, got %d", len(trimmed))
}
if trimmed[0]["content"] != "new target 1.1.1.1" {
t.Fatalf("unexpected first message: %v", trimmed[0])
}
}
func TestExtractLastUserTurnMessagesSkipsSystem(t *testing.T) {
msgs := []ChatMessage{
{Role: "system", Content: "sys"},
{Role: "user", Content: "q"},
{Role: "assistant", Content: "a"},
}
out := ExtractLastUserTurnMessages(msgs)
if len(out) != 2 {
t.Fatalf("expected 2, got %d", len(out))
}
if out[0].Role != "user" {
t.Fatal("expected user first")
}
}
func TestMergeAssistantTraceOutput(t *testing.T) {
msgs := []ChatMessage{
{Role: "user", Content: "q"},
{Role: "assistant", Content: "draft"},
}
out := MergeAssistantTraceOutput(msgs, "final summary")
if out[len(out)-1].Content != "final summary" {
t.Fatalf("expected merged output, got %q", out[len(out)-1].Content)
}
}
+100 -11
View File
@@ -3,8 +3,10 @@ package app
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"crypto/tls"
"database/sql" "database/sql"
"fmt" "fmt"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -16,6 +18,7 @@ import (
"cyberstrike-ai/internal/c2" "cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database" "cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/einoobserve"
"cyberstrike-ai/internal/handler" "cyberstrike-ai/internal/handler"
"cyberstrike-ai/internal/knowledge" "cyberstrike-ai/internal/knowledge"
"cyberstrike-ai/internal/logger" "cyberstrike-ai/internal/logger"
@@ -29,6 +32,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/net/http2"
) )
// App 应用 // App 应用
@@ -52,6 +56,7 @@ type App struct {
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启 dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启 larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
wechatCancel context.CancelFunc // 微信 iLink 长轮询取消函数
c2Manager *c2.Manager // C2 管理器(未启用 C2 时为 nil) c2Manager *c2.Manager // C2 管理器(未启用 C2 时为 nil)
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗 c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
c2WatchdogCancel context.CancelFunc // 看门狗取消函数 c2WatchdogCancel context.CancelFunc // 看门狗取消函数
@@ -59,7 +64,7 @@ type App struct {
} }
// New 创建新应用 // New 创建新应用
func New(cfg *config.Config, log *logger.Logger) (*App, error) { func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
router := gin.Default() router := gin.Default()
@@ -90,6 +95,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
// 创建MCP服务器(带数据库持久化) // 创建MCP服务器(带数据库持久化)
mcpServer := mcp.NewServerWithStorage(log.Logger, db) mcpServer := mcp.NewServerWithStorage(log.Logger, db)
mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(cfg.Agent.ToolTimeoutMinutes)
// 创建安全工具执行器 // 创建安全工具执行器
executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger) executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger)
@@ -290,10 +296,10 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
}() }()
} }
// 获取配置文件路径 // 配置文件路径必须由入口传入(与 flag -config 一致)。勿再用 os.Args[1],否则 ./cyberstrike-ai --https 会把 --https 当成路径。
configPath := "config.yaml" configPath = strings.TrimSpace(configPath)
if len(os.Args) > 1 { if configPath == "" {
configPath = os.Args[1] configPath = "config.yaml"
} }
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath) skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
@@ -444,9 +450,11 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
configHandler.SetRetrieverUpdater(knowledgeRetriever) configHandler.SetRetrieverUpdater(knowledgeRetriever)
} }
// 设置机器人连接重启器,前端应用配置后无需重启服务即可使钉钉/飞书新配置生效 // 设置机器人连接重启器,前端应用配置后无需重启服务即可使钉钉/飞书/微信新配置生效
configHandler.SetRobotRestarter(app) configHandler.SetRobotRestarter(app)
wechatRobotHandler := handler.NewWechatRobotHandler(cfg, configHandler, log.Logger)
configHandler.SetC2Runtime(app) configHandler.SetC2Runtime(app)
configHandler.SetC2ToolRegistrar(func() error { configHandler.SetC2ToolRegistrar(func() error {
if app.config.C2.EnabledEffective() && app.c2Manager != nil { if app.config.C2.EnabledEffective() && app.c2Manager != nil {
@@ -464,6 +472,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
notificationHandler, notificationHandler,
conversationHandler, conversationHandler,
robotHandler, robotHandler,
wechatRobotHandler,
groupHandler, groupHandler,
configHandler, configHandler,
externalMCPHandler, externalMCPHandler,
@@ -528,18 +537,49 @@ func (a *App) RunWithContext(ctx context.Context) error {
}() }()
} }
// 启动主服务器 // 启动主服务器(可选 HTTPS + HTTP/2,见 config server.tls_*
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port) addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
a.logger.Info("启动HTTP服务器", zap.String("address", addr)) tlsMode, tlsConf, certFile, keyFile, tlsErr := prepareMainServerTLS(&a.config.Server)
if tlsErr != nil {
return tlsErr
}
srv := &http.Server{Addr: addr, Handler: a.router} srv := &http.Server{Addr: addr, Handler: a.router}
var mainMux *mainServerMux
httpRedirect := config.ServerHTTPRedirectEnabled(&a.config.Server)
if tlsMode != mainTLSOff {
srv.TLSConfig = tlsConf
if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil {
return fmt.Errorf("主服务 HTTP/2 配置失败: %w", err)
}
switch tlsMode {
case mainTLSFromFiles:
a.logger.Info("启动 HTTPS 主服务(已启用 HTTP/2 协商)",
zap.String("address", addr),
zap.String("cert", certFile),
)
case mainTLSInMemorySelfSigned:
a.logger.Info("启动 HTTPS 主服务(内存自签证书,仅测试;已启用 HTTP/2 协商)",
zap.String("address", addr),
)
}
if httpRedirect {
a.logger.Info("已启用 HTTP→HTTPS 自动跳转(同端口嗅探分流)", zap.String("address", addr))
}
} else {
a.logger.Info("启动 HTTP 主服务", zap.String("address", addr))
}
// 监听 context 取消,优雅关闭 HTTP 服务器 // 监听 context 取消,优雅关闭 HTTP 服务器
go func() { go func() {
<-ctx.Done() <-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { if mainMux != nil {
if err := mainMux.Shutdown(shutdownCtx); err != nil {
a.logger.Error("HTTP/HTTPS 分流服务器关闭失败", zap.Error(err))
}
} else if err := srv.Shutdown(shutdownCtx); err != nil {
a.logger.Error("HTTP服务器关闭失败", zap.Error(err)) a.logger.Error("HTTP服务器关闭失败", zap.Error(err))
} }
if mcpServer != nil { if mcpServer != nil {
@@ -549,7 +589,36 @@ func (a *App) RunWithContext(ctx context.Context) error {
} }
}() }()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { var err error
switch {
case tlsMode != mainTLSOff && httpRedirect:
var tlsConfReady *tls.Config
tlsConfReady, err = ensureMainTLSConfigCerts(tlsMode, tlsConf, certFile, keyFile)
if err != nil {
return fmt.Errorf("加载 TLS 证书: %w", err)
}
srv.TLSConfig = tlsConfReady
var ln net.Listener
ln, err = net.Listen("tcp", addr)
if err != nil {
return err
}
mainMux = newMainServerMux(ln, srv, portFromListenAddr(addr), a.logger.Logger)
err = mainMux.Serve()
case tlsMode == mainTLSOff:
err = srv.ListenAndServe()
case tlsMode == mainTLSFromFiles:
err = srv.ListenAndServeTLS(certFile, keyFile)
case tlsMode == mainTLSInMemorySelfSigned:
var ln net.Listener
ln, err = tls.Listen("tcp", addr, srv.TLSConfig)
if err == nil {
err = srv.Serve(ln)
}
default:
err = srv.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
return err return err
} }
return nil return nil
@@ -557,6 +626,10 @@ func (a *App) RunWithContext(ctx context.Context) error {
// Shutdown 关闭应用 // Shutdown 关闭应用
func (a *App) Shutdown() { func (a *App) Shutdown() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = einoobserve.ShutdownOtel(shutdownCtx)
shutdownCancel()
// 停止钉钉/飞书长连接 // 停止钉钉/飞书长连接
a.robotMu.Lock() a.robotMu.Lock()
if a.dingCancel != nil { if a.dingCancel != nil {
@@ -606,9 +679,14 @@ func (a *App) startRobotConnections() {
a.dingCancel = cancel a.dingCancel = cancel
go robot.StartDing(ctx, cfg.Robots, a.robotHandler, a.logger.Logger) go robot.StartDing(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
} }
if cfg.Robots.Wechat.Enabled && cfg.Robots.Wechat.BotToken != "" {
ctx, cancel := context.WithCancel(context.Background())
a.wechatCancel = cancel
go robot.StartWechat(ctx, cfg.Robots, a.robotHandler, cfg.Version, a.logger.Logger)
}
} }
// RestartRobotConnections 重启钉钉/飞书长连接,使前端应用配置后立即生效(实现 handler.RobotRestarter // RestartRobotConnections 重启钉钉/飞书/微信长连接,使前端应用配置后立即生效(实现 handler.RobotRestarter
func (a *App) RestartRobotConnections() { func (a *App) RestartRobotConnections() {
a.robotMu.Lock() a.robotMu.Lock()
if a.dingCancel != nil { if a.dingCancel != nil {
@@ -619,6 +697,10 @@ func (a *App) RestartRobotConnections() {
a.larkCancel() a.larkCancel()
a.larkCancel = nil a.larkCancel = nil
} }
if a.wechatCancel != nil {
a.wechatCancel()
a.wechatCancel = nil
}
a.robotMu.Unlock() a.robotMu.Unlock()
// 给旧 goroutine 一点时间退出 // 给旧 goroutine 一点时间退出
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
@@ -634,6 +716,7 @@ func setupRoutes(
notificationHandler *handler.NotificationHandler, notificationHandler *handler.NotificationHandler,
conversationHandler *handler.ConversationHandler, conversationHandler *handler.ConversationHandler,
robotHandler *handler.RobotHandler, robotHandler *handler.RobotHandler,
wechatRobotHandler *handler.WechatRobotHandler,
groupHandler *handler.GroupHandler, groupHandler *handler.GroupHandler,
configHandler *handler.ConfigHandler, configHandler *handler.ConfigHandler,
externalMCPHandler *handler.ExternalMCPHandler, externalMCPHandler *handler.ExternalMCPHandler,
@@ -682,6 +765,12 @@ func setupRoutes(
// 机器人测试(需登录):POST /api/robot/testbody: {"platform":"dingtalk","user_id":"test","text":"帮助"},用于验证机器人逻辑 // 机器人测试(需登录):POST /api/robot/testbody: {"platform":"dingtalk","user_id":"test","text":"帮助"},用于验证机器人逻辑
protected.POST("/robot/test", robotHandler.HandleRobotTest) protected.POST("/robot/test", robotHandler.HandleRobotTest)
// 微信 iLink 扫码绑定(需登录)
protected.POST("/robot/wechat/qrcode", wechatRobotHandler.HandleWechatQRCode)
protected.GET("/robot/wechat/qrcode/status", wechatRobotHandler.HandleWechatQRCodeStatus)
protected.POST("/robot/wechat/qrcode/verify", wechatRobotHandler.HandleWechatVerifyCode)
protected.GET("/robot/wechat/status", wechatRobotHandler.HandleWechatStatus)
// Agent Loop // Agent Loop
protected.POST("/agent-loop", agentHandler.AgentLoop) protected.POST("/agent-loop", agentHandler.AgentLoop)
// Agent Loop 流式输出 // Agent Loop 流式输出
+196
View File
@@ -0,0 +1,196 @@
package app
import (
"bufio"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"sync"
"time"
"go.uber.org/zap"
)
// peekedConn 在已预读首字节后仍将连接交给 net/http 或 crypto/tls。
type peekedConn struct {
net.Conn
r *bufio.Reader
}
func (c *peekedConn) Read(p []byte) (int, error) {
return c.r.Read(p)
}
// oneConnListener 供 http.Server.Serve 处理单条 TCP 连接(含 keep-alive)。
type oneConnListener struct {
conn net.Conn
addr net.Addr
once sync.Once
}
func (l *oneConnListener) Accept() (net.Conn, error) {
var c net.Conn
l.once.Do(func() {
c = l.conn
l.conn = nil
})
if c == nil {
return nil, net.ErrClosed
}
return c, nil
}
func (l *oneConnListener) Close() error { return nil }
func (l *oneConnListener) Addr() net.Addr { return l.addr }
func isTLSHandshakeRecord(b byte) bool {
return b == 0x16
}
func newHTTPToHTTPSRedirectHandler(httpsPort int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
var target string
if httpsPort == 443 {
target = fmt.Sprintf("https://%s%s", host, r.URL.RequestURI())
} else {
target = fmt.Sprintf("https://%s:%d%s", host, httpsPort, r.URL.RequestURI())
}
http.Redirect(w, r, target, http.StatusPermanentRedirect)
})
}
func portFromListenAddr(addr string) int {
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
return 443
}
p, err := strconv.Atoi(portStr)
if err != nil || p <= 0 {
return 443
}
return p
}
func ensureMainTLSConfigCerts(mode mainTLSMode, tlsConf *tls.Config, certFile, keyFile string) (*tls.Config, error) {
if mode != mainTLSFromFiles {
return tlsConf, nil
}
if tlsConf == nil {
tlsConf = &tls.Config{MinVersion: tls.VersionTLS12}
}
if len(tlsConf.Certificates) > 0 {
return tlsConf, nil
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
tlsConf.Certificates = []tls.Certificate{cert}
return tlsConf, nil
}
type mainServerMux struct {
ln net.Listener
httpsSrv *http.Server
redirectSrv *http.Server
logger *zap.Logger
}
func newMainServerMux(ln net.Listener, httpsSrv *http.Server, httpsPort int, logger *zap.Logger) *mainServerMux {
return &mainServerMux{
ln: ln,
httpsSrv: httpsSrv,
redirectSrv: &http.Server{Handler: newHTTPToHTTPSRedirectHandler(httpsPort), ReadHeaderTimeout: 10 * time.Second},
logger: logger,
}
}
func (m *mainServerMux) Serve() error {
for {
conn, err := m.ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return http.ErrServerClosed
}
return err
}
go m.handleConn(conn)
}
}
func (m *mainServerMux) handleConn(raw net.Conn) {
if err := raw.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
_ = raw.Close()
return
}
br := bufio.NewReader(raw)
b, err := br.Peek(1)
if err != nil {
_ = raw.Close()
return
}
_ = raw.SetReadDeadline(time.Time{})
pc := &peekedConn{Conn: raw, r: br}
ocl := &oneConnListener{conn: pc, addr: raw.LocalAddr()}
if isTLSHandshakeRecord(b[0]) {
m.serveHTTPS(pc, raw.LocalAddr())
return
}
if err := m.redirectSrv.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
m.logger.Debug("HTTP 重定向连接处理结束", zap.Error(err))
}
}
// serveHTTPS 在已嗅探为 TLS 的连接上完成握手,再按 ALPN 走 HTTP/2 或 HTTP/1.1。
// 不能对同一 http.Server 并发调用 Serve(TLSConfig!=nil),否则握手/ALPN 会异常(浏览器 ERR_SSL_PROTOCOL_ERROR)。
func (m *mainServerMux) serveHTTPS(pc *peekedConn, localAddr net.Addr) {
tlsConn := tls.Server(pc, m.httpsSrv.TLSConfig)
handCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := tlsConn.HandshakeContext(handCtx); err != nil {
m.logger.Debug("TLS 握手失败", zap.Error(err))
_ = pc.Close()
return
}
srv := m.httpsSrv
if srv.TLSNextProto != nil {
proto := tlsConn.ConnectionState().NegotiatedProtocol
if fn := srv.TLSNextProto[proto]; fn != nil {
fn(srv, tlsConn, srv.Handler)
return
}
}
plain := *srv
plain.TLSConfig = nil
ocl := &oneConnListener{conn: tlsConn, addr: localAddr}
if err := plain.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
m.logger.Debug("HTTPS 连接处理结束", zap.Error(err))
}
}
func (m *mainServerMux) Shutdown(ctx context.Context) error {
_ = m.ln.Close()
var err1, err2 error
if m.httpsSrv != nil {
err1 = m.httpsSrv.Shutdown(ctx)
}
if m.redirectSrv != nil {
err2 = m.redirectSrv.Shutdown(ctx)
}
if err1 != nil {
return err1
}
return err2
}
@@ -0,0 +1,150 @@
package app
import (
"crypto/tls"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"cyberstrike-ai/internal/config"
"golang.org/x/net/http2"
)
func TestNewHTTPToHTTPSRedirectHandler(t *testing.T) {
t.Parallel()
tests := []struct {
name string
httpsPort int
host string
uri string
wantTarget string
}{
{
name: "non standard port",
httpsPort: 8080,
host: "127.0.0.1:8080",
uri: "/login?next=/",
wantTarget: "https://127.0.0.1:8080/login?next=/",
},
{
name: "standard port",
httpsPort: 443,
host: "example.com:80",
uri: "/",
wantTarget: "https://example.com/",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := newHTTPToHTTPSRedirectHandler(tt.httpsPort)
req := httptest.NewRequest(http.MethodGet, "http://"+tt.host+tt.uri, nil)
req.Host = tt.host
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusPermanentRedirect {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusPermanentRedirect)
}
if got := rec.Header().Get("Location"); got != tt.wantTarget {
t.Fatalf("Location = %q, want %q", got, tt.wantTarget)
}
})
}
}
func TestIsTLSHandshakeRecord(t *testing.T) {
t.Parallel()
if !isTLSHandshakeRecord(0x16) {
t.Fatal("expected TLS handshake record")
}
if isTLSHandshakeRecord('G') {
t.Fatal("GET should not be TLS")
}
}
func TestServerHTTPRedirectEnabled(t *testing.T) {
t.Parallel()
disabled := false
enabled := true
if config.ServerHTTPRedirectEnabled(nil) {
t.Fatal("nil config should disable redirect")
}
if !config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true}) {
t.Fatal("HTTPS without explicit flag should enable redirect")
}
if config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true, TLSHTTPRedirect: &disabled}) {
t.Fatal("explicit false should disable redirect")
}
if !config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true, TLSHTTPRedirect: &enabled}) {
t.Fatal("explicit true should enable redirect")
}
if config.ServerHTTPRedirectEnabled(&config.ServerConfig{}) {
t.Fatal("plain HTTP should not redirect")
}
}
func TestMainServerMuxHTTPRedirectAndHTTPS(t *testing.T) {
cert, err := generateMainServerSelfSignedCert()
if err != nil {
t.Fatalf("generate cert: %v", err)
}
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "ok")
})
srv := &http.Server{Handler: handler, TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cert},
}}
if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil {
t.Fatalf("configure http2: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
mux := newMainServerMux(ln, srv, portFromListenAddr(ln.Addr().String()), nil)
go func() { _ = mux.Serve() }()
client := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
addr := ln.Addr().String()
httpResp, err := client.Get("http://" + addr + "/")
if err != nil {
t.Fatalf("http get: %v", err)
}
_ = httpResp.Body.Close()
if httpResp.StatusCode != http.StatusPermanentRedirect {
t.Fatalf("http status = %d, want %d", httpResp.StatusCode, http.StatusPermanentRedirect)
}
if got := httpResp.Header.Get("Location"); got != "https://127.0.0.1:"+strconv.Itoa(portFromListenAddr(addr))+"/" {
t.Fatalf("Location = %q", got)
}
httpsResp, err := client.Get("https://" + addr + "/")
if err != nil {
t.Fatalf("https get: %v", err)
}
defer httpsResp.Body.Close()
if httpsResp.StatusCode != http.StatusOK {
t.Fatalf("https status = %d, want %d", httpsResp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(httpsResp.Body)
if string(body) != "ok" {
t.Fatalf("body = %q, want ok", body)
}
}
+86
View File
@@ -0,0 +1,86 @@
package app
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"strings"
"time"
"cyberstrike-ai/internal/config"
)
// mainTLSMode 主 Web 服务 TLS 启动方式。
type mainTLSMode int
const (
mainTLSOff mainTLSMode = iota
mainTLSFromFiles
mainTLSInMemorySelfSigned
)
// prepareMainServerTLS 根据 server 配置决定主站是否启用 HTTPS(及 HTTP/2 协商)。
// fromFiles:使用 tls_cert_path + tls_key_path,由 http.Server.ListenAndServeTLS 加载 PEM。
// inMemorytls_auto_self_sign 生成的自签证书,仅用于本地/测试。
func prepareMainServerTLS(cfg *config.ServerConfig) (mode mainTLSMode, tlsConf *tls.Config, certFile, keyFile string, err error) {
if cfg == nil || !config.MainWebUIUsesHTTPS(cfg) {
return mainTLSOff, nil, "", "", nil
}
certFile = strings.TrimSpace(cfg.TLSCertPath)
keyFile = strings.TrimSpace(cfg.TLSKeyPath)
if certFile != "" && keyFile != "" {
// 证书由 ListenAndServeTLS 从文件加载;此处仅提供最小 TLS 配置供 http2.ConfigureServer 合并 ALPN。
return mainTLSFromFiles, &tls.Config{MinVersion: tls.VersionTLS12}, certFile, keyFile, nil
}
if cfg.TLSAutoSelfSign {
cert, genErr := generateMainServerSelfSignedCert()
if genErr != nil {
return mainTLSOff, nil, "", "", fmt.Errorf("生成自签 TLS 证书: %w", genErr)
}
tlsConf = &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cert},
}
return mainTLSInMemorySelfSigned, tlsConf, "", "", nil
}
return mainTLSOff, nil, "", "", fmt.Errorf("server: 已启用 TLStls_enabled / tls_auto_self_sign / 证书路径),请设置 tls_cert_path 与 tls_key_path,或将 tls_auto_self_sign 设为 true(仅测试环境)")
}
func generateMainServerSelfSignedCert() (tls.Certificate, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
return tls.Certificate{}, err
}
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "CyberStrikeAI"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, err
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return tls.Certificate{}, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return tls.X509KeyPair(certPEM, keyPEM)
}
+85 -66
View File
@@ -82,7 +82,7 @@ func NewBuilder(db *database.DB, openAIConfig *config.OpenAIConfig, logger *zap.
} }
} }
// BuildChainFromConversation 从对话构建攻击链(简化版本:用户输入+最后一轮ReAct输入+大模型输出) // BuildChainFromConversation 从对话构建攻击链(单次 LLM 调用;输入为当前任务轮次的 last_react 轨迹,与继续对话续跑范围一致)。
func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID string) (*Chain, error) { func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID string) (*Chain, error) {
b.logger.Info("开始构建攻击链(简化版本)", zap.String("conversationId", conversationID)) b.logger.Info("开始构建攻击链(简化版本)", zap.String("conversationId", conversationID))
@@ -157,33 +157,34 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
var reactInputFinal string var reactInputFinal string
var dataSource string // 记录数据来源 var dataSource string // 记录数据来源
// 如果成功获取到保存的ReAct数据,直接使用 // 优先使用落库的代理轨迹(与继续对话 loadHistoryFromAgentTrace 同源),并裁剪为「当前任务轮次」
if reactInputJSON != "" && modelOutput != "" { if reactInputJSON != "" {
// 计算 ReAct 输入的哈希值,用于追踪 trimmedJSON := agent.ExtractLastUserTurnTraceJSON(reactInputJSON)
hash := sha256.Sum256([]byte(reactInputJSON)) hash := sha256.Sum256([]byte(trimmedJSON))
reactInputHash := hex.EncodeToString(hash[:])[:16] // 使用前16字符作为短标识 reactInputHash := hex.EncodeToString(hash[:])[:16]
// 统计消息数量
var messageCount int var messageCount int
var tempMessages []interface{} if msgs, parseErr := agent.ParseTraceMessages(trimmedJSON); parseErr == nil {
if json.Unmarshal([]byte(reactInputJSON), &tempMessages) == nil { messageCount = len(msgs)
messageCount = len(tempMessages) msgs = agent.MergeAssistantTraceOutput(msgs, modelOutput)
reactInputFinal = b.formatAgentTraceFromChatMessages(msgs)
} else {
b.logger.Warn("解析代理轨迹失败,回退原始 JSON 格式化", zap.Error(parseErr))
reactInputFinal = b.formatAgentTraceInputFromJSON(trimmedJSON)
if strings.TrimSpace(modelOutput) != "" {
reactInputFinal += "\n\n## 助手结论(last_react_output\n\n" + modelOutput
}
} }
dataSource = "database_last_agent_trace" dataSource = "last_user_turn_agent_trace"
b.logger.Info("使用保存的ReAct数据构建攻击链", b.logger.Info("使用当前任务轮次代理轨迹构建攻击链(与续跑上下文范围一致)",
zap.String("conversationId", conversationID), zap.String("conversationId", conversationID),
zap.String("dataSource", dataSource), zap.String("dataSource", dataSource),
zap.Int("reactInputSize", len(reactInputJSON)), zap.Int("traceInputSizeBeforeTrim", len(reactInputJSON)),
zap.Int("traceInputSizeAfterTrim", len(trimmedJSON)),
zap.Int("messageCount", messageCount), zap.Int("messageCount", messageCount),
zap.String("reactInputHash", reactInputHash), zap.String("reactInputHash", reactInputHash),
zap.Int("modelOutputSize", len(modelOutput))) zap.Int("modelOutputSize", len(modelOutput)))
// 从保存的ReAct输入(JSON格式)中提取用户输入
// userInput = b.extractUserInputFromReActInput(reactInputJSON)
// 将JSON格式的messages转换为可读格式
reactInputFinal = b.formatAgentTraceInputFromJSON(reactInputJSON)
} else { } else {
// 2. 如果没有保存的ReAct数据,从对话消息构建 // 2. 如果没有保存的ReAct数据,从对话消息构建
dataSource = "messages_table" dataSource = "messages_table"
@@ -243,8 +244,15 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
} }
} }
// 3. 构建简化的prompt,一次性传递给大模型 // 3. 按 token 预算压缩输入,再构建 prompt(避免超出模型上下文)
prompt := b.buildSimplePrompt(reactInputFinal, modelOutput) reactInputFinal, modelOutput, _ = b.fitAttackChainPayload(reactInputFinal, modelOutput)
// 4. 构建 prompt 并单次调用大模型(助手结论已并入轨迹时不再重复传入)
promptAssistantOut := modelOutput
if reactInputJSON != "" {
promptAssistantOut = ""
}
prompt := b.buildSimplePrompt(reactInputFinal, promptAssistantOut)
// fmt.Println(prompt) // fmt.Println(prompt)
// 6. 调用AI生成攻击链(一次性,不做任何处理) // 6. 调用AI生成攻击链(一次性,不做任何处理)
chainJSON, err := b.callAIForChainGeneration(ctx, prompt) chainJSON, err := b.callAIForChainGeneration(ctx, prompt)
@@ -366,10 +374,17 @@ func (b *Builder) formatProcessDetailsForAttackChain(details []database.ProcessD
return strings.TrimSpace(sb.String()) return strings.TrimSpace(sb.String())
} }
// buildAgentTraceInput 构建最后一轮ReAct的输入(历史消息+当前用户输入) // buildAgentTraceInput 构建最后一轮 ReAct 的输入(从最后一条 user 消息起,不含更早轮次)。
func (b *Builder) buildAgentTraceInput(messages []database.Message) string { func (b *Builder) buildAgentTraceInput(messages []database.Message) string {
start := 0
for i := len(messages) - 1; i >= 0; i-- {
if strings.EqualFold(messages[i].Role, "user") {
start = i
break
}
}
var builder strings.Builder var builder strings.Builder
for _, msg := range messages { for _, msg := range messages[start:] {
builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", msg.Role, msg.Content)) builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", msg.Role, msg.Content))
} }
return builder.String() return builder.String()
@@ -396,67 +411,66 @@ func (b *Builder) buildAgentTraceInput(messages []database.Message) string {
// return "" // return ""
// } // }
// formatAgentTraceInputFromJSON 将JSON格式的messages数组转换为可读的字符串格式 // formatAgentTraceInputFromJSON 将 JSON 轨迹转为可读文本(会先按当前任务轮次裁剪)。
func (b *Builder) formatAgentTraceInputFromJSON(reactInputJSON string) string { func (b *Builder) formatAgentTraceInputFromJSON(reactInputJSON string) string {
var messages []map[string]interface{} trimmed := agent.ExtractLastUserTurnTraceJSON(reactInputJSON)
if err := json.Unmarshal([]byte(reactInputJSON), &messages); err != nil { msgs, err := agent.ParseTraceMessages(trimmed)
if err != nil {
b.logger.Warn("解析ReAct输入JSON失败", zap.Error(err)) b.logger.Warn("解析ReAct输入JSON失败", zap.Error(err))
return reactInputJSON // 如果解析失败,返回原始JSON return trimmed
} }
return b.formatAgentTraceFromChatMessages(msgs)
}
// formatAgentTraceFromChatMessages 将代理消息带格式化为攻击链分析输入(与续跑轨迹字段一致)。
func (b *Builder) formatAgentTraceFromChatMessages(msgs []agent.ChatMessage) string {
var builder strings.Builder var builder strings.Builder
for _, msg := range messages { for _, msg := range msgs {
role, _ := msg["role"].(string) role := msg.Role
content, _ := msg["content"].(string) content := msg.Content
// 处理assistant消息:提取tool_calls信息 if strings.EqualFold(role, "assistant") && len(msg.ToolCalls) > 0 {
if role == "assistant" { if content != "" {
if toolCalls, ok := msg["tool_calls"].([]interface{}); ok && len(toolCalls) > 0 { builder.WriteString(fmt.Sprintf("[%s]: %s\n", role, content))
// 如果有文本内容,先显示 }
if content != "" { builder.WriteString(fmt.Sprintf("[%s] 工具调用 (%d个):\n", role, len(msg.ToolCalls)))
builder.WriteString(fmt.Sprintf("[%s]: %s\n", role, content)) for i, tc := range msg.ToolCalls {
} args := ""
// 详细显示每个工具调用 if tc.Function.Arguments != nil {
builder.WriteString(fmt.Sprintf("[%s] 工具调用 (%d个):\n", role, len(toolCalls))) if b, err := json.Marshal(tc.Function.Arguments); err == nil {
for i, toolCall := range toolCalls { args = string(b)
if tc, ok := toolCall.(map[string]interface{}); ok {
toolCallID, _ := tc["id"].(string)
if funcData, ok := tc["function"].(map[string]interface{}); ok {
toolName, _ := funcData["name"].(string)
arguments, _ := funcData["arguments"].(string)
builder.WriteString(fmt.Sprintf(" [工具调用 %d]\n", i+1))
builder.WriteString(fmt.Sprintf(" ID: %s\n", toolCallID))
builder.WriteString(fmt.Sprintf(" 工具名称: %s\n", toolName))
builder.WriteString(fmt.Sprintf(" 参数: %s\n", arguments))
}
} }
} }
builder.WriteString("\n") builder.WriteString(fmt.Sprintf(" [工具调用 %d]\n", i+1))
continue builder.WriteString(fmt.Sprintf(" ID: %s\n", tc.ID))
builder.WriteString(fmt.Sprintf(" 工具名称: %s\n", tc.Function.Name))
builder.WriteString(fmt.Sprintf(" 参数: %s\n", args))
} }
builder.WriteString("\n")
continue
} }
// 处理tool消息:显示tool_call_id和完整内容 if strings.EqualFold(role, "tool") {
if role == "tool" { if msg.ToolCallID != "" {
toolCallID, _ := msg["tool_call_id"].(string) builder.WriteString(fmt.Sprintf("[%s] (tool_call_id: %s):\n%s\n\n", role, msg.ToolCallID, content))
if toolCallID != "" {
builder.WriteString(fmt.Sprintf("[%s] (tool_call_id: %s):\n%s\n\n", role, toolCallID, content))
} else { } else {
builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", role, content)) builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", role, content))
} }
continue continue
} }
// 其他消息类型(system, user等)正常显示
builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", role, content)) builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", role, content))
} }
return builder.String() return builder.String()
} }
// buildSimplePrompt 构建简化的prompt // buildSimplePrompt 构建简化的prompt
func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string { func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
return fmt.Sprintf(`你是专业的安全测试分析师和攻击链构建专家你的任务是根据对话记录和工具执行结果构建一个逻辑清晰有教育意义的攻击链图完整展现渗透测试的思维过程和执行路径 return fmt.Sprintf(`你是专业的安全测试分析师和攻击链构建专家你的任务是根据**当前任务轮次**的对话记录和工具执行结果一次性输出攻击链 JSON不要分多轮追问
## 输入范围继续对话续跑一致
- 下方ReAct 轨迹仅包含**最后一次用户提问之后**的消息与工具结果last_react 当前任务轮次不含更早的用户提问轮次
- 助手结论为同轮任务的最终输出摘要last_react_output节点须与轨迹中的实际工具执行一致严禁编造
## 核心目标 ## 核心目标
@@ -618,12 +632,9 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
5. **漏洞确认**如何确认漏洞存在actionvulnerability 5. **漏洞确认**如何确认漏洞存在actionvulnerability
6. **攻击路径**完整的攻击路径是什么从target到vulnerability的路径 6. **攻击路径**完整的攻击路径是什么从target到vulnerability的路径
## 最后一轮ReAct输入 ## 当前任务 ReAct 轨迹含工具执行助手结论见轨迹末尾 assistant
%s %s
## 大模型输出
%s %s
## 输出格式 ## 输出格式
@@ -752,7 +763,15 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
9. **不要过度精简**如果实际执行步骤较多可以适当增加节点数量最多20个确保不遗漏关键步骤 9. **不要过度精简**如果实际执行步骤较多可以适当增加节点数量最多20个确保不遗漏关键步骤
10. **输出前验证**在输出JSON前必须验证所有边都满足source < target的条件确保DAG结构正确 10. **输出前验证**在输出JSON前必须验证所有边都满足source < target的条件确保DAG结构正确
现在开始分析并构建攻击链`, reactInput, modelOutput) 现在开始分析并构建攻击链`, reactInput, assistantOutSection(modelOutput))
}
func assistantOutSection(modelOutput string) string {
modelOutput = strings.TrimSpace(modelOutput)
if modelOutput == "" {
return ""
}
return "\n## 助手结论(补充)\n\n" + modelOutput + "\n"
} }
// saveChain 保存攻击链到数据库 // saveChain 保存攻击链到数据库
@@ -812,7 +831,7 @@ func (b *Builder) callAIForChainGeneration(ctx context.Context, prompt string) (
}, },
}, },
"temperature": 0.3, "temperature": 0.3,
"max_completion_tokens": 80000, "max_completion_tokens": attackChainMaxCompletionTokens(b.maxTokens),
} }
var apiResponse struct { var apiResponse struct {
+248
View File
@@ -0,0 +1,248 @@
package attackchain
import (
"strings"
"unicode/utf8"
"go.uber.org/zap"
)
const (
attackChainTruncationMarker = "\n\n...[攻击链输入已截断 / attack chain input truncated]...\n\n"
attackChainSystemReserve = 256
attackChainSafetyReserve = 2048
)
// attackChainMaxCompletionTokens 为攻击链 JSON 输出预留的 completion token 上限。
func attackChainMaxCompletionTokens(maxTotal int) int {
const capTokens = 16384
if maxTotal <= 0 {
return 8192
}
v := maxTotal / 8
if v < 4096 {
v = 4096
}
if v > capTokens {
v = capTokens
}
return v
}
func (b *Builder) modelName() string {
if b.openAIConfig != nil && b.openAIConfig.Model != "" {
return b.openAIConfig.Model
}
return "gpt-4"
}
func (b *Builder) countTokens(text string) int {
if text == "" {
return 0
}
n, err := b.tokenCounter.Count(b.modelName(), text)
if err != nil {
return utf8.RuneCountInString(text) / 4
}
return n
}
// attackChainPayloadTokenBudget 计算 reactInput + modelOutput 可用的 token 预算。
func (b *Builder) attackChainPayloadTokenBudget() int {
maxTotal := b.maxTokens
if maxTotal <= 0 {
maxTotal = 100000
}
templateTok := b.countTokens(b.buildSimplePrompt("", ""))
completion := attackChainMaxCompletionTokens(maxTotal)
reserve := templateTok + attackChainSystemReserve + completion + attackChainSafetyReserve
budget := maxTotal - reserve
minBudget := maxTotal * 35 / 100
if budget < minBudget {
budget = minBudget
}
if budget < 4096 {
budget = 4096
}
return budget
}
// fitAttackChainPayload 在构建最终 prompt 前压缩 ReAct 轨迹与模型输出,避免超出模型上下文。
func (b *Builder) fitAttackChainPayload(reactInput, modelOutput string) (string, string, bool) {
budget := b.attackChainPayloadTokenBudget()
modelBudget := budget * 15 / 100
if modelBudget < 512 {
modelBudget = 512
}
reactBudget := budget - modelBudget
origReactTok := b.countTokens(reactInput)
origModelTok := b.countTokens(modelOutput)
truncated := false
outModel := modelOutput
if origModelTok > modelBudget {
outModel = truncateTextByTokens(b, modelOutput, modelBudget)
truncated = true
}
outReact := reactInput
perToolLimits := []int{12000, 6000, 3000, 1500, 800}
for _, lim := range perToolLimits {
compact := compactFormattedToolBodies(outReact, lim)
if compact != outReact {
outReact = compact
truncated = true
}
if b.countTokens(outReact) <= reactBudget {
break
}
}
if b.countTokens(outReact) > reactBudget {
outReact = truncateTextByTokens(b, outReact, reactBudget)
truncated = true
}
if truncated {
b.logger.Info("攻击链输入已按 token 预算截断",
zap.Int("maxTotalTokens", b.maxTokens),
zap.Int("payloadBudget", budget),
zap.Int("reactBudget", reactBudget),
zap.Int("modelBudget", modelBudget),
zap.Int("reactInputTokensBefore", origReactTok),
zap.Int("reactInputTokensAfter", b.countTokens(outReact)),
zap.Int("modelOutputTokensBefore", origModelTok),
zap.Int("modelOutputTokensAfter", b.countTokens(outModel)),
zap.Int("maxCompletionTokens", attackChainMaxCompletionTokens(b.maxTokens)),
)
}
return outReact, outModel, truncated
}
// compactFormattedToolBodies 缩短格式化 trace 中 [tool] 消息的正文,保留工具头与调用 ID。
func compactFormattedToolBodies(s string, maxRunesPerBody int) string {
if maxRunesPerBody <= 0 || s == "" {
return s
}
const marker = "[tool]"
var out strings.Builder
remaining := s
changed := false
for {
idx := strings.Index(remaining, marker)
if idx < 0 {
out.WriteString(remaining)
break
}
out.WriteString(remaining[:idx])
remaining = remaining[idx:]
nl := strings.IndexByte(remaining, '\n')
if nl < 0 {
out.WriteString(remaining)
break
}
header := remaining[:nl+1]
remaining = remaining[nl+1:]
bodyEnd := strings.Index(remaining, "\n\n[")
var body, rest string
if bodyEnd < 0 {
body = remaining
rest = ""
} else {
body = remaining[:bodyEnd]
rest = remaining[bodyEnd:]
}
if runeLen(body) > maxRunesPerBody {
body = truncateRunesWithNotice(body, maxRunesPerBody)
changed = true
}
out.WriteString(header)
out.WriteString(body)
remaining = rest
if rest == "" {
break
}
}
if !changed {
return s
}
return out.String()
}
func truncateTextByTokens(b *Builder, text string, maxTokens int) string {
if maxTokens <= 0 || text == "" {
return ""
}
if b.countTokens(text) <= maxTokens {
return text
}
markerTok := b.countTokens(attackChainTruncationMarker)
usable := maxTokens - markerTok
if usable < 256 {
usable = maxTokens / 2
}
headBudget := usable * 60 / 100
tailBudget := usable - headBudget
head := takeTokensFromStart(b, text, headBudget)
tail := takeTokensFromEnd(b, text, tailBudget)
return head + attackChainTruncationMarker + tail
}
func takeTokensFromStart(b *Builder, text string, maxTokens int) string {
rs := []rune(text)
if len(rs) == 0 || maxTokens <= 0 {
return ""
}
lo, hi := 0, len(rs)
for lo < hi {
mid := (lo + hi + 1) / 2
if b.countTokens(string(rs[:mid])) <= maxTokens {
lo = mid
} else {
hi = mid - 1
}
}
return string(rs[:lo])
}
func takeTokensFromEnd(b *Builder, text string, maxTokens int) string {
rs := []rune(text)
if len(rs) == 0 || maxTokens <= 0 {
return ""
}
lo, hi := 0, len(rs)
for lo < hi {
mid := (lo + hi) / 2
if b.countTokens(string(rs[mid:])) <= maxTokens {
hi = mid
} else {
lo = mid + 1
}
}
return string(rs[lo:])
}
func truncateRunesWithNotice(s string, maxRunes int) string {
rs := []rune(s)
if len(rs) <= maxRunes {
return s
}
const notice = "\n...[工具输出已截断 / tool output truncated]...\n"
noticeRunes := []rune(notice)
keep := maxRunes - len(noticeRunes)
if keep < 200 {
keep = maxRunes * 2 / 3
}
if keep < 1 {
return notice
}
head := keep * 70 / 100
tail := keep - head
return string(rs[:head]) + notice + string(rs[len(rs)-tail:])
}
func runeLen(s string) int {
return len([]rune(s))
}
+63
View File
@@ -0,0 +1,63 @@
package attackchain
import (
"strings"
"testing"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"go.uber.org/zap"
)
func testBuilder(maxTotal int) *Builder {
return &Builder{
logger: zap.NewNop(),
openAIConfig: &config.OpenAIConfig{Model: "gpt-4"},
tokenCounter: agent.NewTikTokenCounter(),
maxTokens: maxTotal,
}
}
func TestCompactFormattedToolBodies(t *testing.T) {
long := strings.Repeat("x", 20000)
in := "[user]: hi\n\n[tool] (tool_call_id: abc):\n" + long + "\n\n[assistant]: done\n"
out := compactFormattedToolBodies(in, 500)
if strings.Contains(out, strings.Repeat("x", 10000)) {
t.Fatal("expected tool body to be truncated")
}
if !strings.Contains(out, "[user]: hi") {
t.Fatal("expected user header preserved")
}
if !strings.Contains(out, "[assistant]: done") {
t.Fatal("expected assistant header preserved")
}
}
func TestFitAttackChainPayloadWithinBudget(t *testing.T) {
b := testBuilder(32000)
react := strings.Repeat("scan ", 50000)
model := strings.Repeat("result ", 10000)
r, m, truncated := b.fitAttackChainPayload(react, model)
if !truncated {
t.Fatal("expected truncation for large payload")
}
prompt := b.buildSimplePrompt(r, m)
total := b.countTokens(prompt) + attackChainMaxCompletionTokens(b.maxTokens) + attackChainSystemReserve
if total > b.maxTokens+attackChainSafetyReserve {
t.Fatalf("prompt still too large: estimated %d > max %d", total, b.maxTokens)
}
_ = m
}
func TestAttackChainMaxCompletionTokens(t *testing.T) {
if got := attackChainMaxCompletionTokens(120000); got != 15000 && got != 16384 {
// 120000/8 = 15000
if got < 4096 || got > 16384 {
t.Fatalf("unexpected completion cap: %d", got)
}
}
if got := attackChainMaxCompletionTokens(0); got != 8192 {
t.Fatalf("expected default 8192, got %d", got)
}
}
+4 -2
View File
@@ -239,13 +239,15 @@ func (m *Manager) StartListener(id string) (*database.C2Listener, error) {
} }
cfg.ApplyDefaults() cfg.ApplyDefaults()
// 通过工厂创建具体实现 // 通过工厂创建具体实现。必须使用 rec 的副本:HTTP handler 在返回 JSON 前会清空
// rec.ImplantToken / EncryptionKey 做脱敏,若 listener 实现持有同一指针会导致 beacon 鉴权永久失败。
listenerRec := *rec
factory := m.registry.Get(rec.Type) factory := m.registry.Get(rec.Type)
if factory == nil { if factory == nil {
return nil, ErrUnsupportedType return nil, ErrUnsupportedType
} }
inst, err := factory(ListenerCreationCtx{ inst, err := factory(ListenerCreationCtx{
Listener: rec, Listener: &listenerRec,
Config: cfg, Config: cfg,
Manager: m, Manager: m,
Logger: m.logger.With(zap.String("listener_id", rec.ID), zap.String("type", rec.Type)), Logger: m.logger.With(zap.String("listener_id", rec.ID), zap.String("type", rec.Type)),
+74
View File
@@ -0,0 +1,74 @@
package c2
import (
"io"
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// 回归:StartListener 返回的 rec 被 handler 脱敏清空 ImplantToken 后,运行中的 HTTP listener 仍能鉴权。
func TestStartListener_ImplantTokenSurvivesHandlerRedaction(t *testing.T) {
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
lnPick, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := lnPick.Addr().(*net.TCPAddr).Port
_ = lnPick.Close()
mgr := NewManager(db, zap.NewNop(), tmp)
mgr.Registry().Register(string(ListenerTypeHTTPBeacon), NewHTTPBeaconListener)
rec, err := mgr.CreateListener(CreateListenerInput{
Name: "t",
Type: string(ListenerTypeHTTPBeacon),
BindHost: "127.0.0.1",
BindPort: port,
})
if err != nil {
t.Fatal(err)
}
token := rec.ImplantToken
rec, err = mgr.StartListener(rec.ID)
if err != nil {
t.Fatal(err)
}
// 模拟 internal/handler/c2.go StartListener 在 JSON 响应前的脱敏
rec.ImplantToken = ""
rec.EncryptionKey = ""
time.Sleep(50 * time.Millisecond)
body := `{"hostname":"n","username":"u","os":"Linux","arch":"amd64","internal_ip":"10.0.0.1","pid":42}`
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:"+strconv.Itoa(port)+"/check_in", strings.NewReader(body))
req.Header.Set("X-Implant-Token", token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
if !strings.Contains(string(b), "session_id") {
t.Fatalf("expected session_id in body: %s", b)
}
_ = mgr.StopListener(rec.ID)
}
+147 -4
View File
@@ -63,6 +63,126 @@ type MultiAgentConfig struct {
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"` EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras. // EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
EinoMiddleware MultiAgentEinoMiddlewareConfig `yaml:"eino_middleware,omitempty" json:"eino_middleware,omitempty"` EinoMiddleware MultiAgentEinoMiddlewareConfig `yaml:"eino_middleware,omitempty" json:"eino_middleware,omitempty"`
// EinoCallbacks attaches CloudWeGo eino callbacks.InitCallbacks on ADK Runner context (structured logs + optional SSE trace).
EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"`
}
// MultiAgentEinoCallbacksConfig enables Eino unified callbacks on each ADK agent run (deep / plan_execute / supervisor / eino_single).
// Modes: log_only (zap + optional OTel; no SSE to browser), sse (adds client SSE eino_trace_* when sse_trace_to_client), full (sse rules + stream callback copies closed).
type MultiAgentEinoCallbacksConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` // log_only | sse | full; empty with enabled=true defaults to log_only
// SseTraceToClient when true emits eino_trace_* SSE for UI (use only for admin/debug; nil/false recommended in production).
SseTraceToClient *bool `yaml:"sse_trace_to_client,omitempty" json:"sse_trace_to_client,omitempty"`
// Otel configures OpenTelemetry trace export (independent of mode; exporter none disables export even if enabled).
Otel MultiAgentEinoCallbacksOtelConfig `yaml:"otel,omitempty" json:"otel,omitempty"`
// MaxInputSummaryRunes / MaxOutputSummaryRunes cap text placed in SSE payloads and debug logs (not full payloads).
MaxInputSummaryRunes int `yaml:"max_input_summary_runes,omitempty" json:"max_input_summary_runes,omitempty"`
MaxOutputSummaryRunes int `yaml:"max_output_summary_runes,omitempty" json:"max_output_summary_runes,omitempty"`
// ZapVerbose when true logs input/output summaries at zap.Debug on start/end; false uses Info with short fields only.
ZapVerbose bool `yaml:"zap_verbose,omitempty" json:"zap_verbose,omitempty"`
}
// MultiAgentEinoCallbacksOtelConfig OpenTelemetry for Eino callback spans (W3C trace in collector / stdout).
type MultiAgentEinoCallbacksOtelConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
ServiceName string `yaml:"service_name,omitempty" json:"service_name,omitempty"`
Exporter string `yaml:"exporter,omitempty" json:"exporter,omitempty"` // none | stdout | otlphttp
OTLPEndpoint string `yaml:"otlp_endpoint,omitempty" json:"otlp_endpoint,omitempty"` // host:port, e.g. localhost:4318 (path /v1/traces)
SampleRatio float64 `yaml:"sample_ratio,omitempty" json:"sample_ratio,omitempty"` // 01, default 1.0
}
// EinoCallbacksModeEffective returns off | log_only | sse | full.
func (c MultiAgentEinoCallbacksConfig) EinoCallbacksModeEffective() string {
if !c.Enabled {
return "off"
}
m := strings.TrimSpace(strings.ToLower(c.Mode))
switch m {
case "log_only":
return "log_only"
case "sse":
return "sse"
case "full":
return "full"
case "":
return "log_only"
default:
return "log_only"
}
}
// SseTraceToClientEffective is false unless explicitly set true (best practice: do not expose framework traces to end users by default).
func (c MultiAgentEinoCallbacksConfig) SseTraceToClientEffective() bool {
if c.SseTraceToClient == nil {
return false
}
return *c.SseTraceToClient
}
// ShouldEmitEinoTraceSSE is true when client-visible trace events should be sent over progress/SSE.
func (c MultiAgentEinoCallbacksConfig) ShouldEmitEinoTraceSSE(mode string) bool {
if !c.SseTraceToClientEffective() {
return false
}
return mode == "sse" || mode == "full"
}
// OtelExporterEffective returns none | stdout | otlphttp.
func (c MultiAgentEinoCallbacksOtelConfig) OtelExporterEffective() string {
e := strings.TrimSpace(strings.ToLower(c.Exporter))
switch e {
case "none", "stdout", "otlphttp":
return e
case "":
if c.Enabled {
return "stdout"
}
return "none"
default:
return "none"
}
}
// OtelTracingActive is true when spans should be started (enabled + non-none exporter).
func (c MultiAgentEinoCallbacksConfig) OtelTracingActive() bool {
if !c.Otel.Enabled {
return false
}
return c.Otel.OtelExporterEffective() != "none"
}
func (c MultiAgentEinoCallbacksOtelConfig) ServiceNameEffective() string {
s := strings.TrimSpace(c.ServiceName)
if s != "" {
return s
}
return "cyberstrike-ai"
}
func (c MultiAgentEinoCallbacksOtelConfig) SampleRatioEffective() float64 {
r := c.SampleRatio
if r <= 0 {
return 1.0
}
if r > 1 {
return 1.0
}
return r
}
func (c MultiAgentEinoCallbacksConfig) EinoCallbacksMaxInputSummaryRunes() int {
if c.MaxInputSummaryRunes > 0 {
return c.MaxInputSummaryRunes
}
return 400
}
func (c MultiAgentEinoCallbacksConfig) EinoCallbacksMaxOutputSummaryRunes() int {
if c.MaxOutputSummaryRunes > 0 {
return c.MaxOutputSummaryRunes
}
return 400
} }
// MultiAgentEinoMiddlewareConfig optional Eino ADK middleware and Deep / supervisor tuning. // MultiAgentEinoMiddlewareConfig optional Eino ADK middleware and Deep / supervisor tuning.
@@ -271,17 +391,31 @@ type MultiAgentAPIUpdate struct {
RobotUseMultiAgent bool `json:"robot_use_multi_agent"` RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"` BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"` PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"` // 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
} }
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等) // RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink 等)
type RobotsConfig struct { type RobotsConfig struct {
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略 Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定)
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信 Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉 Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书 Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
} }
// RobotWechatConfig 微信 iLink 机器人配置(个人微信 ClawBot / iLink 协议)
type RobotWechatConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
BotToken string `yaml:"bot_token,omitempty" json:"bot_token,omitempty"`
ILinkBotID string `yaml:"ilink_bot_id,omitempty" json:"ilink_bot_id,omitempty"`
ILinkUserID string `yaml:"ilink_user_id,omitempty" json:"ilink_user_id,omitempty"`
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://ilinkai.weixin.qq.com
BotType string `yaml:"bot_type,omitempty" json:"bot_type,omitempty"` // get_bot_qrcode 参数,默认 3
BotAgent string `yaml:"bot_agent,omitempty" json:"bot_agent,omitempty"` // base_info.bot_agent
GetUpdatesBuf string `yaml:"get_updates_buf,omitempty" json:"get_updates_buf,omitempty"` // 长轮询游标(运行时)
}
// RobotSessionConfig 机器人会话隔离策略 // RobotSessionConfig 机器人会话隔离策略
type RobotSessionConfig struct { type RobotSessionConfig struct {
StrictUserIdentity *bool `yaml:"strict_user_identity,omitempty" json:"strict_user_identity,omitempty"` // true 时只允许真实用户标识,不允许会话/群 ID 兜底 StrictUserIdentity *bool `yaml:"strict_user_identity,omitempty" json:"strict_user_identity,omitempty"` // true 时只允许真实用户标识,不允许会话/群 ID 兜底
@@ -323,8 +457,17 @@ type RobotLarkConfig struct {
} }
type ServerConfig struct { type ServerConfig struct {
Host string `yaml:"host"` Host string `yaml:"host" json:"host"`
Port int `yaml:"port"` Port int `yaml:"port" json:"port"`
// TLSEnabled 为 true 时主 Web UI 使用 HTTPS;现代浏览器在同源下会协商 HTTP/2,缓解 HTTP/1.1 每源并发连接数限制。
TLSEnabled bool `yaml:"tls_enabled,omitempty" json:"tls_enabled,omitempty"`
// TLSCertPath / TLSKeyPath 非空时从 PEM 文件加载证书(生产环境推荐)。
TLSCertPath string `yaml:"tls_cert_path,omitempty" json:"tls_cert_path,omitempty"`
TLSKeyPath string `yaml:"tls_key_path,omitempty" json:"tls_key_path,omitempty"`
// TLSAutoSelfSign 为 true 且未配置有效证书路径时,启动时生成内存自签证书(仅本地/测试;浏览器会提示不受信任)。
TLSAutoSelfSign bool `yaml:"tls_auto_self_sign,omitempty" json:"tls_auto_self_sign,omitempty"`
// TLSHTTPRedirect 为 false 时禁用 HTTP→HTTPS 跳转;省略或为 true 且已启用 HTTPS 时,明文 HTTP 访问将 308 跳转到 HTTPS(同端口嗅探分流)。
TLSHTTPRedirect *bool `yaml:"tls_http_redirect,omitempty" json:"tls_http_redirect,omitempty"`
} }
type LogConfig struct { type LogConfig struct {
+46
View File
@@ -0,0 +1,46 @@
package config
import "strings"
// MainWebUIUsesHTTPS 判断主 Web UI 是否以 HTTPS 监听(与 internal/app.prepareMainServerTLS 前置条件一致)。
func MainWebUIUsesHTTPS(s *ServerConfig) bool {
if s == nil {
return false
}
if s.TLSEnabled {
return true
}
if s.TLSAutoSelfSign {
return true
}
cert := strings.TrimSpace(s.TLSCertPath)
key := strings.TrimSpace(s.TLSKeyPath)
return cert != "" && key != ""
}
// ServerHTTPRedirectEnabled 是否在主站启用 HTTPS 时把明文 HTTP 请求重定向到 HTTPS(默认开启)。
func ServerHTTPRedirectEnabled(s *ServerConfig) bool {
if s == nil || !MainWebUIUsesHTTPS(s) {
return false
}
if s.TLSHTTPRedirect == nil {
return true
}
return *s.TLSHTTPRedirect
}
// ApplyDevHTTPSBootstrap 供 --https / 一键脚本使用:强制开启主站 TLS。
// 若已配置 tls_cert_path 与 tls_key_path 则仅用 PEM,不开启自签;否则启用 tls_auto_self_sign(内存证书,仅本地测试)。
func ApplyDevHTTPSBootstrap(cfg *Config) {
if cfg == nil {
return
}
cfg.Server.TLSEnabled = true
cert := strings.TrimSpace(cfg.Server.TLSCertPath)
key := strings.TrimSpace(cfg.Server.TLSKeyPath)
if cert != "" && key != "" {
cfg.Server.TLSAutoSelfSign = false
return
}
cfg.Server.TLSAutoSelfSign = true
}
+3 -1
View File
@@ -26,7 +26,7 @@ type Conversation struct {
// Message 消息 // Message 消息
type Message struct { type Message struct {
ID string `json:"id"` ID string `json:"id"`
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ReasoningContent string `json:"reasoningContent,omitempty"` ReasoningContent string `json:"reasoningContent,omitempty"`
@@ -117,6 +117,7 @@ func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conve
} }
for i := range conv.Messages { for i := range conv.Messages {
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok { if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
details = DedupeConsecutiveProcessDetails(details)
detailsJSON := make([]map[string]interface{}, len(details)) detailsJSON := make([]map[string]interface{}, len(details))
for j, detail := range details { for j, detail := range details {
var data interface{} var data interface{}
@@ -235,6 +236,7 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
// 将过程详情附加到对应的消息上 // 将过程详情附加到对应的消息上
for i := range conv.Messages { for i := range conv.Messages {
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok { if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
details = DedupeConsecutiveProcessDetails(details)
// 将ProcessDetail转换为JSON格式,以便前端使用 // 将ProcessDetail转换为JSON格式,以便前端使用
detailsJSON := make([]map[string]interface{}, len(details)) detailsJSON := make([]map[string]interface{}, len(details))
for j, detail := range details { for j, detail := range details {
@@ -0,0 +1,28 @@
package database
import (
"fmt"
"strings"
)
// DedupeConsecutiveProcessDetails 去掉相邻且语义相同的过程详情(使用 DB 中 data 列原始 JSON 作指纹,避免 map 序列化键序不稳定)。
func DedupeConsecutiveProcessDetails(rows []ProcessDetail) []ProcessDetail {
if len(rows) < 2 {
return rows
}
out := make([]ProcessDetail, 0, len(rows))
var lastKey string
for _, d := range rows {
key := processDetailRowKey(d)
if len(out) > 0 && key != "" && key == lastKey {
continue
}
out = append(out, d)
lastKey = key
}
return out
}
func processDetailRowKey(d ProcessDetail) string {
return fmt.Sprintf("%s\x00%s\x00%s", d.EventType, strings.TrimSpace(d.Message), d.Data)
}
+435
View File
@@ -0,0 +1,435 @@
// Package einoobserve attaches CloudWeGo Eino [callbacks.Handler] to ADK Runner contexts for
// structured logging and optional SSE trace events (eino_trace_*).
package einoobserve
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/callbacks"
"github.com/cloudwego/eino/components"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
)
type ctxSpanKey struct{}
type ctxOtelSpanKey struct{}
// Params for attaching per-run callback instrumentation.
type Params struct {
Logger *zap.Logger
Progress func(eventType, message string, data interface{})
ConversationID string
OrchMode string
OrchestratorName string
}
// AttachAgentRunCallbacks returns ctx wrapped with callbacks.InitCallbacks when enabled.
// Safe to call with nil cfg or disabled cfg (returns ctx unchanged).
func AttachAgentRunCallbacks(ctx context.Context, cfg *config.MultiAgentEinoCallbacksConfig, p Params) context.Context {
if ctx == nil {
return ctx
}
if cfg == nil || !cfg.Enabled {
return ctx
}
mode := cfg.EinoCallbacksModeEffective()
if mode == "off" {
return ctx
}
runID := uuid.New().String()
if p.Progress != nil && cfg.ShouldEmitEinoTraceSSE(mode) {
p.Progress("eino_trace_run", "Eino callbacks session", map[string]interface{}{
"runId": runID,
"conversationId": strings.TrimSpace(p.ConversationID),
"orchestration": strings.TrimSpace(p.OrchMode),
"orchestratorName": strings.TrimSpace(p.OrchestratorName),
"observeMode": mode,
"source": "eino_callbacks",
})
}
h := &runHandler{
cfg: *cfg,
mode: mode,
params: p,
runID: runID,
}
b := callbacks.NewHandlerBuilder().
OnStartFn(h.onStart).
OnEndFn(h.onEnd).
OnErrorFn(h.onError)
if mode == "full" {
b = b.OnStartWithStreamInputFn(h.onStartStreamIn).OnEndWithStreamOutputFn(h.onEndStreamOut)
}
ri := &callbacks.RunInfo{
Name: "CyberStrikeADKRun",
Type: strings.TrimSpace(p.OrchMode),
Component: components.Component("AgentSession"),
}
return callbacks.InitCallbacks(ctx, ri, b.Build())
}
type runHandler struct {
cfg config.MultiAgentEinoCallbacksConfig
mode string
params Params
runID string
mu sync.Mutex
spanStack []string
seq atomic.Uint64
}
func (h *runHandler) genSpanID() string {
return fmt.Sprintf("%s-%d", h.runID, h.seq.Add(1))
}
func (h *runHandler) popSpan() (id string) {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.spanStack) == 0 {
return ""
}
id = h.spanStack[len(h.spanStack)-1]
h.spanStack = h.spanStack[:len(h.spanStack)-1]
return id
}
// popMatching removes the given id from the stack top if it matches; otherwise pops until empty or match (rare ordering mismatch).
func (h *runHandler) popMatching(want string) string {
h.mu.Lock()
defer h.mu.Unlock()
if want == "" {
if len(h.spanStack) == 0 {
return ""
}
id := h.spanStack[len(h.spanStack)-1]
h.spanStack = h.spanStack[:len(h.spanStack)-1]
return id
}
for len(h.spanStack) > 0 {
top := h.spanStack[len(h.spanStack)-1]
h.spanStack = h.spanStack[:len(h.spanStack)-1]
if top == want {
return top
}
}
return want
}
func (h *runHandler) onStart(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
var parentID string
h.mu.Lock()
if len(h.spanStack) > 0 {
parentID = h.spanStack[len(h.spanStack)-1]
}
spanID := h.genSpanID()
h.spanStack = append(h.spanStack, spanID)
h.mu.Unlock()
inSum := summarizeCallbackInput(input, h.cfg.EinoCallbacksMaxInputSummaryRunes())
if h.cfg.OtelTracingActive() {
tracer := otel.Tracer("cyberstrike/eino")
spanName := callbackSpanName(info)
var sp trace.Span
ctx, sp = tracer.Start(ctx, spanName,
trace.WithSpanKind(trace.SpanKindInternal),
trace.WithAttributes(
attribute.String("eino.component", string(info.Component)),
attribute.String("eino.name", info.Name),
attribute.String("eino.type", info.Type),
attribute.String("cyberstrike.run_id", h.runID),
attribute.String("cyberstrike.conversation_id", strings.TrimSpace(h.params.ConversationID)),
attribute.String("cyberstrike.orchestration", strings.TrimSpace(h.params.OrchMode)),
),
)
if inSum != "" {
sp.SetAttributes(attribute.String("eino.input.summary", truncateForAttr(inSum, 256)))
}
ctx = context.WithValue(ctx, ctxOtelSpanKey{}, sp)
}
if h.params.Logger != nil {
fields := []zap.Field{
zap.String("runId", h.runID),
zap.String("spanId", spanID),
zap.String("parentSpanId", parentID),
zap.String("component", string(info.Component)),
zap.String("name", info.Name),
zap.String("type", info.Type),
zap.String("phase", "start"),
}
if sp, ok := ctx.Value(ctxOtelSpanKey{}).(trace.Span); ok && sp != nil {
if sc := sp.SpanContext(); sc.IsValid() {
fields = append(fields,
zap.String("trace_id", sc.TraceID().String()),
zap.String("otel_span_id", sc.SpanID().String()),
)
}
}
if h.cfg.ZapVerbose {
h.params.Logger.Debug("eino_callback", append(fields, zap.String("inputSummary", inSum))...)
} else {
h.params.Logger.Info("eino_callback", fields...)
}
}
if h.params.Progress != nil && h.cfg.ShouldEmitEinoTraceSSE(h.mode) {
h.params.Progress("eino_trace_start", "", map[string]interface{}{
"runId": h.runID,
"spanId": spanID,
"parentSpanId": parentID,
"conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(info.Component),
"name": info.Name,
"type": info.Type,
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"inputSummary": inSum,
"source": "eino_callbacks",
})
}
ctx = context.WithValue(ctx, ctxSpanKey{}, spanID)
return ctx
}
func (h *runHandler) onEnd(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
spanID, _ := ctx.Value(ctxSpanKey{}).(string)
if spanID == "" {
spanID = h.popSpan()
} else {
spanID = h.popMatching(spanID)
}
outSum := summarizeCallbackOutput(output, h.cfg.EinoCallbacksMaxOutputSummaryRunes())
if sp, ok := ctx.Value(ctxOtelSpanKey{}).(trace.Span); ok && sp != nil {
if outSum != "" {
sp.SetAttributes(attribute.String("eino.output.summary", truncateForAttr(outSum, 256)))
}
sp.SetStatus(codes.Ok, "")
sp.End()
}
if h.params.Logger != nil {
fields := []zap.Field{
zap.String("runId", h.runID),
zap.String("spanId", spanID),
zap.String("component", string(info.Component)),
zap.String("name", info.Name),
zap.String("type", info.Type),
zap.String("phase", "end"),
}
if h.cfg.ZapVerbose {
h.params.Logger.Debug("eino_callback", append(fields, zap.String("outputSummary", outSum))...)
} else {
h.params.Logger.Info("eino_callback", fields...)
}
}
if h.params.Progress != nil && h.cfg.ShouldEmitEinoTraceSSE(h.mode) {
h.params.Progress("eino_trace_end", "", map[string]interface{}{
"runId": h.runID,
"spanId": spanID,
"conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(info.Component),
"name": info.Name,
"type": info.Type,
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"outputSummary": outSum,
"source": "eino_callbacks",
})
}
return ctx
}
func (h *runHandler) onError(ctx context.Context, info *callbacks.RunInfo, err error) context.Context {
spanID, _ := ctx.Value(ctxSpanKey{}).(string)
if spanID == "" {
spanID = h.popSpan()
} else {
spanID = h.popMatching(spanID)
}
msg := ""
if err != nil {
msg = truncateRunes(err.Error(), h.cfg.EinoCallbacksMaxOutputSummaryRunes())
}
if sp, ok := ctx.Value(ctxOtelSpanKey{}).(trace.Span); ok && sp != nil {
if err != nil {
sp.RecordError(err)
}
sp.SetStatus(codes.Error, msg)
sp.End()
}
if h.params.Logger != nil {
h.params.Logger.Warn("eino_callback_error",
zap.String("runId", h.runID),
zap.String("spanId", spanID),
zap.String("component", string(info.Component)),
zap.String("name", info.Name),
zap.String("type", info.Type),
zap.Error(err),
)
}
if h.params.Progress != nil && h.cfg.ShouldEmitEinoTraceSSE(h.mode) {
h.params.Progress("eino_trace_error", msg, map[string]interface{}{
"runId": h.runID,
"spanId": spanID,
"conversationId": strings.TrimSpace(h.params.ConversationID),
"orchestration": strings.TrimSpace(h.params.OrchMode),
"component": string(info.Component),
"name": info.Name,
"type": info.Type,
"ts": time.Now().UTC().Format(time.RFC3339Nano),
"error": msg,
"source": "eino_callbacks",
})
}
return ctx
}
func (h *runHandler) onStartStreamIn(ctx context.Context, info *callbacks.RunInfo, input *schema.StreamReader[callbacks.CallbackInput]) context.Context {
if input != nil {
input.Close()
}
if h.params.Logger != nil {
h.params.Logger.Debug("eino_callback_stream_in",
zap.String("runId", h.runID),
zap.String("component", string(info.Component)),
zap.String("name", info.Name),
)
}
return ctx
}
func (h *runHandler) onEndStreamOut(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[callbacks.CallbackOutput]) context.Context {
if output != nil {
output.Close()
}
if h.params.Logger != nil {
h.params.Logger.Debug("eino_callback_stream_out",
zap.String("runId", h.runID),
zap.String("component", string(info.Component)),
zap.String("name", info.Name),
)
}
return ctx
}
func callbackSpanName(info *callbacks.RunInfo) string {
if info == nil {
return "eino.callback"
}
comp := strings.TrimSpace(string(info.Component))
name := strings.TrimSpace(info.Name)
typ := strings.TrimSpace(info.Type)
if name != "" && comp != "" {
return comp + "/" + name
}
if typ != "" && comp != "" {
return comp + "[" + typ + "]"
}
if comp != "" {
return comp
}
return "eino.callback"
}
func truncateForAttr(s string, maxRunes int) string {
return truncateRunes(s, maxRunes)
}
func summarizeCallbackInput(in callbacks.CallbackInput, maxRunes int) string {
if in == nil {
return ""
}
if ai := adk.ConvAgentCallbackInput(in); ai != nil {
parts := []string{"agent"}
if ai.Input != nil {
parts = append(parts, fmt.Sprintf("messages=%d", len(ai.Input.Messages)))
}
if ai.ResumeInfo != nil {
parts = append(parts, "resume=true")
}
return strings.Join(parts, " ")
}
if mi := model.ConvCallbackInput(in); mi != nil {
return fmt.Sprintf("chatModel messages=%d tools=%d", len(mi.Messages), len(mi.Tools))
}
if ti := tool.ConvCallbackInput(in); ti != nil {
raw := ti.ArgumentsInJSON
return "tool args=" + truncateRunes(raw, maxRunes)
}
b, err := json.Marshal(in)
if err != nil {
return fmt.Sprintf("%T", in)
}
return truncateRunes(string(b), maxRunes)
}
func summarizeCallbackOutput(out callbacks.CallbackOutput, maxRunes int) string {
if out == nil {
return ""
}
if ao := adk.ConvAgentCallbackOutput(out); ao != nil {
return "agent_events=stream"
}
if mo := model.ConvCallbackOutput(out); mo != nil && mo.Message != nil {
s := ""
if mo.Message.Content != "" {
s = mo.Message.Content
}
if mo.TokenUsage != nil {
return fmt.Sprintf("tokens total=%d completion=%d prompt=%d text=%s",
mo.TokenUsage.TotalTokens, mo.TokenUsage.CompletionTokens, mo.TokenUsage.PromptTokens,
truncateRunes(s, minInt(120, maxRunes)))
}
return "assistant len=" + itoa(len(s))
}
if to := tool.ConvCallbackOutput(out); to != nil {
if to.Response != "" {
return truncateRunes(to.Response, maxRunes)
}
if to.ToolOutput != nil {
return "tool_result multimodal"
}
}
b, err := json.Marshal(out)
if err != nil {
return fmt.Sprintf("%T", out)
}
return truncateRunes(string(b), maxRunes)
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func itoa(n int) string {
return fmt.Sprintf("%d", n)
}
func truncateRunes(s string, maxRunes int) string {
if maxRunes <= 0 {
return ""
}
r := []rune(s)
if len(r) <= maxRunes {
return s
}
return string(r[:maxRunes]) + "…"
}
+26
View File
@@ -0,0 +1,26 @@
package einoobserve
import (
"context"
"testing"
"cyberstrike-ai/internal/config"
)
func TestAttachAgentRunCallbacks_Disabled(t *testing.T) {
ctx := context.Background()
cfg := &config.MultiAgentEinoCallbacksConfig{Enabled: false}
out := AttachAgentRunCallbacks(ctx, cfg, Params{})
if out != ctx {
t.Fatalf("expected same ctx when disabled")
}
}
func TestTruncateRunes(t *testing.T) {
if got := truncateRunes("abc", 10); got != "abc" {
t.Fatalf("got %q", got)
}
if got := truncateRunes("abcdefghij", 4); got != "abcd…" {
t.Fatalf("got %q", got)
}
}
+111
View File
@@ -0,0 +1,111 @@
package einoobserve
import (
"context"
"fmt"
"strings"
"sync"
"cyberstrike-ai/internal/config"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
"go.uber.org/zap"
)
var (
otelMu sync.Mutex
otelShutdown func(context.Context) error
otelInitialized bool
)
// InitOtelFromConfig installs the global OpenTelemetry TracerProvider when
// eino_callbacks.otel is enabled and exporter is not none. Safe to call multiple times.
func InitOtelFromConfig(cfg *config.MultiAgentEinoCallbacksConfig, log *zap.Logger) (shutdown func(context.Context) error, err error) {
shutdown = func(context.Context) error { return nil }
if cfg == nil || !cfg.OtelTracingActive() {
return shutdown, nil
}
otelMu.Lock()
defer otelMu.Unlock()
if otelInitialized {
if otelShutdown != nil {
return otelShutdown, nil
}
return shutdown, nil
}
oc := cfg.Otel
expKind := oc.OtelExporterEffective()
ctx := context.Background()
var exporter sdktrace.SpanExporter
switch expKind {
case "stdout":
exporter, err = stdouttrace.New()
if err != nil {
return shutdown, fmt.Errorf("eino otel stdout exporter: %w", err)
}
case "otlphttp":
ep := strings.TrimSpace(oc.OTLPEndpoint)
if ep == "" {
ep = "localhost:4318"
}
exporter, err = otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(ep),
otlptracehttp.WithURLPath("/v1/traces"),
)
if err != nil {
return shutdown, fmt.Errorf("eino otel otlphttp exporter: %w", err)
}
default:
return shutdown, nil
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(oc.ServiceNameEffective()),
),
)
if err != nil {
return shutdown, fmt.Errorf("eino otel resource: %w", err)
}
sampler := sdktrace.ParentBased(sdktrace.TraceIDRatioBased(oc.SampleRatioEffective()))
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
sdktrace.WithSampler(sampler),
)
otel.SetTracerProvider(tp)
otelShutdown = tp.Shutdown
otelInitialized = true
if log != nil {
log.Info("eino otel: tracer provider initialized",
zap.String("exporter", expKind),
zap.String("service", oc.ServiceNameEffective()),
zap.Float64("sample_ratio", oc.SampleRatioEffective()),
)
}
return otelShutdown, nil
}
// ShutdownOtel flushes and shuts down the global TracerProvider if it was installed.
func ShutdownOtel(ctx context.Context) error {
otelMu.Lock()
fn := otelShutdown
otelShutdown = nil
inited := otelInitialized
otelInitialized = false
otelMu.Unlock()
if !inited || fn == nil {
return nil
}
return fn(ctx)
}
+21 -3
View File
@@ -23,6 +23,7 @@ import (
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin" "cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/multiagent" "cyberstrike-ai/internal/multiagent"
"cyberstrike-ai/internal/openai"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
@@ -1158,7 +1159,16 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
return return
} }
if eventType == "response_delta" { if eventType == "response_delta" {
respPlan.b.WriteString(message) if dataMap, ok := data.(map[string]interface{}); ok {
if acc, okAcc := dataMap[openai.SSEAccumulatedKey].(string); okAcc {
respPlan.b.Reset()
respPlan.b.WriteString(acc)
} else {
respPlan.b.WriteString(message)
}
} else {
respPlan.b.WriteString(message)
}
if dataMap, ok := data.(map[string]interface{}); ok && respPlan.meta == nil { if dataMap, ok := data.(map[string]interface{}); ok && respPlan.meta == nil {
respPlan.meta = make(map[string]interface{}, len(dataMap)) respPlan.meta = make(map[string]interface{}, len(dataMap))
for k, v := range dataMap { for k, v := range dataMap {
@@ -1213,8 +1223,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
} else if tb.persistAs == "" { } else if tb.persistAs == "" {
tb.persistAs = persistAs tb.persistAs = persistAs
} }
// delta 片段直接拼接 if acc, okAcc := dataMap[openai.SSEAccumulatedKey].(string); okAcc {
tb.b.WriteString(message) tb.b.Reset()
tb.b.WriteString(acc)
} else {
tb.b.WriteString(message)
}
// 有时 delta 先到 start 未到,补充元信息 // 有时 delta 先到 start 未到,补充元信息
for k, v := range dataMap { for k, v := range dataMap {
tb.meta[k] = v tb.meta[k] = v
@@ -1249,6 +1263,10 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
eventType != "response_start" && eventType != "response_start" &&
eventType != "response_delta" && eventType != "response_delta" &&
eventType != "tool_result_delta" && eventType != "tool_result_delta" &&
eventType != "eino_trace_run" &&
eventType != "eino_trace_start" &&
eventType != "eino_trace_end" &&
eventType != "eino_trace_error" &&
eventType != "eino_agent_reply_stream_start" && eventType != "eino_agent_reply_stream_start" &&
eventType != "eino_agent_reply_stream_delta" && eventType != "eino_agent_reply_stream_delta" &&
eventType != "eino_agent_reply_stream_end" { eventType != "eino_agent_reply_stream_end" {
+98 -15
View File
@@ -206,6 +206,25 @@ func (h *ConfigHandler) SetRobotRestarter(restarter RobotRestarter) {
h.robotRestarter = restarter h.robotRestarter = restarter
} }
// ApplyWechatRobotBinding 微信 iLink 扫码绑定成功后写入配置并重启机器人连接
func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) error {
h.mu.Lock()
wc.Enabled = true
h.config.Robots.Wechat = wc
h.mu.Unlock()
if err := h.saveConfig(); err != nil {
return err
}
if h.robotRestarter != nil {
h.robotRestarter.RestartRobotConnections()
}
h.logger.Info("微信机器人绑定已保存",
zap.String("ilink_bot_id", wc.ILinkBotID),
zap.Bool("enabled", wc.Enabled),
)
return nil
}
// GetConfigResponse 获取配置响应 // GetConfigResponse 获取配置响应
type GetConfigResponse struct { type GetConfigResponse struct {
OpenAI config.OpenAIConfig `json:"openai"` OpenAI config.OpenAIConfig `json:"openai"`
@@ -609,15 +628,46 @@ func (h *ConfigHandler) GetTools(c *gin.Context) {
// UpdateConfigRequest 更新配置请求 // UpdateConfigRequest 更新配置请求
type UpdateConfigRequest struct { type UpdateConfigRequest struct {
OpenAI *config.OpenAIConfig `json:"openai,omitempty"` OpenAI *config.OpenAIConfig `json:"openai,omitempty"`
FOFA *config.FofaConfig `json:"fofa,omitempty"` FOFA *config.FofaConfig `json:"fofa,omitempty"`
MCP *config.MCPConfig `json:"mcp,omitempty"` MCP *config.MCPConfig `json:"mcp,omitempty"`
Tools []ToolEnableStatus `json:"tools,omitempty"` Tools []ToolEnableStatus `json:"tools,omitempty"`
Agent *config.AgentConfig `json:"agent,omitempty"` Agent *AgentConfigUpdate `json:"agent,omitempty"`
Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"` Knowledge *config.KnowledgeConfig `json:"knowledge,omitempty"`
Robots *config.RobotsConfig `json:"robots,omitempty"` Robots *config.RobotsConfig `json:"robots,omitempty"`
MultiAgent *config.MultiAgentAPIUpdate `json:"multi_agent,omitempty"` MultiAgent *config.MultiAgentAPIUpdate `json:"multi_agent,omitempty"`
C2 *config.C2APIUpdate `json:"c2,omitempty"` C2 *config.C2APIUpdate `json:"c2,omitempty"`
}
// AgentConfigUpdate 用于 PATCH /api/config 的 agent 段:仅 JSON 中出现的字段(指针非 nil)覆盖内存配置。
// 避免旧版「整包替换 *AgentConfig」时,未传的整型字段被反序列化为 0 误覆盖(例如 tool_timeout_minutes 变成 0)。
type AgentConfigUpdate struct {
MaxIterations *int `json:"max_iterations,omitempty"`
LargeResultThreshold *int `json:"large_result_threshold,omitempty"`
ResultStorageDir *string `json:"result_storage_dir,omitempty"`
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
}
func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
if dst == nil || src == nil {
return
}
if src.MaxIterations != nil {
dst.MaxIterations = *src.MaxIterations
}
if src.LargeResultThreshold != nil {
dst.LargeResultThreshold = *src.LargeResultThreshold
}
if src.ResultStorageDir != nil {
dst.ResultStorageDir = *src.ResultStorageDir
}
if src.ToolTimeoutMinutes != nil {
dst.ToolTimeoutMinutes = *src.ToolTimeoutMinutes
}
if src.SystemPromptPath != nil {
dst.SystemPromptPath = *src.SystemPromptPath
}
} }
// ToolEnableStatus 工具启用状态 // ToolEnableStatus 工具启用状态
@@ -664,12 +714,19 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
) )
} }
// 更新Agent配置 // 更新Agent配置(按字段合并,避免部分 JSON 把未出现的字段写成 0)
if req.Agent != nil { if req.Agent != nil {
h.config.Agent = *req.Agent applyAgentConfigUpdate(&h.config.Agent, req.Agent)
h.logger.Info("更新Agent配置", h.logger.Info("更新Agent配置",
zap.Int("max_iterations", h.config.Agent.MaxIterations), zap.Int("max_iterations", h.config.Agent.MaxIterations),
zap.Int("tool_timeout_minutes", h.config.Agent.ToolTimeoutMinutes),
) )
if h.agent != nil && req.Agent.MaxIterations != nil {
h.agent.UpdateMaxIterations(h.config.Agent.MaxIterations)
}
if h.mcpServer != nil {
h.mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(h.config.Agent.ToolTimeoutMinutes)
}
} }
// 更新Knowledge配置 // 更新Knowledge配置
@@ -697,6 +754,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
if req.Robots != nil { if req.Robots != nil {
h.config.Robots = *req.Robots h.config.Robots = *req.Robots
h.logger.Info("更新机器人配置", h.logger.Info("更新机器人配置",
zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled),
zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled), zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled),
zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled), zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled),
zap.Bool("lark_enabled", h.config.Robots.Lark.Enabled), zap.Bool("lark_enabled", h.config.Robots.Lark.Enabled),
@@ -717,7 +775,9 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil { if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
} }
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(req.MultiAgent.ToolSearchAlwaysVisibleTools) if req.MultiAgent.ToolSearchAlwaysVisibleTools != nil {
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(*req.MultiAgent.ToolSearchAlwaysVisibleTools)
}
h.logger.Info("更新多代理配置", h.logger.Info("更新多代理配置",
zap.Bool("enabled", h.config.MultiAgent.Enabled), zap.Bool("enabled", h.config.MultiAgent.Enabled),
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent), zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
@@ -1116,6 +1176,9 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
h.agent.UpdateToolDescriptionMode(h.config.Security.ToolDescriptionMode) h.agent.UpdateToolDescriptionMode(h.config.Security.ToolDescriptionMode)
h.logger.Info("Agent配置已更新") h.logger.Info("Agent配置已更新")
} }
if h.mcpServer != nil {
h.mcpServer.ConfigureHTTPToolCallTimeoutFromAgentMinutes(h.config.Agent.ToolTimeoutMinutes)
}
// 更新AttackChainHandler的OpenAI配置 // 更新AttackChainHandler的OpenAI配置
if h.attackChainHandler != nil { if h.attackChainHandler != nil {
@@ -1181,7 +1244,7 @@ func (h *ConfigHandler) saveConfig() error {
return fmt.Errorf("解析配置文件失败: %w", err) return fmt.Errorf("解析配置文件失败: %w", err)
} }
updateAgentConfig(root, h.config.Agent.MaxIterations) updateAgentConfig(root, h.config.Agent)
updateMCPConfig(root, h.config.MCP) updateMCPConfig(root, h.config.MCP)
updateOpenAIConfig(root, h.config.OpenAI) updateOpenAIConfig(root, h.config.OpenAI)
updateFOFAConfig(root, h.config.FOFA) updateFOFAConfig(root, h.config.FOFA)
@@ -1286,10 +1349,14 @@ func writeYAMLDocument(path string, doc *yaml.Node) error {
return os.WriteFile(path, buf.Bytes(), 0644) return os.WriteFile(path, buf.Bytes(), 0644)
} }
func updateAgentConfig(doc *yaml.Node, maxIterations int) { func updateAgentConfig(doc *yaml.Node, agent config.AgentConfig) {
root := doc.Content[0] root := doc.Content[0]
agentNode := ensureMap(root, "agent") agentNode := ensureMap(root, "agent")
setIntInMap(agentNode, "max_iterations", maxIterations) setIntInMap(agentNode, "max_iterations", agent.MaxIterations)
setIntInMap(agentNode, "tool_timeout_minutes", agent.ToolTimeoutMinutes)
setIntInMap(agentNode, "large_result_threshold", agent.LargeResultThreshold)
setStringInMap(agentNode, "result_storage_dir", agent.ResultStorageDir)
setStringInMap(agentNode, "system_prompt_path", agent.SystemPromptPath)
} }
func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) { func updateMCPConfig(doc *yaml.Node, cfg config.MCPConfig) {
@@ -1429,6 +1496,20 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
root := doc.Content[0] root := doc.Content[0]
robotsNode := ensureMap(root, "robots") robotsNode := ensureMap(root, "robots")
if cfg.Session.StrictUserIdentity != nil {
sessionNode := ensureMap(robotsNode, "session")
setBoolInMap(sessionNode, "strict_user_identity", *cfg.Session.StrictUserIdentity)
}
wechatNode := ensureMap(robotsNode, "wechat")
setBoolInMap(wechatNode, "enabled", cfg.Wechat.Enabled)
setStringInMap(wechatNode, "bot_token", cfg.Wechat.BotToken)
setStringInMap(wechatNode, "ilink_bot_id", cfg.Wechat.ILinkBotID)
setStringInMap(wechatNode, "ilink_user_id", cfg.Wechat.ILinkUserID)
setStringInMap(wechatNode, "base_url", cfg.Wechat.BaseURL)
setStringInMap(wechatNode, "bot_type", cfg.Wechat.BotType)
setStringInMap(wechatNode, "bot_agent", cfg.Wechat.BotAgent)
wecomNode := ensureMap(robotsNode, "wecom") wecomNode := ensureMap(robotsNode, "wecom")
setBoolInMap(wecomNode, "enabled", cfg.Wecom.Enabled) setBoolInMap(wecomNode, "enabled", cfg.Wecom.Enabled)
setStringInMap(wecomNode, "token", cfg.Wecom.Token) setStringInMap(wecomNode, "token", cfg.Wecom.Token)
@@ -1441,12 +1522,14 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
setBoolInMap(dingtalkNode, "enabled", cfg.Dingtalk.Enabled) setBoolInMap(dingtalkNode, "enabled", cfg.Dingtalk.Enabled)
setStringInMap(dingtalkNode, "client_id", cfg.Dingtalk.ClientID) setStringInMap(dingtalkNode, "client_id", cfg.Dingtalk.ClientID)
setStringInMap(dingtalkNode, "client_secret", cfg.Dingtalk.ClientSecret) setStringInMap(dingtalkNode, "client_secret", cfg.Dingtalk.ClientSecret)
setBoolInMap(dingtalkNode, "allow_conversation_id_fallback", cfg.Dingtalk.AllowConversationIDFallback)
larkNode := ensureMap(robotsNode, "lark") larkNode := ensureMap(robotsNode, "lark")
setBoolInMap(larkNode, "enabled", cfg.Lark.Enabled) setBoolInMap(larkNode, "enabled", cfg.Lark.Enabled)
setStringInMap(larkNode, "app_id", cfg.Lark.AppID) setStringInMap(larkNode, "app_id", cfg.Lark.AppID)
setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret) setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret)
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken) setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
setBoolInMap(larkNode, "allow_chat_id_fallback", cfg.Lark.AllowChatIDFallback)
} }
func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) { func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
+2
View File
@@ -117,6 +117,8 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
return return
} }
details = database.DedupeConsecutiveProcessDetails(details)
// 转换为前端期望的 JSON 结构(与 GetConversation 中 processDetails 结构一致) // 转换为前端期望的 JSON 结构(与 GetConversation 中 processDetails 结构一致)
out := make([]map[string]interface{}, 0, len(details)) out := make([]map[string]interface{}, 0, len(details))
for _, d := range details { for _, d := range details {
+293
View File
@@ -0,0 +1,293 @@
package handler
import (
"context"
"net/http"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/robot/ilink"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
const wechatLoginTTL = 5 * time.Minute
// WechatConfigSaver 绑定成功后写入配置并重启机器人连接
type WechatConfigSaver interface {
ApplyWechatRobotBinding(cfg config.RobotWechatConfig) error
}
type wechatLoginSession struct {
QRCode string
QRCodeImgURL string
PendingVerify string
CurrentBaseURL string
StartedAt time.Time
}
// WechatRobotHandler 微信 iLink 机器人(扫码绑定 + 配置)
type WechatRobotHandler struct {
config *config.Config
configSaver WechatConfigSaver
logger *zap.Logger
mu sync.Mutex
logins map[string]*wechatLoginSession
}
// NewWechatRobotHandler 创建微信机器人处理器
func NewWechatRobotHandler(cfg *config.Config, saver WechatConfigSaver, logger *zap.Logger) *WechatRobotHandler {
return &WechatRobotHandler{
config: cfg,
configSaver: saver,
logger: logger,
logins: make(map[string]*wechatLoginSession),
}
}
func (h *WechatRobotHandler) purgeExpiredLogins() {
now := time.Now()
for k, v := range h.logins {
if now.Sub(v.StartedAt) > wechatLoginTTL {
delete(h.logins, k)
}
}
}
func (h *WechatRobotHandler) ilinkClient(baseURL string) *ilink.Client {
ver := h.config.Version
if ver == "" {
ver = "1.0.0"
}
ver = strings.TrimPrefix(strings.TrimSpace(ver), "v")
ver = strings.TrimPrefix(ver, "V")
wc := h.config.Robots.Wechat
return ilink.NewClient(baseURL, wc.BotToken, wc.BotAgent, ilink.BuildClientVersion(ver))
}
// HandleWechatQRCode POST /api/robot/wechat/qrcode — 生成绑定二维码
func (h *WechatRobotHandler) HandleWechatQRCode(c *gin.Context) {
h.mu.Lock()
h.purgeExpiredLogins()
h.mu.Unlock()
var req struct {
BotType string `json:"bot_type"`
}
_ = c.ShouldBindJSON(&req)
botType := req.BotType
if botType == "" {
botType = h.config.Robots.Wechat.BotType
}
if botType == "" {
botType = ilink.DefaultBotType
}
baseURL := h.config.Robots.Wechat.BaseURL
if baseURL == "" {
baseURL = ilink.DefaultBaseURL
}
var localTokens []string
if t := h.config.Robots.Wechat.BotToken; t != "" {
localTokens = []string{t}
}
client := h.ilinkClient(baseURL)
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
qr, err := client.GetBotQRCode(ctx, botType, localTokens)
if err != nil {
h.logger.Warn("获取微信二维码失败", zap.Error(err))
c.JSON(http.StatusBadGateway, gin.H{"error": "获取二维码失败: " + err.Error()})
return
}
if qr.QRCode == "" || qr.QRCodeImgContent == "" {
c.JSON(http.StatusBadGateway, gin.H{"error": "微信服务器未返回有效二维码"})
return
}
sessionKey := uuid.New().String()
h.mu.Lock()
h.logins[sessionKey] = &wechatLoginSession{
QRCode: qr.QRCode,
QRCodeImgURL: qr.QRCodeImgContent,
CurrentBaseURL: baseURL,
StartedAt: time.Now(),
}
h.mu.Unlock()
resp := gin.H{
"session_key": sessionKey,
"qrcode": qr.QRCode,
"qrcode_open_url": qr.QRCodeImgContent,
"message": "请使用微信扫描二维码并确认绑定",
}
if dataURL, err := ilink.QRCodeDataURL(qr.QRCodeImgContent, 256); err != nil {
h.logger.Warn("生成二维码图片失败", zap.Error(err))
} else {
resp["qrcode_image_data_url"] = dataURL
}
c.JSON(http.StatusOK, resp)
}
// HandleWechatQRCodeStatus GET /api/robot/wechat/qrcode/status — 轮询扫码状态
func (h *WechatRobotHandler) HandleWechatQRCodeStatus(c *gin.Context) {
sessionKey := c.Query("session_key")
verifyCode := c.Query("verify_code")
if sessionKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 session_key"})
return
}
h.mu.Lock()
sess, ok := h.logins[sessionKey]
h.mu.Unlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "登录会话不存在或已过期,请重新生成二维码"})
return
}
if time.Since(sess.StartedAt) > wechatLoginTTL {
h.mu.Lock()
delete(h.logins, sessionKey)
h.mu.Unlock()
c.JSON(http.StatusGone, gin.H{"error": "二维码已过期,请重新生成"})
return
}
baseURL := sess.CurrentBaseURL
if baseURL == "" {
baseURL = ilink.DefaultBaseURL
}
vc := verifyCode
if vc == "" {
vc = sess.PendingVerify
}
client := h.ilinkClient(baseURL)
ctx, cancel := context.WithTimeout(c.Request.Context(), 40*time.Second)
defer cancel()
st, err := client.GetQRCodeStatus(ctx, sess.QRCode, vc)
if err != nil {
h.logger.Warn("轮询微信二维码状态失败", zap.Error(err))
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
switch st.Status {
case "wait", "scaned":
c.JSON(http.StatusOK, gin.H{"status": st.Status})
return
case "need_verifycode":
c.JSON(http.StatusOK, gin.H{
"status": st.Status,
"message": "请在手机微信查看配对数字,并在下方输入",
})
return
case "scaned_but_redirect":
if st.RedirectHost != "" {
h.mu.Lock()
if s, ok := h.logins[sessionKey]; ok {
s.CurrentBaseURL = "https://" + st.RedirectHost
}
h.mu.Unlock()
}
c.JSON(http.StatusOK, gin.H{"status": st.Status})
return
case "binded_redirect":
h.mu.Lock()
delete(h.logins, sessionKey)
h.mu.Unlock()
c.JSON(http.StatusOK, gin.H{
"status": st.Status,
"already_connected": true,
"message": "该微信已绑定过,无需重复绑定",
})
return
case "confirmed":
if st.BotToken == "" || st.ILinkBotID == "" {
c.JSON(http.StatusBadGateway, gin.H{"error": "绑定确认成功但缺少 bot_token"})
return
}
saveBase := st.BaseURL
if saveBase == "" {
saveBase = baseURL
}
wc := h.config.Robots.Wechat
wc.Enabled = true
wc.BotToken = st.BotToken
wc.ILinkBotID = st.ILinkBotID
wc.ILinkUserID = st.ILinkUserID
wc.BaseURL = saveBase
if wc.BotType == "" {
wc.BotType = ilink.DefaultBotType
}
if wc.BotAgent == "" {
wc.BotAgent = ilink.DefaultBotAgent
}
if h.configSaver != nil {
if err := h.configSaver.ApplyWechatRobotBinding(wc); err != nil {
h.logger.Warn("保存微信机器人配置失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()})
return
}
} else {
h.config.Robots.Wechat = wc
}
h.mu.Lock()
delete(h.logins, sessionKey)
h.mu.Unlock()
c.JSON(http.StatusOK, gin.H{
"status": "confirmed",
"message": "绑定成功,微信机器人已启用",
"ilink_bot_id": st.ILinkBotID,
"ilink_user_id": st.ILinkUserID,
})
return
default:
c.JSON(http.StatusOK, gin.H{"status": st.Status})
}
}
// HandleWechatVerifyCode POST /api/robot/wechat/qrcode/verify — 提交手机配对数字
func (h *WechatRobotHandler) HandleWechatVerifyCode(c *gin.Context) {
var req struct {
SessionKey string `json:"session_key"`
VerifyCode string `json:"verify_code"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.SessionKey == "" || req.VerifyCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "需要 session_key 与 verify_code"})
return
}
h.mu.Lock()
sess, ok := h.logins[req.SessionKey]
if ok {
sess.PendingVerify = req.VerifyCode
}
h.mu.Unlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "登录会话不存在或已过期"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已提交配对码,请继续等待绑定"})
}
// HandleWechatStatus GET /api/robot/wechat/status — 当前绑定状态(供前端展示)
func (h *WechatRobotHandler) HandleWechatStatus(c *gin.Context) {
wc := h.config.Robots.Wechat
bound := wc.BotToken != "" && wc.ILinkBotID != ""
c.JSON(http.StatusOK, gin.H{
"enabled": wc.Enabled,
"bound": bound,
"ilink_bot_id": wc.ILinkBotID,
"ilink_user_id": wc.ILinkUserID,
"base_url": wc.BaseURL,
})
}
+38 -1
View File
@@ -44,6 +44,10 @@ type Server struct {
runningCancels map[string]context.CancelFunc runningCancels map[string]context.CancelFunc
runningCancelsMu sync.Mutex runningCancelsMu sync.Mutex
abortUserNotes map[string]string // 监控页终止时附带的用户说明,与 executionID 对应 abortUserNotes map[string]string // 监控页终止时附带的用户说明,与 executionID 对应
// httpToolTimeoutMinutes 同步 agent.tool_timeout_minutes,用于 POST /api/mcp 的 tools/call(不经 Agent 包装的路径)。
// nil 表示未配置,沿用默认 30 分钟;指向 0 表示不限制;>0 为分钟数。
httpToolTimeoutMinutes *int
httpToolTimeoutMu sync.RWMutex
} }
type sseClient struct { type sseClient struct {
@@ -90,6 +94,39 @@ func NewServerWithStorage(logger *zap.Logger, storage MonitorStorage) *Server {
return s return s
} }
// ConfigureHTTPToolCallTimeoutFromAgentMinutes 将 agent.tool_timeout_minutes 同步到经 HTTP POST /api/mcp 触发的 tools/call。
// minutes<=0 表示不设置硬性截止时间(与配置「0 不限制」一致);minutes>0 为该次调用的最长等待时间。
// 未调用前对 tools/call 使用默认 30 分钟(与历史硬编码一致)。
func (s *Server) ConfigureHTTPToolCallTimeoutFromAgentMinutes(minutes int) {
if s == nil {
return
}
v := minutes
if v < 0 {
v = 0
}
s.httpToolTimeoutMu.Lock()
defer s.httpToolTimeoutMu.Unlock()
s.httpToolTimeoutMinutes = &v
}
func (s *Server) effectiveHTTPToolCallDeadline() (context.Context, context.CancelFunc) {
const defaultDur = 30 * time.Minute
if s == nil {
return context.WithTimeout(context.Background(), defaultDur)
}
s.httpToolTimeoutMu.RLock()
mPtr := s.httpToolTimeoutMinutes
s.httpToolTimeoutMu.RUnlock()
if mPtr == nil {
return context.WithTimeout(context.Background(), defaultDur)
}
if *mPtr <= 0 {
return context.WithCancel(context.Background())
}
return context.WithTimeout(context.Background(), time.Duration(*mPtr)*time.Minute)
}
// RegisterTool 注册工具 // RegisterTool 注册工具
func (s *Server) RegisterTool(tool Tool, handler ToolHandler) { func (s *Server) RegisterTool(tool Tool, handler ToolHandler) {
s.mu.Lock() s.mu.Lock()
@@ -457,7 +494,7 @@ func (s *Server) handleCallTool(msg *Message) *Message {
} }
} }
baseCtx, timeoutCancel := context.WithTimeout(context.Background(), 30*time.Minute) baseCtx, timeoutCancel := s.effectiveHTTPToolCallDeadline()
defer timeoutCancel() defer timeoutCancel()
execCtx, runCancel := context.WithCancel(baseCtx) execCtx, runCancel := context.WithCancel(baseCtx)
s.registerRunningCancel(executionID, runCancel) s.registerRunningCancel(executionID, runCancel)
+96 -37
View File
@@ -14,7 +14,9 @@ import (
"unicode/utf8" "unicode/utf8"
"cyberstrike-ai/internal/agent" "cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp" "cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/einoobserve"
"cyberstrike-ai/internal/openai" "cyberstrike-ai/internal/openai"
"github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/adk"
@@ -95,6 +97,9 @@ type einoADKRunLoopArgs struct {
// ModelFacingTrace 可选:由各 ChatModelAgent Handlers 链末尾中间件写入「即将送入模型」的消息快照; // ModelFacingTrace 可选:由各 ChatModelAgent Handlers 链末尾中间件写入「即将送入模型」的消息快照;
// 非空时优先用于 LastAgentTraceInput 序列化,使续跑与 summarization/reduction 后的上下文一致。 // 非空时优先用于 LastAgentTraceInput 序列化,使续跑与 summarization/reduction 后的上下文一致。
ModelFacingTrace *modelFacingTraceHolder ModelFacingTrace *modelFacingTraceHolder
// EinoCallbacks 可选:为 ADK Runner 注入 eino [callbacks] 全链路观测(见 internal/einoobserve)。
EinoCallbacks *config.MultiAgentEinoCallbacksConfig
} }
func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) { func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs []adk.Message) (*RunResult, error) {
@@ -172,6 +177,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
var einoMainRound int var einoMainRound int
var einoLastAgent string var einoLastAgent string
subAgentToolStep := make(map[string]int) subAgentToolStep := make(map[string]int)
// mainAgentToolStep:主代理每次工具调用批次递增,供 UI 显示「第 N 轮」(单代理无子代理切换时原先会一直停在第 1 轮)。
mainAgentToolStep := make(map[string]int)
pendingByID := make(map[string]toolCallPendingInfo) pendingByID := make(map[string]toolCallPendingInfo)
pendingQueueByAgent := make(map[string][]string) pendingQueueByAgent := make(map[string][]string)
markPending := func(tc toolCallPendingInfo) { markPending := func(tc toolCallPendingInfo) {
@@ -262,7 +269,16 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
isErr := !success || invokeErr != nil isErr := !success || invokeErr != nil
body := content body := content
if invokeErr != nil { if invokeErr != nil {
body = invokeErr.Error() // 保留已流式累计的 stdout(如 execute 超时前的一半输出),避免 tool_result 只剩错误串、模型与 UI 丢失上下文
tail := friendlyEinoExecuteInvokeTail(invokeErr)
// execute 流式包装可能已把超时句写入 content(供 ADK tool 与流式 delta);勿重复拼接
if tail != "" && strings.Contains(content, tail) {
body = content
} else if strings.TrimSpace(content) != "" {
body = strings.TrimRight(content, "\n") + "\n\n" + tail
} else {
body = tail
}
isErr = true isErr = true
} }
recordPendingExecuteStdoutDup(toolName, body, isErr) recordPendingExecuteStdoutDup(toolName, body, isErr)
@@ -289,6 +305,16 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
}) })
} }
if args.EinoCallbacks != nil {
ctx = einoobserve.AttachAgentRunCallbacks(ctx, args.EinoCallbacks, einoobserve.Params{
Logger: logger,
Progress: progress,
ConversationID: conversationID,
OrchMode: orchMode,
OrchestratorName: orchestratorName,
})
}
runnerCfg := adk.RunnerConfig{ runnerCfg := adk.RunnerConfig{
Agent: da, Agent: da,
EnableStreaming: true, EnableStreaming: true,
@@ -505,8 +531,10 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
} }
if streamsMainAssistant(ev.AgentName) { if streamsMainAssistant(ev.AgentName) {
mainIterKey := einoMainIterationKey(iterEinoAgent, orchestratorName)
if einoMainRound == 0 { if einoMainRound == 0 {
einoMainRound = 1 einoMainRound = 1
mainAgentToolStep[mainIterKey] = 1
progress("iteration", "", map[string]interface{}{ progress("iteration", "", map[string]interface{}{
"iteration": 1, "iteration": 1,
"einoScope": "main", "einoScope": "main",
@@ -516,17 +544,26 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"conversationId": conversationID, "conversationId": conversationID,
"source": "eino", "source": "eino",
}) })
} else if einoLastAgent != "" && !streamsMainAssistant(einoLastAgent) { } else if einoLastAgent != "" {
einoMainRound++ needBump := false
progress("iteration", "", map[string]interface{}{ if !streamsMainAssistant(einoLastAgent) {
"iteration": einoMainRound, needBump = true // 子代理 → 主代理
"einoScope": "main", } else if einoLastAgent != ev.AgentName {
"einoRole": "orchestrator", needBump = true // plan_executeplanner ↔ executor 等主代理切换
"einoAgent": iterEinoAgent, }
"orchestration": orchMode, if needBump {
"conversationId": conversationID, einoMainRound++
"source": "eino", mainAgentToolStep[mainIterKey] = einoMainRound
}) progress("iteration", "", map[string]interface{}{
"iteration": einoMainRound,
"einoScope": "main",
"einoRole": "orchestrator",
"einoAgent": iterEinoAgent,
"orchestration": orchMode,
"conversationId": conversationID,
"source": "eino",
})
}
} }
} }
einoLastAgent = ev.AgentName einoLastAgent = ev.AgentName
@@ -549,6 +586,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
var subAssistantBuf string var subAssistantBuf string
var subReplyStreamID string var subReplyStreamID string
var mainAssistantBuf string var mainAssistantBuf string
// 已通过 response_delta 推到前端的正文(与 monitor.js normalizeStreamingDeltaJs 累积一致)
var mainAssistWireAccum string
var mainAssistDupTarget string // 非空表示本段主助手流需缓冲至 EOF,与 execute 输出比对去重 var mainAssistDupTarget string // 非空表示本段主助手流需缓冲至 EOF,与 execute 输出比对去重
var reasoningBuf string var reasoningBuf string
var prevReasoningDisplay string // UI 用:剥离 Claude 内部 signature 尾缀后的累计展示 var prevReasoningDisplay string // UI 用:剥离 Claude 内部 signature 尾缀后的累计展示
@@ -618,9 +657,9 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"orchestration": orchMode, "orchestration": orchMode,
}) })
} }
progress("reasoning_chain_stream_delta", displayDelta, map[string]interface{}{ progress("reasoning_chain_stream_delta", displayDelta, openai.WithSSEAccumulated(map[string]interface{}{
"streamId": reasoningStreamID, "streamId": reasoningStreamID,
}) }, fullDisplay))
} }
} }
} }
@@ -650,13 +689,14 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
}) })
streamHeaderSent = true streamHeaderSent = true
} }
progress("response_delta", contentDelta, map[string]interface{}{ progress("response_delta", contentDelta, openai.WithSSEAccumulated(map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(), "mcpExecutionIds": snapshotMCPIDs(),
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
}) }, mainAssistantBuf))
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
} }
} }
} else if !streamsMainAssistant(ev.AgentName) { } else if !streamsMainAssistant(ev.AgentName) {
@@ -674,10 +714,10 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"source": "eino", "source": "eino",
}) })
} }
progress("eino_agent_reply_stream_delta", subDelta, map[string]interface{}{ progress("eino_agent_reply_stream_delta", subDelta, openai.WithSSEAccumulated(map[string]interface{}{
"streamId": subReplyStreamID, "streamId": subReplyStreamID,
"conversationId": conversationID, "conversationId": conversationID,
}) }, subAssistantBuf))
} }
} }
} }
@@ -702,21 +742,29 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
} }
} else if s != "" { } else if s != "" {
if progress != nil { if progress != nil {
progress("response_start", "", map[string]interface{}{ // 仅用 TrimSpace 与 execute 比对;推到 UI 的必须是 mainAssistantBuf
"conversationId": conversationID, // 否则尾部空白/换行与已流式前缀不一致时,前端 normalize 会走拼接路径造成叠字。
"mcpExecutionIds": snapshotMCPIDs(), _, eofTail := normalizeStreamingDelta(mainAssistWireAccum, mainAssistantBuf)
"messageGeneratedBy": "eino:" + ev.AgentName, if eofTail != "" {
"einoRole": "orchestrator", if !streamHeaderSent {
"einoAgent": ev.AgentName, progress("response_start", "", map[string]interface{}{
"orchestration": orchMode, "conversationId": conversationID,
}) "mcpExecutionIds": snapshotMCPIDs(),
progress("response_delta", s, map[string]interface{}{ "messageGeneratedBy": "eino:" + ev.AgentName,
"conversationId": conversationID, "einoRole": "orchestrator",
"mcpExecutionIds": snapshotMCPIDs(), "einoAgent": ev.AgentName,
"einoRole": "orchestrator", "orchestration": orchMode,
"einoAgent": ev.AgentName, })
"orchestration": orchMode, }
}) progress("response_delta", eofTail, openai.WithSSEAccumulated(map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
"einoRole": "orchestrator",
"einoAgent": ev.AgentName,
"orchestration": orchMode,
}, mainAssistantBuf))
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
}
} }
lastAssistant = s lastAssistant = s
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil)) runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
@@ -756,7 +804,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 { if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
lastToolChunk = mergeMessageToolCalls(&schema.Message{ToolCalls: merged}) lastToolChunk = mergeMessageToolCalls(&schema.Message{ToolCalls: merged})
} }
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending) tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, orchMode, progress, toolEmitSeen, subAgentToolStep, mainAgentToolStep, markPending)
// 流式路径此前只把 tool_calls 推给进度 UI,未写入 runAccumulatedMsgs;落库后 loadHistory→RepairOrphan 会删掉全部 tool 结果,表现为「续跑/下轮失忆」。 // 流式路径此前只把 tool_calls 推给进度 UI,未写入 runAccumulatedMsgs;落库后 loadHistory→RepairOrphan 会删掉全部 tool 结果,表现为「续跑/下轮失忆」。
if lastToolChunk != nil && len(lastToolChunk.ToolCalls) > 0 { if lastToolChunk != nil && len(lastToolChunk.ToolCalls) > 0 {
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage("", lastToolChunk.ToolCalls)) runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage("", lastToolChunk.ToolCalls))
@@ -785,7 +833,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
continue continue
} }
runAccumulatedMsgs = append(runAccumulatedMsgs, msg) runAccumulatedMsgs = append(runAccumulatedMsgs, msg)
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending) tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, orchMode, progress, toolEmitSeen, subAgentToolStep, mainAgentToolStep, markPending)
if mv.Role == schema.Assistant { if mv.Role == schema.Assistant {
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" { if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
@@ -824,13 +872,13 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
}) })
progress("response_delta", body, map[string]interface{}{ progress("response_delta", body, openai.WithSSEAccumulated(map[string]interface{}{
"conversationId": conversationID, "conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(), "mcpExecutionIds": snapshotMCPIDs(),
"einoRole": "orchestrator", "einoRole": "orchestrator",
"einoAgent": ev.AgentName, "einoAgent": ev.AgentName,
"orchestration": orchMode, "orchestration": orchMode,
}) }, body))
} }
lastAssistant = body lastAssistant = body
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") { if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
@@ -933,6 +981,17 @@ func einoPartialRunLastOutputHint() string {
"[Run ended abnormally; continue from the trace above without repeating completed steps.]" "[Run ended abnormally; continue from the trace above without repeating completed steps.]"
} }
// friendlyEinoExecuteInvokeTail 将 Eino execute 等非 MCP 路径的结尾错误转成简短提示;其它情况保留原 error 文本。
func friendlyEinoExecuteInvokeTail(invokeErr error) string {
if invokeErr == nil {
return ""
}
if errors.Is(invokeErr, context.DeadlineExceeded) {
return einoExecuteTimeoutUserHint()
}
return "[执行未正常结束] " + invokeErr.Error()
}
func buildEinoRunResultFromAccumulated( func buildEinoRunResultFromAccumulated(
orchMode string, orchMode string,
runAccumulatedMsgs []adk.Message, runAccumulatedMsgs []adk.Message,
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"strings" "strings"
"time"
"cyberstrike-ai/internal/einomcp" "cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/security" "cyberstrike-ai/internal/security"
@@ -15,6 +16,24 @@ import (
"github.com/cloudwego/eino/schema" "github.com/cloudwego/eino/schema"
) )
// prependPythonUnbufferedEnv 为 /bin/sh -c 注入 PYTHONUNBUFFERED=1。
// eino-ext local 对流式 stdout 使用 bufio 按「行」推送;python3 写管道时默认块缓冲,print 长期留在用户态缓冲,
// 管道里收不到换行,表现为长时间无输出直至超时或退出。若命令里已出现 PYTHONUNBUFFERED 则不再覆盖。
func prependPythonUnbufferedEnv(shellCommand string) string {
if strings.TrimSpace(shellCommand) == "" {
return shellCommand
}
if strings.Contains(strings.ToUpper(shellCommand), "PYTHONUNBUFFERED") {
return shellCommand
}
return "export PYTHONUNBUFFERED=1\n" + shellCommand
}
// einoExecuteTimeoutUserHint 与写入 ADK 工具消息(模型可见)及 SSE tool_result 尾标一致。
func einoExecuteTimeoutUserHint() string {
return "已超时终止 · Timed out"
}
// einoStreamingShellWrap 包装 Eino filesystem 使用的 StreamingShellcloudwego eino-ext local.Local)。 // einoStreamingShellWrap 包装 Eino filesystem 使用的 StreamingShellcloudwego eino-ext local.Local)。
// 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连, // 官方 execute 工具默认走 ExecuteStreaming 且不设 RunInBackendGround;末尾带 & 时子进程仍与管道相连,
// streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。 // streamStdout 按行读取会在无换行输出时长时间阻塞(与 MCP 工具 exec 的独立实现不同)。
@@ -22,10 +41,17 @@ import (
// //
// 使用 Pipe 将内层流转发给调用方:在 inner EOF 后、关闭 Pipe 前同步调用 ToolInvokeNotify.Fire // 使用 Pipe 将内层流转发给调用方:在 inner EOF 后、关闭 Pipe 前同步调用 ToolInvokeNotify.Fire
// 保证 run loop 在模型开始下一轮输出前已记录 execute 结果(用于 UI 与「重复助手复述」去重)。 // 保证 run loop 在模型开始下一轮输出前已记录 execute 结果(用于 UI 与「重复助手复述」去重)。
//
// 若 inner 在校验阶段直接返回 error(未建立 reader),不会进入下方 goroutine,也必须 Fire
// 否则 pending tool_call 要等整轮 run 结束才被 force-close,与已展示的助手/工具软错误文案不同步。
type einoStreamingShellWrap struct { type einoStreamingShellWrap struct {
inner filesystem.StreamingShell inner filesystem.StreamingShell
invokeNotify *einomcp.ToolInvokeNotifyHolder invokeNotify *einomcp.ToolInvokeNotifyHolder
einoAgentName string einoAgentName string
// outputChunk 可选;非 nil 时在收到内层 ExecuteResponse 片段时推送,与 MCP 工具的 tool_result_delta 一致(需有效 toolCallId)。
outputChunk func(toolName, toolCallID, chunk string)
// toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。
toolTimeoutMinutes int
// recordMonitor 在 execute 流结束后写入 tool_executions 并 recorder(executionId),使「渗透测试详情」与常规 MCP 一致。 // recordMonitor 在 execute 流结束后写入 tool_executions 并 recorder(executionId),使「渗透测试详情」与常规 MCP 一致。
recordMonitor func(command, stdout string, success bool, invokeErr error) recordMonitor func(command, stdout string, success bool, invokeErr error)
} }
@@ -38,24 +64,47 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
return w.inner.ExecuteStreaming(ctx, nil) return w.inner.ExecuteStreaming(ctx, nil)
} }
req := *input req := *input
cmd := strings.TrimSpace(req.Command) userCmd := strings.TrimSpace(req.Command)
if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround { if security.IsBackgroundShellCommand(req.Command) && !req.RunInBackendGround {
req.RunInBackendGround = true req.RunInBackendGround = true
} }
sr, err := w.inner.ExecuteStreaming(ctx, &req) req.Command = prependPythonUnbufferedEnv(req.Command)
tid := strings.TrimSpace(compose.GetToolCallID(ctx))
agentTag := strings.TrimSpace(w.einoAgentName)
execCtx := ctx
var execCancel context.CancelFunc
if w.toolTimeoutMinutes > 0 {
execCtx, execCancel = context.WithTimeout(ctx, time.Duration(w.toolTimeoutMinutes)*time.Minute)
}
sr, err := w.inner.ExecuteStreaming(execCtx, &req)
if err != nil { if err != nil {
if execCancel != nil {
execCancel()
}
if w.recordMonitor != nil {
w.recordMonitor(userCmd, "", false, err)
}
if w.invokeNotify != nil && tid != "" {
w.invokeNotify.Fire(tid, "execute", agentTag, false, "", err)
}
return nil, err return nil, err
} }
tid := strings.TrimSpace(compose.GetToolCallID(ctx))
if sr == nil || w.invokeNotify == nil || tid == "" { if sr == nil || w.invokeNotify == nil || tid == "" {
if execCancel != nil {
execCancel()
}
return sr, nil return sr, nil
} }
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](32) outR, outW := schema.Pipe[*filesystem.ExecuteResponse](32)
agentTag := strings.TrimSpace(w.einoAgentName)
go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string) { go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string, cancel context.CancelFunc, tctx context.Context) {
defer inner.Close() defer inner.Close()
if cancel != nil {
defer cancel()
}
var sb strings.Builder var sb strings.Builder
const maxCapture = 16 * 1024 const maxCapture = 16 * 1024
@@ -80,12 +129,18 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
hasExitCode = true hasExitCode = true
exitCode = *resp.ExitCode exitCode = *resp.ExitCode
} }
var appended string
if remain := maxCapture - sb.Len(); remain > 0 { if remain := maxCapture - sb.Len(); remain > 0 {
out := resp.Output out := resp.Output
if len(out) > remain { if len(out) > remain {
out = out[:remain] out = out[:remain]
} }
sb.WriteString(out) sb.WriteString(out)
appended = out
}
// 仅推送写入 sb 的片段,与末尾 Fire/recordMonitor 的截断累计一致,避免最终 tool_result 短于已展示增量。
if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
w.outputChunk("execute", tid, appended)
} }
if outW.Send(resp, nil) { if outW.Send(resp, nil) {
success = false success = false
@@ -99,12 +154,33 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
success = false success = false
invokeErr = fmt.Errorf("execute exited with code %d", exitCode) invokeErr = fmt.Errorf("execute exited with code %d", exitCode)
} }
// WithTimeout 触发后,子进程常被信号结束,local 侧多报 exit -1 / canceled,错误链里不一定带 DeadlineExceeded。
// 用执行所用 ctx 归一化,便于 UI 展示「超时」而非含糊的 -1。
if tctx != nil && errors.Is(tctx.Err(), context.DeadlineExceeded) {
success = false
invokeErr = context.DeadlineExceeded
}
// ADK 从本 Pipe 拼出 tool 消息正文;仅 Notify 尾标不会进入模型上下文。超时句写入流,与 UI 一致。
if invokeErr != nil && errors.Is(invokeErr, context.DeadlineExceeded) {
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
_ = outW.Send(&filesystem.ExecuteResponse{Output: hint}, nil)
if w.outputChunk != nil && tid != "" {
w.outputChunk("execute", tid, hint)
}
if remain := maxCapture - sb.Len(); remain > 0 {
h := hint
if len(h) > remain {
h = h[:remain]
}
sb.WriteString(h)
}
}
if w.recordMonitor != nil { if w.recordMonitor != nil {
w.recordMonitor(command, sb.String(), success, invokeErr) w.recordMonitor(command, sb.String(), success, invokeErr)
} }
w.invokeNotify.Fire(tid, "execute", agentTag, success, sb.String(), invokeErr) w.invokeNotify.Fire(tid, "execute", agentTag, success, sb.String(), invokeErr)
outW.Close() outW.Close()
}(sr, cmd) }(sr, userCmd, execCancel, execCtx)
return outR, nil return outR, nil
} }
+11 -8
View File
@@ -161,6 +161,8 @@ func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddl
} }
// prependEinoMiddlewares returns handlers to prepend (outermost first) and optionally replaces tools when tool_search is used. // prependEinoMiddlewares returns handlers to prepend (outermost first) and optionally replaces tools when tool_search is used.
// toolSearchActive is true when the toolsearch middleware was mounted (dynamic tools split off); callers should pass this to
// injectToolNamesOnlyInstruction — tool_search is not part of the pre-middleware tools list, so name-scanning alone cannot detect it.
func prependEinoMiddlewares( func prependEinoMiddlewares(
ctx context.Context, ctx context.Context,
mw *config.MultiAgentEinoMiddlewareConfig, mw *config.MultiAgentEinoMiddlewareConfig,
@@ -170,16 +172,16 @@ func prependEinoMiddlewares(
skillsRoot string, skillsRoot string,
conversationID string, conversationID string,
logger *zap.Logger, logger *zap.Logger,
) (outTools []tool.BaseTool, extraHandlers []adk.ChatModelAgentMiddleware, err error) { ) (outTools []tool.BaseTool, extraHandlers []adk.ChatModelAgentMiddleware, toolSearchActive bool, err error) {
if mw == nil { if mw == nil {
return tools, nil, nil return tools, nil, false, nil
} }
outTools = tools outTools = tools
if mw.PatchToolCallsEffective() { if mw.PatchToolCallsEffective() {
patchMW, perr := patchtoolcalls.New(ctx, &patchtoolcalls.Config{}) patchMW, perr := patchtoolcalls.New(ctx, &patchtoolcalls.Config{})
if perr != nil { if perr != nil {
return nil, nil, fmt.Errorf("patchtoolcalls: %w", perr) return nil, nil, false, fmt.Errorf("patchtoolcalls: %w", perr)
} }
extraHandlers = append(extraHandlers, patchMW) extraHandlers = append(extraHandlers, patchMW)
} }
@@ -190,7 +192,7 @@ func prependEinoMiddlewares(
} else { } else {
redMW, rerr := buildReductionMiddleware(ctx, *mw, conversationID, einoLoc, logger) redMW, rerr := buildReductionMiddleware(ctx, *mw, conversationID, einoLoc, logger)
if rerr != nil { if rerr != nil {
return nil, nil, rerr return nil, nil, false, rerr
} }
extraHandlers = append(extraHandlers, redMW) extraHandlers = append(extraHandlers, redMW)
} }
@@ -209,10 +211,11 @@ func prependEinoMiddlewares(
if split && len(dynamic) > 0 { if split && len(dynamic) > 0 {
ts, terr := toolsearch.New(ctx, &toolsearch.Config{DynamicTools: dynamic}) ts, terr := toolsearch.New(ctx, &toolsearch.Config{DynamicTools: dynamic})
if terr != nil { if terr != nil {
return nil, nil, fmt.Errorf("toolsearch: %w", terr) return nil, nil, false, fmt.Errorf("toolsearch: %w", terr)
} }
extraHandlers = append(extraHandlers, ts) extraHandlers = append(extraHandlers, ts)
outTools = static outTools = static
toolSearchActive = true
if logger != nil { if logger != nil {
logger.Info("eino middleware: tool_search enabled", logger.Info("eino middleware: tool_search enabled",
zap.Int("static_tools", len(static)), zap.Int("static_tools", len(static)),
@@ -233,12 +236,12 @@ func prependEinoMiddlewares(
} }
baseDir := filepath.Join(skillsRoot, rel, sanitizeEinoPathSegment(conversationID)) baseDir := filepath.Join(skillsRoot, rel, sanitizeEinoPathSegment(conversationID))
if mk := os.MkdirAll(baseDir, 0o755); mk != nil { if mk := os.MkdirAll(baseDir, 0o755); mk != nil {
return nil, nil, fmt.Errorf("plantask mkdir: %w", mk) return nil, nil, toolSearchActive, fmt.Errorf("plantask mkdir: %w", mk)
} }
ptBE := &localPlantaskBackend{Local: einoLoc} ptBE := &localPlantaskBackend{Local: einoLoc}
pt, perr := plantask.New(ctx, &plantask.Config{Backend: ptBE, BaseDir: baseDir}) pt, perr := plantask.New(ctx, &plantask.Config{Backend: ptBE, BaseDir: baseDir})
if perr != nil { if perr != nil {
return nil, nil, fmt.Errorf("plantask: %w", perr) return nil, nil, toolSearchActive, fmt.Errorf("plantask: %w", perr)
} }
extraHandlers = append(extraHandlers, pt) extraHandlers = append(extraHandlers, pt)
if logger != nil { if logger != nil {
@@ -247,7 +250,7 @@ func prependEinoMiddlewares(
} }
} }
return outTools, extraHandlers, nil return outTools, extraHandlers, toolSearchActive, nil
} }
func deepExtrasFromConfig(ma *config.MultiAgentConfig) (outputKey string, retry *adk.ModelRetryConfig, taskDesc func(context.Context, []adk.Agent) (string, error)) { func deepExtrasFromConfig(ma *config.MultiAgentConfig) (outputKey string, retry *adk.ModelRetryConfig, taskDesc func(context.Context, []adk.Agent) (string, error)) {
+6 -12
View File
@@ -96,7 +96,7 @@ func RunEinoSingleChatModelAgent(
return nil, err return nil, err
} }
mainToolsForCfg, mainOrchestratorPre, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger) mainToolsForCfg, mainOrchestratorPre, singleToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("eino single eino 中间件: %w", err) return nil, fmt.Errorf("eino single eino 中间件: %w", err)
} }
@@ -143,7 +143,7 @@ func RunEinoSingleChatModelAgent(
} }
if einoSkillMW != nil { if einoSkillMW != nil {
if einoFSTools && einoLoc != nil { if einoFSTools && einoLoc != nil {
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecMonitor) fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
if fsErr != nil { if fsErr != nil {
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr) return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
} }
@@ -173,27 +173,20 @@ func RunEinoSingleChatModelAgent(
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
ToolCallMiddlewares: []compose.ToolMiddleware{ ToolCallMiddlewares: []compose.ToolMiddleware{
hitlToolCallMiddleware(), hitlToolCallMiddleware(),
{Invokable: softRecoveryToolCallMiddleware()}, softRecoveryToolMiddleware(),
}, },
}, },
EmitInternalEvents: true, EmitInternalEvents: true,
} }
ins := injectToolNamesOnlyInstruction(ctx, ag.EinoSingleAgentSystemInstruction(), mainTools) ins := injectToolNamesOnlyInstruction(ctx, ag.EinoSingleAgentSystemInstruction(), mainTools, singleToolSearchActive)
if logger != nil { if logger != nil {
names := collectToolNames(ctx, mainTools) names := collectToolNames(ctx, mainTools)
mountedNames := collectToolNames(ctx, mainToolsForCfg) mountedNames := collectToolNames(ctx, mainToolsForCfg)
hasToolSearch := false
for _, n := range names {
if strings.EqualFold(strings.TrimSpace(n), "tool_search") {
hasToolSearch = true
break
}
}
logger.Info("eino tool-name injection", logger.Info("eino tool-name injection",
zap.String("scope", "eino_single"), zap.String("scope", "eino_single"),
zap.Int("tool_names", len(names)), zap.Int("tool_names", len(names)),
zap.Int("mounted_tool_names", len(mountedNames)), zap.Int("mounted_tool_names", len(mountedNames)),
zap.Bool("has_tool_search", hasToolSearch), zap.Bool("tool_search_middleware", singleToolSearchActive),
) )
} }
@@ -247,6 +240,7 @@ func RunEinoSingleChatModelAgent(
ToolInvokeNotify: toolInvokeNotify, ToolInvokeNotify: toolInvokeNotify,
DA: chatAgent, DA: chatAgent,
ModelFacingTrace: modelFacingTrace, ModelFacingTrace: modelFacingTrace,
EinoCallbacks: &ma.EinoCallbacks,
EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " + EmptyResponseMessage: "(Eino ADK single-agent session completed but no assistant text was captured. Check process details or logs.) " +
"Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)", "Eino ADK 单代理会话已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
}, baseMsgs) }, baseMsgs)
+16 -4
View File
@@ -82,6 +82,8 @@ func subAgentFilesystemMiddleware(
invokeNotify *einomcp.ToolInvokeNotifyHolder, invokeNotify *einomcp.ToolInvokeNotifyHolder,
einoAgentName string, einoAgentName string,
recordMonitor func(command, stdout string, success bool, invokeErr error), recordMonitor func(command, stdout string, success bool, invokeErr error),
toolTimeoutMinutes int,
outputChunk func(toolName, toolCallID, chunk string),
) (adk.ChatModelAgentMiddleware, error) { ) (adk.ChatModelAgentMiddleware, error) {
if loc == nil { if loc == nil {
return nil, nil return nil, nil
@@ -89,10 +91,20 @@ func subAgentFilesystemMiddleware(
return filesystem.New(ctx, &filesystem.MiddlewareConfig{ return filesystem.New(ctx, &filesystem.MiddlewareConfig{
Backend: loc, Backend: loc,
StreamingShell: &einoStreamingShellWrap{ StreamingShell: &einoStreamingShellWrap{
inner: loc, inner: loc,
invokeNotify: invokeNotify, invokeNotify: invokeNotify,
einoAgentName: strings.TrimSpace(einoAgentName), einoAgentName: strings.TrimSpace(einoAgentName),
recordMonitor: recordMonitor, outputChunk: outputChunk,
recordMonitor: recordMonitor,
toolTimeoutMinutes: toolTimeoutMinutes,
}, },
}) })
} }
// agentToolTimeoutMinutes 返回 agent.tool_timeout_minutes(与 executeToolViaMCP 一致);cfg 为 nil 时 0。
func agentToolTimeoutMinutes(cfg *config.Config) int {
if cfg == nil {
return 0
}
return cfg.Agent.ToolTimeoutMinutes
}
+20 -11
View File
@@ -9,34 +9,43 @@ import (
// injectToolNamesOnlyInstruction prepends a compact tool-name-only section into // injectToolNamesOnlyInstruction prepends a compact tool-name-only section into
// the system instruction so the model can reference current callable names. // the system instruction so the model can reference current callable names.
func injectToolNamesOnlyInstruction(ctx context.Context, instruction string, tools []tool.BaseTool) string { // toolSearchMiddlewareActive must be true when prependEinoMiddlewares mounted toolsearch (dynamic tools); do not infer this
// by scanning tool names — tool_search is injected by middleware and is usually absent from the pre-split tools list.
func injectToolNamesOnlyInstruction(ctx context.Context, instruction string, tools []tool.BaseTool, toolSearchMiddlewareActive bool) string {
names := collectToolNames(ctx, tools) names := collectToolNames(ctx, tools)
if len(names) == 0 { if len(names) == 0 {
return strings.TrimSpace(instruction) return strings.TrimSpace(instruction)
} }
hasToolSearch := false hasToolSearch := toolSearchMiddlewareActive
for _, n := range names { if !hasToolSearch {
if strings.EqualFold(strings.TrimSpace(n), "tool_search") { for _, n := range names {
hasToolSearch = true if strings.EqualFold(strings.TrimSpace(n), "tool_search") {
break hasToolSearch = true
break
}
} }
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString("以下是当前会话中可调用的工具名称列表(仅名称,无参数定义):\n") sb.WriteString("以下是当前会话绑定的工具名称索引(仅名称,无参数 JSON Schema)。\n")
sb.WriteString("说明:若启用了 tool_search,则列表里可能含「非常驻」工具——它们不一定出现在当前轮次下发给模型的工具定义中;在未看到该工具的完整 schema 前,禁止凭名称臆测参数。\n")
for _, name := range names { for _, name := range names {
sb.WriteString("- ") sb.WriteString("- ")
sb.WriteString(name) sb.WriteString(name)
sb.WriteByte('\n') sb.WriteByte('\n')
} }
sb.WriteString("\n使用规则:\n") sb.WriteString("\n使用规则:\n")
sb.WriteString("1) 上仅为名称列表,不含参数定义。\n") sb.WriteString("1) 上仅为名称索引,不含参数定义。禁止猜测参数名、类型、枚举取值或是否必填。\n")
if hasToolSearch { if hasToolSearch {
sb.WriteString("2) 在调用具体工具前,应先使用 tool_search 查看工具详情与参数要求,再发起调用。\n") sb.WriteString("【强制 / 最高优先级】本会话已启用 tool_search(动态工具池)。凡名称索引里出现、但你在「当前请求所附 tools 定义」中看不到其完整参数 schema 的工具,一律必须先调用 tool_search;为省 token 或赶进度而跳过 tool_search、直接调用业务工具,属于明确禁止的错误流程。\n")
sb.WriteString("2) 默认策略:只要对目标工具的参数定义有任何不确定,就先 tool_search;宁可多一次 tool_search,也不要在未见 schema 时盲调业务工具。\n")
sb.WriteString("3) 调用顺序:先 tool_search(唯一必填参数 regex_pattern:按工具名匹配的正则,如子串 nuclei 或 ^exact_tool_name$)→ 在后续轮次确认目标工具已出现在 tools 列表且已阅读其 schema → 再发起对该工具的真实调用。\n")
sb.WriteString("4) tool_search 的返回仅为匹配到的工具名列表;schema 在解锁后的下一轮才会下发。禁止在 schema 未出现时编造 JSON 参数。\n")
sb.WriteString("5) 不要臆造不存在的工具名。\n\n")
} else { } else {
sb.WriteString("2) 调用具体工具前,请先确认该工具的参数要求;不确定时先澄清再调用。\n") sb.WriteString("2) 调用具体工具前,请先确认该工具的参数要求(以当前请求中的工具定义为准);不确定时先澄清再调用。\n")
sb.WriteString("3) 不要臆造不存在的工具名。\n\n")
} }
sb.WriteString("3) 不要臆造不存在的工具名。\n\n")
if s := strings.TrimSpace(instruction); s != "" { if s := strings.TrimSpace(instruction); s != "" {
sb.WriteString(s) sb.WriteString(s)
} }
@@ -0,0 +1,22 @@
package multiagent
import (
"strings"
"testing"
)
// Eino execute 去重分支 EOF flush 须以 mainAssistantBuf 为基准计算 tail
// 若误用 TrimSpace(mainAssistantBuf),会与已推前缀在空白处失配,normalize 走拼接路径叠字。
func TestNormalizeStreamingDelta_eofTailUsesRawBufNotTrim(t *testing.T) {
wireAccum := "phrase "
rawFull := "phrase \n"
_, tail := normalizeStreamingDelta(wireAccum, rawFull)
if want := "\n"; tail != want {
t.Fatalf("tail=%q want %q", tail, want)
}
nextWrong, badTail := normalizeStreamingDelta(wireAccum, strings.TrimSpace(rawFull))
if badTail != "phrase" || nextWrong != "phrase phrase" {
t.Fatalf("trimmed full vs wire prefix mismatch should concat-append; got next=%q badTail=%q", nextWrong, badTail)
}
}
+49 -33
View File
@@ -223,7 +223,7 @@ func RunDeepAgent(
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err) return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
} }
subToolsForCfg, subPre, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWSub, subTools, einoLoc, skillsRoot, conversationID, logger) subToolsForCfg, subPre, subToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWSub, subTools, einoLoc, skillsRoot, conversationID, logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err) return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
} }
@@ -244,7 +244,7 @@ func RunDeepAgent(
} }
if einoSkillMW != nil { if einoSkillMW != nil {
if einoFSTools && einoLoc != nil { if einoFSTools && einoLoc != nil {
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecMonitor) subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
if fsErr != nil { if fsErr != nil {
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr) return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
} }
@@ -260,23 +260,16 @@ func RunDeepAgent(
subHandlers = append(subHandlers, teleMw) subHandlers = append(subHandlers, teleMw)
} }
subInstrFinal := injectToolNamesOnlyInstruction(ctx, instr, subTools) subInstrFinal := injectToolNamesOnlyInstruction(ctx, instr, subTools, subToolSearchActive)
if logger != nil { if logger != nil {
subNames := collectToolNames(ctx, subTools) subNames := collectToolNames(ctx, subTools)
mountedNames := collectToolNames(ctx, subToolsForCfg) mountedNames := collectToolNames(ctx, subToolsForCfg)
hasToolSearch := false
for _, n := range subNames {
if strings.EqualFold(strings.TrimSpace(n), "tool_search") {
hasToolSearch = true
break
}
}
logger.Info("eino tool-name injection", logger.Info("eino tool-name injection",
zap.String("scope", "sub_agent"), zap.String("scope", "sub_agent"),
zap.String("agent", id), zap.String("agent", id),
zap.Int("tool_names", len(subNames)), zap.Int("tool_names", len(subNames)),
zap.Int("mounted_tool_names", len(mountedNames)), zap.Int("mounted_tool_names", len(mountedNames)),
zap.Bool("has_tool_search", hasToolSearch), zap.Bool("tool_search_middleware", subToolSearchActive),
) )
} }
sa, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ sa, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
@@ -290,7 +283,7 @@ func RunDeepAgent(
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
ToolCallMiddlewares: []compose.ToolMiddleware{ ToolCallMiddlewares: []compose.ToolMiddleware{
hitlToolCallMiddleware(), hitlToolCallMiddleware(),
{Invokable: softRecoveryToolCallMiddleware()}, softRecoveryToolMiddleware(),
}, },
}, },
EmitInternalEvents: true, EmitInternalEvents: true,
@@ -341,28 +334,21 @@ func RunDeepAgent(
if err != nil { if err != nil {
return nil, err return nil, err
} }
mainToolsForCfg, mainOrchestratorPre, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger) mainToolsForCfg, mainOrchestratorPre, mainToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
if err != nil { if err != nil {
return nil, err return nil, err
} }
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools) orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools, mainToolSearchActive)
if logger != nil { if logger != nil {
mainNames := collectToolNames(ctx, mainTools) mainNames := collectToolNames(ctx, mainTools)
mountedNames := collectToolNames(ctx, mainToolsForCfg) mountedNames := collectToolNames(ctx, mainToolsForCfg)
hasToolSearch := false
for _, n := range mainNames {
if strings.EqualFold(strings.TrimSpace(n), "tool_search") {
hasToolSearch = true
break
}
}
logger.Info("eino tool-name injection", logger.Info("eino tool-name injection",
zap.String("scope", "orchestrator"), zap.String("scope", "orchestrator"),
zap.String("orchestration", orchMode), zap.String("orchestration", orchMode),
zap.Int("tool_names", len(mainNames)), zap.Int("tool_names", len(mainNames)),
zap.Int("mounted_tool_names", len(mountedNames)), zap.Int("mounted_tool_names", len(mountedNames)),
zap.Bool("has_tool_search", hasToolSearch), zap.Bool("tool_search_middleware", mainToolSearchActive),
) )
} }
@@ -390,10 +376,12 @@ func RunDeepAgent(
if einoLoc != nil && einoFSTools { if einoLoc != nil && einoFSTools {
deepBackend = einoLoc deepBackend = einoLoc
deepShell = &einoStreamingShellWrap{ deepShell = &einoStreamingShellWrap{
inner: einoLoc, inner: einoLoc,
invokeNotify: toolInvokeNotify, invokeNotify: toolInvokeNotify,
einoAgentName: orchestratorName, einoAgentName: orchestratorName,
recordMonitor: einoExecMonitor, outputChunk: toolOutputChunk,
recordMonitor: einoExecMonitor,
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
} }
} }
@@ -439,7 +427,7 @@ func RunDeepAgent(
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(), UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
ToolCallMiddlewares: []compose.ToolMiddleware{ ToolCallMiddlewares: []compose.ToolMiddleware{
hitlToolCallMiddleware(), hitlToolCallMiddleware(),
{Invokable: softRecoveryToolCallMiddleware()}, softRecoveryToolMiddleware(),
}, },
}, },
EmitInternalEvents: true, EmitInternalEvents: true,
@@ -457,7 +445,7 @@ func RunDeepAgent(
// 构建 filesystem 中间件(与 Deep sub-agent 一致) // 构建 filesystem 中间件(与 Deep sub-agent 一致)
var peFsMw adk.ChatModelAgentMiddleware var peFsMw adk.ChatModelAgentMiddleware
if einoSkillMW != nil && einoFSTools && einoLoc != nil { if einoSkillMW != nil && einoFSTools && einoLoc != nil {
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecMonitor) peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
if err != nil { if err != nil {
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err) return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
} }
@@ -585,6 +573,7 @@ func RunDeepAgent(
ToolInvokeNotify: toolInvokeNotify, ToolInvokeNotify: toolInvokeNotify,
DA: da, DA: da,
ModelFacingTrace: modelFacingTrace, ModelFacingTrace: modelFacingTrace,
EinoCallbacks: &ma.EinoCallbacks,
EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " + EmptyResponseMessage: "(Eino multi-agent orchestration completed but no assistant text was captured. Check process details or logs.) " +
"(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)", "(Eino 多代理编排已完成,但未捕获到助手文本输出。请查看过程详情或日志。)",
}, baseMsgs) }, baseMsgs)
@@ -748,12 +737,23 @@ func toolCallsRichSignature(msg *schema.Message) string {
return base + "|" + strings.Join(parts, ";") return base + "|" + strings.Join(parts, ";")
} }
func einoMainIterationKey(agentName, orchestratorName string) string {
key := strings.TrimSpace(agentName)
if key == "" {
key = strings.TrimSpace(orchestratorName)
}
if key == "" {
return "_main"
}
return key
}
func tryEmitToolCallsOnce( func tryEmitToolCallsOnce(
msg *schema.Message, msg *schema.Message,
agentName, orchestratorName, conversationID string, agentName, orchestratorName, conversationID, orchMode string,
progress func(string, string, interface{}), progress func(string, string, interface{}),
seen map[string]struct{}, seen map[string]struct{},
subAgentToolStep map[string]int, subAgentToolStep, mainAgentToolStep map[string]int,
markPending func(toolCallPendingInfo), markPending func(toolCallPendingInfo),
) { ) {
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil || seen == nil { if msg == nil || len(msg.ToolCalls) == 0 || progress == nil || seen == nil {
@@ -767,14 +767,14 @@ func tryEmitToolCallsOnce(
return return
} }
seen[sig] = struct{}{} seen[sig] = struct{}{}
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, progress, subAgentToolStep, markPending) emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, orchMode, progress, subAgentToolStep, mainAgentToolStep, markPending)
} }
func emitToolCallsFromMessage( func emitToolCallsFromMessage(
msg *schema.Message, msg *schema.Message,
agentName, orchestratorName, conversationID string, agentName, orchestratorName, conversationID, orchMode string,
progress func(string, string, interface{}), progress func(string, string, interface{}),
subAgentToolStep map[string]int, subAgentToolStep, mainAgentToolStep map[string]int,
markPending func(toolCallPendingInfo), markPending func(toolCallPendingInfo),
) { ) {
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil { if msg == nil || len(msg.ToolCalls) == 0 || progress == nil {
@@ -795,6 +795,22 @@ func emitToolCallsFromMessage(
"conversationId": conversationID, "conversationId": conversationID,
"source": "eino", "source": "eino",
}) })
} else if mainAgentToolStep != nil {
key := einoMainIterationKey(agentName, orchestratorName)
mainAgentToolStep[key]++
n := mainAgentToolStep[key]
// 第 1 轮已在主代理进入时发出;此后每次工具批次对应新一轮 ReAct(与子代理按工具计步一致)。
if n > 1 {
progress("iteration", "", map[string]interface{}{
"iteration": n,
"einoScope": "main",
"einoRole": "orchestrator",
"einoAgent": agentName,
"orchestration": orchMode,
"conversationId": conversationID,
"source": "eino",
})
}
} }
role := "orchestrator" role := "orchestrator"
if isSubToolRound { if isSubToolRound {
+42 -2
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
"github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
) )
// softRecoveryToolCallMiddleware returns an InvokableToolMiddleware that catches // softRecoveryToolCallMiddleware returns an InvokableToolMiddleware that catches
@@ -16,8 +17,9 @@ import (
// returned to the LLM. This allows the model to self-correct within the same // returned to the LLM. This allows the model to self-correct within the same
// iteration rather than crashing the entire graph and requiring a full replay. // iteration rather than crashing the entire graph and requiring a full replay.
// //
// Without this middleware, a JSON parse failure in any tool's InvokableRun propagates // Without Invokable (+ Streamable where applicable) registration, a JSON parse failure
// as a hard error through the Eino ToolsNode → [NodeRunError] → ev.Err, which // in InvokableRun / StreamableRun propagates as a hard error through the Eino ToolsNode
// → [NodeRunError] → ev.Err, which
// either triggers the full-replay retry loop (expensive) or terminates the run // either triggers the full-replay retry loop (expensive) or terminates the run
// entirely once retries are exhausted. With it, the LLM simply sees an error message // entirely once retries are exhausted. With it, the LLM simply sees an error message
// in the tool result and can adjust its next tool call accordingly. // in the tool result and can adjust its next tool call accordingly.
@@ -39,6 +41,44 @@ func softRecoveryToolCallMiddleware() compose.InvokableToolMiddleware {
} }
} }
// softRecoveryStreamableToolCallMiddleware mirrors softRecoveryToolCallMiddleware for
// tools that implement StreamableTool only (e.g. Eino ADK filesystem execute).
// Eino applies Invokable vs Streamable middleware to disjoint code paths in ToolsNode;
// registering only Invokable leaves streaming tools uncovered — empty/malformed JSON
// then fails inside [LocalStreamFunc] before the inner endpoint runs.
func softRecoveryStreamableToolCallMiddleware() compose.StreamableToolMiddleware {
return func(next compose.StreamableToolEndpoint) compose.StreamableToolEndpoint {
return func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
out, err := next(ctx, input)
if err == nil {
return out, nil
}
if !isSoftRecoverableToolError(err) {
return out, err
}
toolName := ""
args := ""
if input != nil {
toolName = input.Name
args = input.Arguments
}
msg := buildSoftRecoveryMessage(toolName, args, err)
return &compose.StreamToolOutput{
Result: schema.StreamReaderFromArray([]string{msg}),
}, nil
}
}
}
// softRecoveryToolMiddleware returns a ToolMiddleware with both Invokable and Streamable
// soft recovery (same semantics as hitlToolCallMiddleware bundling).
func softRecoveryToolMiddleware() compose.ToolMiddleware {
return compose.ToolMiddleware{
Invokable: softRecoveryToolCallMiddleware(),
Streamable: softRecoveryStreamableToolCallMiddleware(),
}
}
// isSoftRecoverableToolError determines whether a tool execution error should be // isSoftRecoverableToolError determines whether a tool execution error should be
// silently converted to a tool-result message rather than crashing the graph. // silently converted to a tool-result message rather than crashing the graph.
// //
@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"strings"
"testing" "testing"
"github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/compose"
@@ -108,6 +110,39 @@ func TestSoftRecoveryToolCallMiddleware_PassesThrough(t *testing.T) {
} }
} }
func TestSoftRecoveryStreamableToolCallMiddleware_LocalStreamFuncJSONError(t *testing.T) {
mw := softRecoveryStreamableToolCallMiddleware()
next := func(ctx context.Context, input *compose.ToolInput) (*compose.StreamToolOutput, error) {
return nil, errors.New(`[LocalStreamFunc] failed to unmarshal arguments in json, toolName=execute, err="Syntax error no sources available, the input json is empty`)
}
wrapped := mw(next)
out, err := wrapped(context.Background(), &compose.ToolInput{
Name: "execute",
Arguments: "",
})
if err != nil {
t.Fatalf("expected nil error (soft recovery), got: %v", err)
}
if out == nil || out.Result == nil {
t.Fatal("expected stream result")
}
var sb strings.Builder
for {
chunk, rerr := out.Result.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("recv: %v", rerr)
}
sb.WriteString(chunk)
}
text := sb.String()
if !containsAll(text, "[Tool Error]", "execute", "JSON") {
t.Fatalf("recovery message missing expected content: %s", text)
}
}
func TestSoftRecoveryToolCallMiddleware_ConvertsJSONError(t *testing.T) { func TestSoftRecoveryToolCallMiddleware_ConvertsJSONError(t *testing.T) {
mw := softRecoveryToolCallMiddleware() mw := softRecoveryToolCallMiddleware()
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) { next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
+20
View File
@@ -0,0 +1,20 @@
package openai
// SSEAccumulatedKey 为 SSE progress 事件 data 中的服务端权威流式全文快照字段。
// 前端应优先用该字段更新 buffer,避免对 delta 二次 normalize 导致叠字。
const SSEAccumulatedKey = "accumulated"
// WithSSEAccumulated 在 progress data 中附带当前流式累计全文(权威快照)。
func WithSSEAccumulated(data map[string]interface{}, accumulated string) map[string]interface{} {
if data == nil {
data = make(map[string]interface{}, 1)
}
data[SSEAccumulatedKey] = accumulated
return data
}
// NormalizeStreamingDelta 将可能是“累计片段/重发片段”的内容归一化为“纯增量”。
// 与 unexported normalizeStreamingDelta 相同,供 agent / multiagent 等包在发 SSE 前累计正文。
func NormalizeStreamingDelta(current, incoming string) (next, delta string) {
return normalizeStreamingDelta(current, incoming)
}
+316
View File
@@ -0,0 +1,316 @@
package ilink
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
DefaultBaseURL = "https://ilinkai.weixin.qq.com"
DefaultBotType = "3"
DefaultBotAgent = "CyberStrikeAI/1.0"
ILinkAppID = "bot"
QRLongPollTimeout = 35 * time.Second
APIDefaultTimeout = 15 * time.Second
GetUpdatesTimeout = 35 * time.Second
)
// Client 微信 iLink Bot HTTP 客户端(与 @tencent-weixin/openclaw-weixin 协议兼容)
type Client struct {
BaseURL string
BotToken string
BotAgent string
ClientVersion uint32
HTTP *http.Client
}
func NewClient(baseURL, botToken, botAgent string, clientVersion uint32) *Client {
base := strings.TrimSpace(baseURL)
if base == "" {
base = DefaultBaseURL
}
agent := strings.TrimSpace(botAgent)
if agent == "" {
agent = DefaultBotAgent
}
return &Client{
BaseURL: strings.TrimRight(base, "/"),
BotToken: strings.TrimSpace(botToken),
BotAgent: sanitizeBotAgent(agent),
ClientVersion: clientVersion,
HTTP: &http.Client{Timeout: 0},
}
}
// BuildClientVersion 将 semver 编码为 iLink-App-ClientVersion0x00MMNNPP
func BuildClientVersion(version string) uint32 {
parts := strings.Split(version, ".")
parse := func(i int) int {
if i >= len(parts) {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(parts[i]))
if n < 0 {
return 0
}
return n
}
major := parse(0) & 0xff
minor := parse(1) & 0xff
patch := parse(2) & 0xff
return uint32((major << 16) | (minor << 8) | patch)
}
type baseInfo struct {
ChannelVersion string `json:"channel_version"`
BotAgent string `json:"bot_agent"`
}
func (c *Client) buildBaseInfo() baseInfo {
return baseInfo{
ChannelVersion: "1.0.0",
BotAgent: c.BotAgent,
}
}
func randomWechatUIN() string {
var b [4]byte
_, _ = rand.Read(b[:])
u := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(u), 10)))
}
func (c *Client) commonHeaders() http.Header {
h := http.Header{}
h.Set("iLink-App-Id", ILinkAppID)
h.Set("iLink-App-ClientVersion", strconv.FormatUint(uint64(c.ClientVersion), 10))
return h
}
func (c *Client) authHeaders() http.Header {
h := c.commonHeaders()
h.Set("Content-Type", "application/json")
h.Set("AuthorizationType", "ilink_bot_token")
h.Set("X-WECHAT-UIN", randomWechatUIN())
if c.BotToken != "" {
h.Set("Authorization", "Bearer "+c.BotToken)
}
return h
}
func (c *Client) endpointURL(path string) (string, error) {
u, err := url.Parse(c.BaseURL + "/")
if err != nil {
return "", err
}
ref, err := url.Parse(path)
if err != nil {
return "", err
}
return u.ResolveReference(ref).String(), nil
}
func (c *Client) doRequest(ctx context.Context, method, path string, body []byte, headers http.Header, timeout time.Duration) ([]byte, error) {
reqURL, err := c.endpointURL(path)
if err != nil {
return nil, err
}
var bodyReader io.Reader
if len(body) > 0 {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
if err != nil {
return nil, err
}
for k, vs := range headers {
for _, v := range vs {
req.Header.Add(k, v)
}
}
client := c.HTTP
if client == nil {
client = http.DefaultClient
}
if timeout > 0 {
ctx2, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req = req.WithContext(ctx2)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("ilink %s %s: %d %s", method, path, resp.StatusCode, string(raw))
}
return raw, nil
}
// QRCodeResponse 获取二维码响应
type QRCodeResponse struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
// GetBotQRCode 获取绑定二维码
func (c *Client) GetBotQRCode(ctx context.Context, botType string, localTokenList []string) (*QRCodeResponse, error) {
if strings.TrimSpace(botType) == "" {
botType = DefaultBotType
}
body, _ := json.Marshal(map[string]interface{}{
"local_token_list": localTokenList,
})
path := "ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(botType)
raw, err := c.doRequest(ctx, http.MethodPost, path, body, c.authHeaders(), APIDefaultTimeout)
if err != nil {
return nil, err
}
var out QRCodeResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// QRStatusResponse 二维码状态轮询响应
type QRStatusResponse struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
ILinkUserID string `json:"ilink_user_id"`
BaseURL string `json:"baseurl"`
RedirectHost string `json:"redirect_host"`
}
// GetQRCodeStatus 长轮询二维码扫码状态
func (c *Client) GetQRCodeStatus(ctx context.Context, qrcode, verifyCode string) (*QRStatusResponse, error) {
path := "ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
if verifyCode != "" {
path += "&verify_code=" + url.QueryEscape(verifyCode)
}
raw, err := c.doRequest(ctx, http.MethodGet, path, nil, c.commonHeaders(), QRLongPollTimeout)
if err != nil {
if ctx.Err() != nil {
return &QRStatusResponse{Status: "wait"}, nil
}
return &QRStatusResponse{Status: "wait"}, nil
}
var out QRStatusResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// MessageItem 消息内容项
type MessageItem struct {
Type int `json:"type"`
TextItem *struct {
Text string `json:"text"`
} `json:"text_item,omitempty"`
}
// WeixinMessage 入站消息
type WeixinMessage struct {
FromUserID string `json:"from_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// GetUpdatesResponse 长轮询消息响应
type GetUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Msgs []WeixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
LongPollingTimeoutMs int `json:"longpolling_timeout_ms"`
}
// GetUpdates 长轮询获取新消息
func (c *Client) GetUpdates(ctx context.Context, getUpdatesBuf string) (*GetUpdatesResponse, error) {
body, _ := json.Marshal(map[string]interface{}{
"get_updates_buf": getUpdatesBuf,
"base_info": c.buildBaseInfo(),
})
raw, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/getupdates", body, c.authHeaders(), GetUpdatesTimeout)
if err != nil {
if ctx.Err() != nil {
return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil
}
return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil
}
var out GetUpdatesResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// SendTextMessage 发送文本回复
func (c *Client) SendTextMessage(ctx context.Context, toUserID, contextToken, text, clientID string) error {
if clientID == "" {
clientID = randomClientID()
}
payload := map[string]interface{}{
"msg": map[string]interface{}{
"to_user_id": toUserID,
"client_id": clientID,
"message_type": 2,
"message_state": 2,
"context_token": contextToken,
"item_list": []map[string]interface{}{
{"type": 1, "text_item": map[string]string{"text": text}},
},
},
"base_info": c.buildBaseInfo(),
}
body, _ := json.Marshal(payload)
_, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/sendmessage", body, c.authHeaders(), APIDefaultTimeout)
return err
}
func randomClientID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return fmt.Sprintf("%x", b)
}
func sanitizeBotAgent(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return DefaultBotAgent
}
if len(raw) > 256 {
return raw[:256]
}
return raw
}
// ExtractText 从消息中提取首条文本
func ExtractText(msg WeixinMessage) string {
for _, item := range msg.ItemList {
if item.Type == 1 && item.TextItem != nil {
return strings.TrimSpace(item.TextItem.Text)
}
}
return ""
}
+26
View File
@@ -0,0 +1,26 @@
package ilink
import (
"encoding/base64"
"fmt"
"strings"
"github.com/skip2/go-qrcode"
)
// QRCodeDataURL 将扫码内容(一般为 liteapp 链接)编码为 PNG data URL,供 Web 端展示。
// qrcode_img_content 不是图片直链,不能用作 <img src>。
func QRCodeDataURL(content string, size int) (string, error) {
content = strings.TrimSpace(content)
if content == "" {
return "", fmt.Errorf("empty qr content")
}
if size <= 0 {
size = 256
}
png, err := qrcode.Encode(content, qrcode.Medium, size)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}
+96
View File
@@ -0,0 +1,96 @@
package robot
import (
"context"
"strings"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/robot/ilink"
"go.uber.org/zap"
)
const (
wechatReconnectInitial = 5 * time.Second
wechatReconnectMax = 60 * time.Second
wechatPlatform = "wechat"
)
// StartWechat 启动微信 iLink 长轮询(无需公网回调),收到消息后调用 handler 并回复。
func StartWechat(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, appVersion string, logger *zap.Logger) {
cfg := robotsCfg.Wechat
if !cfg.Enabled || cfg.BotToken == "" {
return
}
go runWechatLoop(ctx, cfg, h, appVersion, logger)
}
func runWechatLoop(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) {
backoff := wechatReconnectInitial
for {
err := runWechatPoll(ctx, cfg, h, appVersion, logger)
if ctx.Err() != nil {
logger.Info("微信 iLink 长轮询已按配置关闭")
return
}
if err != nil {
logger.Warn("微信 iLink 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
if backoff < wechatReconnectMax {
backoff *= 2
if backoff > wechatReconnectMax {
backoff = wechatReconnectMax
}
}
}
}
}
func runWechatPoll(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) error {
client := ilink.NewClient(cfg.BaseURL, cfg.BotToken, cfg.BotAgent, ilink.BuildClientVersion(appVersion))
buf := cfg.GetUpdatesBuf
logger.Info("微信 iLink 长轮询已启动", zap.String("ilink_bot_id", cfg.ILinkBotID))
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
resp, err := client.GetUpdates(ctx, buf)
if err != nil {
return err
}
if resp.ErrCode != 0 && resp.Ret != 0 {
logger.Warn("微信 getUpdates 返回错误", zap.Int("errcode", resp.ErrCode), zap.String("errmsg", resp.ErrMsg))
}
if resp.GetUpdatesBuf != "" {
buf = resp.GetUpdatesBuf
}
for _, msg := range resp.Msgs {
if msg.MessageType != 1 {
continue
}
text := ilink.ExtractText(msg)
if text == "" {
continue
}
userID := strings.TrimSpace(msg.FromUserID)
if userID == "" {
continue
}
logger.Info("微信收到消息", zap.String("from", userID), zap.String("content", text))
reply := h.HandleMessage(wechatPlatform, userID, text)
if strings.TrimSpace(reply) == "" {
continue
}
if err := client.SendTextMessage(ctx, userID, msg.ContextToken, reply, ""); err != nil {
logger.Warn("微信发送回复失败", zap.String("to", userID), zap.Error(err))
}
}
}
}
+34 -13
View File
@@ -153,6 +153,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
// 执行命令 // 执行命令
cmd := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...) cmd := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd) applyDefaultTerminalEnv(cmd)
_ = prepareShellCmdSession(cmd)
e.logger.Info("执行安全工具", e.logger.Info("执行安全工具",
zap.String("tool", toolName), zap.String("tool", toolName),
@@ -163,13 +164,14 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
var err error var err error
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。 // 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil { if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb) output, err = streamCommandOutput(ctx, cmd, cb)
if err != nil && shouldRetryWithPTY(output) { if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试", e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
zap.String("tool", toolName), zap.String("tool", toolName),
) )
cmd2 := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...) cmd2 := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd2) applyDefaultTerminalEnv(cmd2)
_ = prepareShellCmdSession(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, cb) output, err = runCommandWithPTY(ctx, cmd2, cb)
} }
} else { } else {
@@ -182,6 +184,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
) )
cmd2 := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...) cmd2 := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd2) applyDefaultTerminalEnv(cmd2)
_ = prepareShellCmdSession(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, nil) output, err = runCommandWithPTY(ctx, cmd2, nil)
} }
} }
@@ -837,6 +840,8 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
} else { } else {
cmd = exec.CommandContext(ctx, shell, "-c", command) cmd = exec.CommandContext(ctx, shell, "-c", command)
} }
applyDefaultTerminalEnv(cmd)
_ = prepareShellCmdSession(cmd)
// 执行命令 // 执行命令
e.logger.Info("执行系统命令", e.logger.Info("执行系统命令",
@@ -865,6 +870,8 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
} else { } else {
pidCmd = exec.CommandContext(ctx, shell, "-c", pidCommand) pidCmd = exec.CommandContext(ctx, shell, "-c", pidCommand)
} }
applyDefaultTerminalEnv(pidCmd)
_ = prepareShellCmdSession(pidCmd)
// 获取stdout管道 // 获取stdout管道
stdout, err := pidCmd.StdoutPipe() stdout, err := pidCmd.StdoutPipe()
@@ -976,7 +983,7 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
var err error var err error
// 若上层提供工具输出增量回调,则边执行边流式读取。 // 若上层提供工具输出增量回调,则边执行边流式读取。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil { if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb) output, err = streamCommandOutput(ctx, cmd, cb)
if err != nil && shouldRetryWithPTY(output) { if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试") e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
cmd2 := exec.CommandContext(ctx, shell, "-c", command) cmd2 := exec.CommandContext(ctx, shell, "-c", command)
@@ -984,6 +991,7 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
cmd2.Dir = workDir cmd2.Dir = workDir
} }
applyDefaultTerminalEnv(cmd2) applyDefaultTerminalEnv(cmd2)
_ = prepareShellCmdSession(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, cb) output, err = runCommandWithPTY(ctx, cmd2, cb)
} }
} else { } else {
@@ -997,6 +1005,7 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
cmd2.Dir = workDir cmd2.Dir = workDir
} }
applyDefaultTerminalEnv(cmd2) applyDefaultTerminalEnv(cmd2)
_ = prepareShellCmdSession(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, nil) output, err = runCommandWithPTY(ctx, cmd2, nil)
} }
} }
@@ -1034,8 +1043,11 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
} }
// streamCommandOutput 以“边读边回调”的方式读取命令 stdout/stderr。 // streamCommandOutput 以“边读边回调”的方式读取命令 stdout/stderr。
// 保持输出内容完整拼接返回,并用 cb(chunk) 向上层持续推送 // 使用定长块读取,避免按行读取在无换行输出时永久阻塞;ctx 取消时终止进程树
func streamCommandOutput(cmd *exec.Cmd, cb ToolOutputCallback) (string, error) { func streamCommandOutput(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
if err := prepareShellCmdSession(cmd); err != nil {
return "", err
}
stdoutPipe, err := cmd.StdoutPipe() stdoutPipe, err := cmd.StdoutPipe()
if err != nil { if err != nil {
return "", err return "", err
@@ -1051,18 +1063,27 @@ func streamCommandOutput(cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
return "", err return "", err
} }
stopWatch := make(chan struct{})
go func() {
select {
case <-ctx.Done():
terminateCmdTree(cmd)
case <-stopWatch:
}
}()
defer close(stopWatch)
chunks := make(chan string, 64) chunks := make(chan string, 64)
var wg sync.WaitGroup var wg sync.WaitGroup
readFn := func(r io.Reader) { readFn := func(r io.Reader) {
defer wg.Done() defer wg.Done()
br := bufio.NewReader(r) buf := make([]byte, 8192)
for { for {
s, readErr := br.ReadString('\n') n, readErr := r.Read(buf)
if s != "" { if n > 0 {
chunks <- s chunks <- string(buf[:n])
} }
if readErr != nil { if readErr != nil {
// EOF 正常结束
return return
} }
} }
@@ -1158,12 +1179,14 @@ func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
// PTY 方案为类 UnixWindows 走原逻辑 // PTY 方案为类 UnixWindows 走原逻辑
if cb != nil { if cb != nil {
return streamCommandOutput(cmd, cb) return streamCommandOutput(ctx, cmd, cb)
} }
_ = prepareShellCmdSession(cmd)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
return string(out), err return string(out), err
} }
_ = prepareShellCmdSession(cmd)
ptmx, err := pty.Start(cmd) ptmx, err := pty.Start(cmd)
if err != nil { if err != nil {
return "", err return "", err
@@ -1176,9 +1199,7 @@ func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback
select { select {
case <-ctx.Done(): case <-ctx.Done():
_ = ptmx.Close() // 触发读退出 _ = ptmx.Close() // 触发读退出
if cmd.Process != nil { terminateCmdTree(cmd)
_ = cmd.Process.Kill()
}
case <-done: case <-done:
} }
}() }()
+31
View File
@@ -0,0 +1,31 @@
//go:build !windows
package security
import (
"os/exec"
"syscall"
)
// prepareShellCmdSession 让 shell 子进程在独立会话中运行,便于超时/取消时整组 SIGKILL(含子进程)。
func prepareShellCmdSession(cmd *exec.Cmd) error {
if cmd == nil {
return nil
}
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Setsid = true
return nil
}
// terminateCmdTree 尽力终止 cmd 及其进程组(Unix 下 Setsid 后 PGID == 首进程 PID)。
func terminateCmdTree(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
pid := cmd.Process.Pid
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
_ = cmd.Process.Kill()
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
package security
import "os/exec"
func prepareShellCmdSession(cmd *exec.Cmd) error {
_ = cmd
return nil
}
func terminateCmdTree(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
}
@@ -4,7 +4,7 @@
### What it does ### What it does
- Configure **Host / Port / Password** and choose **Single-Agent** or **Multi-Agent** - Configure **Host / Port / HTTPS / Password** and choose an agent mode
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`) - 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** - 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 - Keep a **test history sidebar** (searchable) so you can revisit previous runs
@@ -63,6 +63,7 @@ If you already have Gradle available, you can still use `build.gradle` to build.
### Notes ### Notes
- This extension connects to your CyberStrikeAI server (default is `http://127.0.0.1:8080`). - Default connection is `https://127.0.0.1:8080` (**HTTPS** checked). Self-signed / local certs are trusted automatically (no import).
- Uncheck **HTTPS** only if your server runs plain HTTP.
- It uses **Bearer Token** authentication obtained from the configured password. - It uses **Bearer Token** authentication obtained from the configured password.
@@ -81,7 +81,8 @@ cd plugins/burp-suite/cyberstrikeai-burp-extension
2) 填写: 2) 填写:
- **Host**:例如 `127.0.0.1` - **Host**:例如 `127.0.0.1`
- **Port**:例如 `8080` - **Port**:例如 `8080`
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `config.yaml` `auth.password` - **HTTPS**:默认勾选(对接 `config.yaml` `tls_enabled` / 自签证书);插件会自动信任本地自签证书,无需导入
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `auth.password`
- **Agent mode**:选择 `Single Agent``Multi Agent` - **Agent mode**:选择 `Single Agent``Multi Agent`
3) 点击 **Validate** 3) 点击 **Validate**
- 成功:状态显示 `OK (token saved)` - 成功:状态显示 `OK (token saved)`
@@ -94,8 +95,9 @@ cd plugins/burp-suite/cyberstrikeai-burp-extension
- **Validate 失败 / 401** - **Validate 失败 / 401**
- 确认密码是否正确(服务端 `auth.password` - 确认密码是否正确(服务端 `auth.password`
- 确认 IP/端口是否能访问(例如浏览器能打开 `http://IP:PORT/` - 确认 IP/端口是否能访问(例如浏览器能打开 `https://IP:PORT/`
- 服务启用了反向代理/HTTPS,需要把插件里 baseUrl 改成对应协议与端口(当前插件默认使用 `http://` - 服务启用 TLS 时勾选 **HTTPS**(默认已勾选);自签证书无需手动导入
- 若仍为纯 HTTP 部署,取消勾选 **HTTPS**
- **选择 Multi Agent 后提示“多代理未启用”** - **选择 Multi Agent 后提示“多代理未启用”**
- 服务端需要开启:`config.yaml``multi_agent.enabled: true` - 服务端需要开启:`config.yaml``multi_agent.enabled: true`
@@ -73,15 +73,34 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
public void onEvent(String type, String message, String rawJson) { public void onEvent(String type, String message, String rawJson) {
if (type == null) type = ""; if (type == null) type = "";
switch (type) { switch (type) {
case "response_start":
tab.appendProgressToRun(runId, "\n\n[主回复]\n");
break;
case "response_delta": case "response_delta":
case "eino_agent_reply_stream_delta": if (message != null && !message.isEmpty()) {
tab.appendFinalToRun(runId, message); tab.appendFinalToRun(runId, message);
}
break; break;
case "response": case "response":
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
tab.appendFinalToRun(runId, message); tab.appendFinalToRun(runId, message);
tab.setFinalResponse(runId, message); tab.setFinalResponse(runId, message);
break; break;
case "eino_agent_reply_stream_start":
tab.appendProgressToRun(runId, "\n\n[子代理回复]\n");
break;
case "eino_agent_reply_stream_delta":
if (message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, message);
}
break;
case "eino_agent_reply_stream_end":
tab.appendProgressToRun(runId, "\n");
break;
case "eino_agent_reply":
if (message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, "\n\n[子代理回复]\n" + message + "\n");
}
break;
case "progress": case "progress":
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n"); tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
tab.setRunStatus(runId, "running"); tab.setRunStatus(runId, "running");
@@ -94,21 +113,40 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
tab.appendProgressToRun(runId, "\n[error] " + message + "\n"); tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
tab.setRunStatus(runId, "error"); tab.setRunStatus(runId, "error");
break; break;
case "reasoning_chain_stream_start":
tab.appendProgressToRun(runId, "\n\n[推理过程]\n");
break;
case "reasoning_chain_stream_delta":
if (message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, message);
}
break;
case "reasoning_chain_stream_end":
tab.appendProgressToRun(runId, "\n");
break;
case "reasoning_chain":
if (message != null && !message.isEmpty()) {
String streamId = rawJson != null ? SimpleJson.extractStringField(rawJson, "streamId") : "";
if (streamId == null || streamId.isEmpty()) {
tab.appendProgressToRun(runId, "\n\n[推理过程]\n" + message + "\n");
}
}
break;
case "thinking_stream_start": case "thinking_stream_start":
if (tab.isShowDebugEvents()) { if (tab.isShowDebugEvents()) {
tab.resetThinkingStream(runId); tab.resetThinkingStream(runId);
} }
break; break;
case "thinking_stream_delta": case "thinking_stream_delta":
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
tab.appendProgressToRun(runId, message);
}
break;
case "tool_call": case "tool_call":
case "tool_result": case "tool_result":
case "tool_result_delta": case "tool_result_delta":
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) { if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
if ("thinking_stream_delta".equals(type)) { tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
tab.appendThinkingDelta(runId, message);
} else {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
}
} }
break; break;
case "conversation": case "conversation":
@@ -125,7 +163,9 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
case "done": case "done":
break; break;
default: default:
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) { if (tab.isShowDebugEvents() && message != null && !message.isEmpty()
&& !type.endsWith("_stream_delta") && !type.endsWith("_stream_start")
&& !type.endsWith("_stream_end")) {
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n"); tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
} }
break; break;
@@ -134,8 +174,9 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
@Override @Override
public void onError(String message, Exception e) { public void onError(String message, Exception e) {
tab.appendProgressToRun(runId, "\n[error] " + message + "\n"); boolean cancelled = message != null && message.toLowerCase().contains("cancel");
tab.setRunStatus(runId, "error"); tab.appendProgressToRun(runId, cancelled ? "\n[info] " + message + "\n" : "\n[error] " + message + "\n");
tab.setRunStatus(runId, cancelled ? "cancelled" : "error");
callbacks.printError("CyberStrikeAI stream error: " + message); callbacks.printError("CyberStrikeAI stream error: " + message);
if (e != null) { if (e != null) {
callbacks.printError(e.toString()); callbacks.printError(e.toString());
@@ -2,17 +2,29 @@ package burp;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
final class CyberStrikeAIClient { final class CyberStrikeAIClient {
private static final int AUTH_CONNECT_TIMEOUT_MS = 4_000;
private static final int AUTH_READ_TIMEOUT_MS = 5_000;
/** login + validate 整段上限,避免两次读超时叠加拖到半分钟 */
private static final int AUTH_OVERALL_TIMEOUT_MS = 10_000;
private static final int DEFAULT_READ_TIMEOUT_MS = 15_000;
private final AtomicReference<HttpURLConnection> activeConnection = new AtomicReference<>();
private final AtomicReference<Thread> activeThread = new AtomicReference<>();
static final class Config { static final class Config {
final String baseUrl; // e.g. http://127.0.0.1:8080 final String baseUrl; // e.g. http://127.0.0.1:8080
final String password; final String password;
@@ -49,15 +61,97 @@ final class CyberStrikeAIClient {
void onDone(); void onDone();
} }
boolean hasActiveRequest() {
return activeConnection.get() != null;
}
void cancelActiveRequest() {
HttpURLConnection conn = activeConnection.getAndSet(null);
if (conn != null) {
try {
conn.disconnect();
} catch (Exception ignored) {
}
}
Thread t = activeThread.getAndSet(null);
if (t != null) {
t.interrupt();
}
}
String loginAndValidate(Config cfg) throws IOException { String loginAndValidate(Config cfg) throws IOException {
String token = login(cfg.baseUrl, cfg.password); Thread worker = Thread.currentThread();
validate(cfg.baseUrl, token); java.util.Timer deadline = new java.util.Timer("CyberStrikeAI-AuthDeadline", true);
return token; deadline.schedule(new java.util.TimerTask() {
@Override
public void run() {
worker.interrupt();
cancelActiveRequest();
}
}, AUTH_OVERALL_TIMEOUT_MS);
try {
String token = login(cfg.baseUrl, cfg.password);
if (Thread.interrupted()) {
throw timeoutIOException();
}
validate(cfg.baseUrl, token);
if (Thread.interrupted()) {
throw timeoutIOException();
}
return token;
} catch (SocketTimeoutException e) {
throw timeoutIOException();
} finally {
deadline.cancel();
}
}
private static IOException timeoutIOException() {
return new IOException("Connection timed out (~" + (AUTH_OVERALL_TIMEOUT_MS / 1000)
+ "s). Check host/port and HTTPS checkbox.");
}
private void trackConnection(HttpURLConnection conn) {
activeThread.set(Thread.currentThread());
activeConnection.set(conn);
}
private void releaseConnection(HttpURLConnection conn) {
if (activeConnection.compareAndSet(conn, null)) {
activeThread.set(null);
}
}
private static boolean isCancelled(Throwable e) {
if (e == null) {
return Thread.currentThread().isInterrupted();
}
if (Thread.currentThread().isInterrupted()) {
return true;
}
if (e instanceof InterruptedIOException) {
return true;
}
if (e instanceof SocketTimeoutException) {
return false;
}
Throwable cause = e.getCause();
if (cause != null && cause != e) {
return isCancelled(cause);
}
String msg = e.getMessage();
return msg != null && (
msg.toLowerCase().contains("cancel")
|| msg.toLowerCase().contains("abort")
|| msg.toLowerCase().contains("closed")
);
} }
private String login(String baseUrl, String password) throws IOException { private String login(String baseUrl, String password) throws IOException {
URL url = new URL(baseUrl + "/api/auth/login"); URL url = new URL(baseUrl + "/api/auth/login");
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
trackConnection(conn);
try {
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setDoOutput(true); conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Content-Type", "application/json");
@@ -92,11 +186,16 @@ final class CyberStrikeAIClient {
throw new IOException("Login response missing token. Check backend address and credentials."); throw new IOException("Login response missing token. Check backend address and credentials.");
} }
return token; return token;
} finally {
releaseConnection(conn);
}
} }
private void validate(String baseUrl, String token) throws IOException { private void validate(String baseUrl, String token) throws IOException {
URL url = new URL(baseUrl + "/api/auth/validate"); URL url = new URL(baseUrl + "/api/auth/validate");
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
trackConnection(conn);
try {
conn.setRequestMethod("GET"); conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + token); conn.setRequestProperty("Authorization", "Bearer " + token);
int code = conn.getResponseCode(); int code = conn.getResponseCode();
@@ -104,6 +203,9 @@ final class CyberStrikeAIClient {
if (code < 200 || code >= 300) { if (code < 200 || code >= 300) {
throw new IOException("Validate failed (" + code + "): " + resp); throw new IOException("Validate failed (" + code + "): " + resp);
} }
} finally {
releaseConnection(conn);
}
} }
void streamTest(Config cfg, String token, String message, StreamListener listener) { void streamTest(Config cfg, String token, String message, StreamListener listener) {
@@ -117,11 +219,12 @@ final class CyberStrikeAIClient {
payload.put("orchestration", cfg.agentMode.orchestration); payload.put("orchestration", cfg.agentMode.orchestration);
} }
new Thread(() -> { Thread worker = new Thread(() -> {
HttpURLConnection conn = null; HttpURLConnection conn = null;
try { try {
URL url = new URL(urlStr); URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection(); conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, 0);
trackConnection(conn);
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setDoOutput(true); conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Content-Type", "application/json");
@@ -142,6 +245,9 @@ final class CyberStrikeAIClient {
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line; String line;
while ((line = br.readLine()) != null) { while ((line = br.readLine()) != null) {
if (Thread.currentThread().isInterrupted()) {
break;
}
// SSE format: "data: {json}" // SSE format: "data: {json}"
if (line.startsWith("data:")) { if (line.startsWith("data:")) {
String json = line.substring("data:".length()).trim(); String json = line.substring("data:".length()).trim();
@@ -156,15 +262,25 @@ final class CyberStrikeAIClient {
} }
} }
} }
listener.onDone(); if (Thread.currentThread().isInterrupted()) {
listener.onError("Cancelled.", null);
} else {
listener.onDone();
}
} catch (Exception e) { } catch (Exception e) {
listener.onError(e.getMessage(), e); if (isCancelled(e)) {
listener.onError("Cancelled.", e);
} else {
listener.onError(e.getMessage(), e);
}
} finally { } finally {
if (conn != null) { if (conn != null) {
releaseConnection(conn);
conn.disconnect(); conn.disconnect();
} }
} }
}, "CyberStrikeAI-Stream").start(); }, "CyberStrikeAI-Stream");
worker.start();
} }
void cancelByConversationId(String baseUrl, String token, String conversationId) throws IOException { void cancelByConversationId(String baseUrl, String token, String conversationId) throws IOException {
@@ -172,7 +288,7 @@ final class CyberStrikeAIClient {
throw new IOException("Missing conversationId."); throw new IOException("Missing conversationId.");
} }
URL url = new URL(baseUrl + "/api/agent-loop/cancel"); URL url = new URL(baseUrl + "/api/agent-loop/cancel");
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setDoOutput(true); conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Content-Type", "application/json");
@@ -14,6 +14,7 @@ final class CyberStrikeAITab implements ITab {
private final JTextField hostField = new JTextField("127.0.0.1"); private final JTextField hostField = new JTextField("127.0.0.1");
private final JTextField portField = new JTextField("8080"); private final JTextField portField = new JTextField("8080");
private final JCheckBox useHttpsBox = new JCheckBox("HTTPS", true);
private final JPasswordField passwordField = new JPasswordField(); private final JPasswordField passwordField = new JPasswordField();
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{ private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{
"Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor" "Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor"
@@ -29,6 +30,10 @@ final class CyberStrikeAITab implements ITab {
private final JTextArea progressArea = new JTextArea(); private final JTextArea progressArea = new JTextArea();
private final JTextArea finalRawArea = new JTextArea(); // raw final stream / final response private final JTextArea finalRawArea = new JTextArea(); // raw final stream / final response
private JScrollPane progressScrollPane;
private JScrollPane finalRawScrollPane;
/** 距底部在此像素内视为「跟随滚动」,否则用户上拉阅读时不抢滚动条 */
private static final int SCROLL_FOLLOW_THRESHOLD_PX = 48;
private final JEditorPane markdownPane = new JEditorPane("text/html", ""); private final JEditorPane markdownPane = new JEditorPane("text/html", "");
private final CardLayout outputCardsLayout = new CardLayout(); private final CardLayout outputCardsLayout = new CardLayout();
private final JPanel outputCards = new JPanel(outputCardsLayout); private final JPanel outputCards = new JPanel(outputCardsLayout);
@@ -41,6 +46,7 @@ final class CyberStrikeAITab implements ITab {
private final CyberStrikeAIClient client = new CyberStrikeAIClient(); private final CyberStrikeAIClient client = new CyberStrikeAIClient();
private final AtomicReference<String> tokenRef = new AtomicReference<>(""); private final AtomicReference<String> tokenRef = new AtomicReference<>("");
private final AtomicReference<Thread> validateThreadRef = new AtomicReference<>();
private final DefaultListModel<TestRun> testListModel = new DefaultListModel<>(); private final DefaultListModel<TestRun> testListModel = new DefaultListModel<>();
private final JList<TestRun> testList = new JList<>(testListModel); private final JList<TestRun> testList = new JList<>(testListModel);
@@ -107,6 +113,8 @@ final class CyberStrikeAITab implements ITab {
row1.add(hostField); row1.add(hostField);
row1.add(new JLabel("Port")); row1.add(new JLabel("Port"));
row1.add(portField); row1.add(portField);
useHttpsBox.setToolTipText("Use https:// for CyberStrikeAI (self-signed certs are trusted automatically)");
row1.add(useHttpsBox);
row1.add(new JLabel("Password")); row1.add(new JLabel("Password"));
row1.add(passwordField); row1.add(passwordField);
row1.add(validateButton); row1.add(validateButton);
@@ -186,15 +194,22 @@ final class CyberStrikeAITab implements ITab {
configureTextArea(requestArea, false); configureTextArea(requestArea, false);
configureTextArea(responseArea, false); configureTextArea(responseArea, false);
outputCards.add(new JScrollPane(finalRawArea), "raw"); finalRawScrollPane = new JScrollPane(finalRawArea);
finalRawScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
finalRawScrollPane.getVerticalScrollBar().setUnitIncrement(16);
outputCards.add(finalRawScrollPane, "raw");
outputCards.add(new JScrollPane(markdownPane), "md"); outputCards.add(new JScrollPane(markdownPane), "md");
outputRoot.add(buildOutputHeader(), BorderLayout.NORTH); outputRoot.add(buildOutputHeader(), BorderLayout.NORTH);
outputRoot.add(buildOutputBody(), BorderLayout.CENTER); outputRoot.add(buildOutputBody(), BorderLayout.CENTER);
rightTabs.addTab("Output", outputRoot); rightTabs.addTab("Output", outputRoot);
rightTabs.addTab("Request", new JScrollPane(requestArea)); JScrollPane requestScroll = new JScrollPane(requestArea);
rightTabs.addTab("Response", new JScrollPane(responseArea)); requestScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
rightTabs.addTab("Request", requestScroll);
JScrollPane responseScroll = new JScrollPane(responseArea);
responseScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
rightTabs.addTab("Response", responseScroll);
return rightTabs; return rightTabs;
} }
@@ -210,12 +225,13 @@ final class CyberStrikeAITab implements ITab {
} }
private JComponent buildOutputBody() { private JComponent buildOutputBody() {
JScrollPane progressScroll = new JScrollPane(progressArea); progressScrollPane = new JScrollPane(progressArea);
progressScroll.setBorder(BorderFactory.createTitledBorder("Progress")); progressScrollPane.setBorder(BorderFactory.createTitledBorder("Progress"));
progressScroll.getVerticalScrollBar().setUnitIncrement(16); progressScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
progressScrollPane.getVerticalScrollBar().setUnitIncrement(16);
JPanel empty = new JPanel(); JPanel empty = new JPanel();
progressContainer.add(progressScroll, "show"); progressContainer.add(progressScrollPane, "show");
progressContainer.add(empty, "hide"); progressContainer.add(empty, "hide");
((CardLayout) progressContainer.getLayout()).show(progressContainer, "show"); ((CardLayout) progressContainer.getLayout()).show(progressContainer, "show");
@@ -259,10 +275,27 @@ final class CyberStrikeAITab implements ITab {
return split; return split;
} }
private static boolean isScrollNearBottom(JScrollPane scrollPane) {
if (scrollPane == null) {
return true;
}
JScrollBar bar = scrollPane.getVerticalScrollBar();
int max = Math.max(0, bar.getMaximum() - bar.getVisibleAmount());
return bar.getValue() >= max - SCROLL_FOLLOW_THRESHOLD_PX;
}
private static void scrollPaneToBottom(JScrollPane scrollPane) {
if (scrollPane == null) {
return;
}
JScrollBar bar = scrollPane.getVerticalScrollBar();
bar.setValue(bar.getMaximum());
}
private static void configureTextArea(JTextArea area, boolean monospaced) { private static void configureTextArea(JTextArea area, boolean monospaced) {
area.setEditable(false); area.setEditable(false);
area.setLineWrap(false); area.setLineWrap(true);
area.setWrapStyleWord(false); area.setWrapStyleWord(true);
if (monospaced) { if (monospaced) {
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
} else { } else {
@@ -381,24 +414,44 @@ final class CyberStrikeAITab implements ITab {
private void wireActions() { private void wireActions() {
validateButton.addActionListener(e -> { validateButton.addActionListener(e -> {
validateButton.setEnabled(false); if ("Cancel".equals(validateButton.getText())) {
cancelValidateInProgress();
return;
}
validateButton.setText("Cancel");
validateButton.setEnabled(true);
stopButton.setEnabled(true);
statusLabel.setText("Validating..."); statusLabel.setText("Validating...");
log("Validating connection..."); log("Validating connection... (max ~10s; click Cancel or Stop to abort)");
new Thread(() -> { Thread worker = new Thread(() -> {
try { try {
CyberStrikeAIClient.Config cfg = currentConfig(); CyberStrikeAIClient.Config cfg = currentConfig();
String token = client.loginAndValidate(cfg); String token = client.loginAndValidate(cfg);
if (Thread.currentThread().isInterrupted()) {
return;
}
tokenRef.set(token); tokenRef.set(token);
SwingUtilities.invokeLater(() -> statusLabel.setText("OK (token saved)")); SwingUtilities.invokeLater(() -> statusLabel.setText("OK (token saved)"));
log("Validation OK."); log("Validation OK.");
} catch (Exception ex) { } catch (Exception ex) {
tokenRef.set(""); tokenRef.set("");
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage())); if (Thread.currentThread().isInterrupted()) {
log("Validation failed: " + ex.getMessage()); SwingUtilities.invokeLater(() -> statusLabel.setText("Cancelled"));
log("Validation cancelled.");
} else {
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
log("Validation failed: " + ex.getMessage());
}
} finally { } finally {
SwingUtilities.invokeLater(() -> validateButton.setEnabled(true)); validateThreadRef.set(null);
SwingUtilities.invokeLater(() -> {
validateButton.setText("Validate");
validateButton.setEnabled(true);
});
} }
}, "CyberStrikeAI-Validate").start(); }, "CyberStrikeAI-Validate");
validateThreadRef.set(worker);
worker.start();
}); });
clearButton.addActionListener(e -> { clearButton.addActionListener(e -> {
@@ -435,10 +488,23 @@ final class CyberStrikeAITab implements ITab {
}); });
stopButton.addActionListener(e -> { stopButton.addActionListener(e -> {
if ("Cancel".equals(validateButton.getText())) {
cancelValidateInProgress();
return;
}
String runId = selectedRunId; String runId = selectedRunId;
if (runId != null && client.hasActiveRequest()) {
client.cancelActiveRequest();
appendProgressToRun(runId, "\n[info] Stream stopped.\n");
setRunStatus(runId, "cancelled");
return;
}
if (runId == null) return; if (runId == null) return;
TestRun run = runs.get(runId); TestRun run = runs.get(runId);
if (run == null) return; if (run == null) return;
String token = getToken(); String token = getToken();
if (token == null || token.trim().isEmpty()) { if (token == null || token.trim().isEmpty()) {
appendProgressToRun(runId, "\n[error] Not validated.\n"); appendProgressToRun(runId, "\n[error] Not validated.\n");
@@ -483,7 +549,8 @@ final class CyberStrikeAITab implements ITab {
String host = hostField.getText().trim(); String host = hostField.getText().trim();
String port = portField.getText().trim(); String port = portField.getText().trim();
String password = new String(passwordField.getPassword()); String password = new String(passwordField.getPassword());
String baseUrl = "http://" + host + ":" + port; String scheme = useHttpsBox.isSelected() ? "https" : "http";
String baseUrl = scheme + "://" + host + ":" + port;
int idx = agentModeBox.getSelectedIndex(); int idx = agentModeBox.getSelectedIndex();
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length) CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
? AGENT_MODES[idx] ? AGENT_MODES[idx]
@@ -567,10 +634,31 @@ final class CyberStrikeAITab implements ITab {
run.progressBuffer.append(s); run.progressBuffer.append(s);
} }
if (runId.equals(selectedRunId)) { if (runId.equals(selectedRunId)) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> appendProgressUi(s, false));
progressArea.append(s); }
progressArea.setCaretPosition(progressArea.getDocument().getLength()); }
});
private void appendProgressUi(String s, boolean forceFollow) {
JScrollBar bar = progressScrollPane != null ? progressScrollPane.getVerticalScrollBar() : null;
int scrollBefore = bar != null ? bar.getValue() : 0;
boolean follow = forceFollow || isScrollNearBottom(progressScrollPane);
progressArea.append(s);
if (follow) {
scrollPaneToBottom(progressScrollPane);
} else if (bar != null) {
bar.setValue(scrollBefore);
}
}
private void appendFinalUi(String s, boolean forceFollow) {
JScrollBar bar = finalRawScrollPane != null ? finalRawScrollPane.getVerticalScrollBar() : null;
int scrollBefore = bar != null ? bar.getValue() : 0;
boolean follow = forceFollow || isScrollNearBottom(finalRawScrollPane);
finalRawArea.append(s);
if (follow) {
scrollPaneToBottom(finalRawScrollPane);
} else if (bar != null) {
bar.setValue(scrollBefore);
} }
} }
@@ -620,10 +708,7 @@ final class CyberStrikeAITab implements ITab {
run.finalBuffer.append(s); run.finalBuffer.append(s);
} }
if (runId.equals(selectedRunId)) { if (runId.equals(selectedRunId)) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> appendFinalUi(s, false));
finalRawArea.append(s);
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
});
} }
} }
@@ -656,9 +741,9 @@ final class CyberStrikeAITab implements ITab {
} }
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
progressArea.setText(progress); progressArea.setText(progress);
progressArea.setCaretPosition(progressArea.getDocument().getLength()); scrollPaneToBottom(progressScrollPane);
finalRawArea.setText(fin); finalRawArea.setText(fin);
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength()); scrollPaneToBottom(finalRawScrollPane);
requestArea.setText(run.requestRaw == null ? "" : run.requestRaw); requestArea.setText(run.requestRaw == null ? "" : run.requestRaw);
responseArea.setText(run.responseRaw == null ? "" : run.responseRaw); responseArea.setText(run.responseRaw == null ? "" : run.responseRaw);
refreshOutputView(); refreshOutputView();
@@ -682,25 +767,36 @@ final class CyberStrikeAITab implements ITab {
void clearAndShowStreamHeader(String title) { void clearAndShowStreamHeader(String title) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
progressArea.setText(""); progressArea.setText("[*] " + title + "\n\n");
finalRawArea.setText(title + "\n\n"); finalRawArea.setText("");
markdownPane.setText("");
}); });
} }
// Legacy helpers kept for Validate logging // Legacy helpers kept for Validate logging
void appendStreamLine(String s) { void appendStreamLine(String s) {
if (s == null) return; if (s == null) return;
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> appendProgressUi(s + "\n", false));
progressArea.append(s);
progressArea.append("\n");
progressArea.setCaretPosition(progressArea.getDocument().getLength());
});
} }
private void log(String s) { private void log(String s) {
appendStreamLine("[*] " + s); appendStreamLine("[*] " + s);
} }
private void cancelValidateInProgress() {
client.cancelActiveRequest();
Thread t = validateThreadRef.getAndSet(null);
if (t != null) {
t.interrupt();
}
SwingUtilities.invokeLater(() -> {
statusLabel.setText("Cancelled");
validateButton.setText("Validate");
validateButton.setEnabled(true);
});
log("Validation cancelled.");
}
private void applyFilter() { private void applyFilter() {
String q = searchField.getText(); String q = searchField.getText();
if (q == null) q = ""; if (q == null) q = "";
@@ -0,0 +1,149 @@
package burp;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URL;
import java.security.cert.X509Certificate;
/**
* Opens HTTPS connections without validating server certificates (self-signed / local dev).
* Applied per-connection only; does not change JVM-wide defaults for other Burp components.
*/
final class SslTrustAll {
private static volatile SSLSocketFactory socketFactory;
private static final HostnameVerifier TRUST_ALL_HOSTS = (hostname, session) -> true;
private SslTrustAll() {
}
static HttpURLConnection open(URL url) throws IOException {
return open(url, 5_000, 30_000);
}
static HttpURLConnection open(URL url, int connectTimeoutMs, int readTimeoutMs) throws IOException {
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(connectTimeoutMs);
conn.setReadTimeout(readTimeoutMs);
if (conn instanceof HttpsURLConnection) {
HttpsURLConnection https = (HttpsURLConnection) conn;
https.setSSLSocketFactory(new TimeoutSslSocketFactory(socketFactory(), connectTimeoutMs, readTimeoutMs));
https.setHostnameVerifier(TRUST_ALL_HOSTS);
}
return conn;
}
private static SSLSocketFactory socketFactory() {
SSLSocketFactory sf = socketFactory;
if (sf != null) {
return sf;
}
synchronized (SslTrustAll.class) {
sf = socketFactory;
if (sf != null) {
return sf;
}
try {
TrustManager[] trustAll = new TrustManager[]{
new X509TrustManager() {
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
}
};
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, trustAll, new java.security.SecureRandom());
sf = ctx.getSocketFactory();
socketFactory = sf;
return sf;
} catch (Exception e) {
throw new RuntimeException("Failed to initialize trust-all TLS", e);
}
}
}
/** Ensures TCP connect + socket read respect timeouts (plain HttpURLConnection SSL can hang longer). */
private static final class TimeoutSslSocketFactory extends SSLSocketFactory {
private final SSLSocketFactory delegate;
private final int connectTimeoutMs;
private final int readTimeoutMs;
TimeoutSslSocketFactory(SSLSocketFactory delegate, int connectTimeoutMs, int readTimeoutMs) {
this.delegate = delegate;
this.connectTimeoutMs = connectTimeoutMs;
this.readTimeoutMs = readTimeoutMs;
}
@Override
public String[] getDefaultCipherSuites() {
return delegate.getDefaultCipherSuites();
}
@Override
public String[] getSupportedCipherSuites() {
return delegate.getSupportedCipherSuites();
}
@Override
public Socket createSocket() throws IOException {
return tune(delegate.createSocket());
}
@Override
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
return tune(delegate.createSocket(s, host, port, autoClose));
}
@Override
public Socket createSocket(String host, int port) throws IOException {
Socket plain = new Socket();
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
return tune(delegate.createSocket(plain, host, port, true));
}
@Override
public Socket createSocket(String host, int port, java.net.InetAddress localHost, int localPort) throws IOException {
Socket plain = new Socket();
plain.bind(new InetSocketAddress(localHost, localPort));
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
return tune(delegate.createSocket(plain, host, port, true));
}
@Override
public Socket createSocket(java.net.InetAddress host, int port) throws IOException {
Socket plain = new Socket();
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
return tune(delegate.createSocket(plain, host.getHostName(), port, true));
}
@Override
public Socket createSocket(java.net.InetAddress address, int port, java.net.InetAddress localAddress, int localPort) throws IOException {
Socket plain = new Socket();
plain.bind(new InetSocketAddress(localAddress, localPort));
plain.connect(new InetSocketAddress(address, port), connectTimeoutMs);
return tune(delegate.createSocket(plain, address.getHostName(), port, true));
}
private Socket tune(Socket socket) throws IOException {
socket.setSoTimeout(readTimeoutMs);
return socket;
}
}
}
@@ -1,12 +1,16 @@
burp/SslTrustAll.class
burp/SslTrustAll$TimeoutSslSocketFactory.class
burp/CyberStrikeAIClient$StreamListener.class burp/CyberStrikeAIClient$StreamListener.class
burp/CyberStrikeAIClient$Config.class burp/CyberStrikeAIClient$Config.class
burp/CyberStrikeAIClient$AgentMode.class burp/CyberStrikeAIClient$AgentMode.class
burp/MarkdownRenderer.class burp/MarkdownRenderer.class
burp/SimpleJson.class burp/SimpleJson.class
burp/CyberStrikeAIClient.class burp/CyberStrikeAIClient.class
burp/CyberStrikeAIClient$1.class
burp/CyberStrikeAITab$DotIcon.class burp/CyberStrikeAITab$DotIcon.class
burp/CyberStrikeAITab.class burp/CyberStrikeAITab.class
burp/CyberStrikeAITab$1.class burp/CyberStrikeAITab$1.class
burp/SslTrustAll$1.class
burp/BurpExtender$1.class burp/BurpExtender$1.class
burp/BurpExtender.class burp/BurpExtender.class
burp/CyberStrikeAITab$TestRun.class burp/CyberStrikeAITab$TestRun.class
@@ -4,3 +4,4 @@
/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/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/MarkdownRenderer.java
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SimpleJson.java /Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SimpleJson.java
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SslTrustAll.java
+35 -6
View File
@@ -64,6 +64,7 @@ show_progress() {
echo "" echo ""
echo "==========================================" echo "=========================================="
echo " CyberStrikeAI 一键部署启动脚本" echo " CyberStrikeAI 一键部署启动脚本"
echo " (默认 HTTPS 自签证书;纯 HTTP 请用: $0 --http"
echo "==========================================" echo "=========================================="
echo "" echo ""
@@ -353,7 +354,18 @@ need_rebuild() {
} }
# 主流程 # 主流程
# 默认启动主站 HTTPS(--https 传给二进制);传 --http 则走明文 HTTP。
main() { main() {
USE_HTTPS=1
FORWARD_ARGS=()
for arg in "$@"; do
if [ "$arg" = "--http" ]; then
USE_HTTPS=0
continue
fi
FORWARD_ARGS+=("$arg")
done
# 环境检查 # 环境检查
info "检查运行环境..." info "检查运行环境..."
check_python check_python
@@ -377,13 +389,30 @@ main() {
# 启动服务器 # 启动服务器
success "所有准备工作完成!" success "所有准备工作完成!"
echo "" echo ""
info "启动 CyberStrikeAI 服务器..." if [ "$USE_HTTPS" -eq 1 ]; then
info "启动 CyberStrikeAI 服务器(HTTPS + HTTP/2,自签证书)..."
note "纯 HTTP 启动请使用: $0 --http"
else
info "启动 CyberStrikeAI 服务器(HTTP..."
fi
echo "==========================================" echo "=========================================="
echo "" echo ""
# 运行服务器 # 始终传入项目根目录下的 config.yaml,避免 cwd 不在项目根时找不到配置;额外参数仍可追加(如再次 -config 覆盖,以 Go flag 后写为准)。
exec "./$BINARY_NAME" if [ "$USE_HTTPS" -eq 1 ]; then
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https "${FORWARD_ARGS[@]}"
else
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https
fi
else
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
exec "./$BINARY_NAME" -config "$CONFIG_FILE" "${FORWARD_ARGS[@]}"
else
exec "./$BINARY_NAME" -config "$CONFIG_FILE"
fi
fi
} }
# 执行主流程 # 执行主流程(支持参数,如: ./run.sh --http
main main "$@"
+333 -1
View File
@@ -440,6 +440,230 @@ args:
print("Body: <empty>") print("Body: <empty>")
def compile_response_filter(pattern: str, ignore_case: bool):
flags = 0
if ignore_case:
flags |= re.IGNORECASE
try:
return re.compile(pattern, flags)
except re.error as exc:
print(f"Invalid response_filter regex: {exc}", file=sys.stderr)
sys.exit(2)
def truncate_utf8(text: str, max_bytes: int) -> Tuple[str, bool]:
if max_bytes <= 0 or not text:
return text, False
encoded = text.encode("utf-8", errors="replace")
if len(encoded) <= max_bytes:
return text, False
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
return truncated, True
def cap_line_entries(entries: List[Tuple[int, str]], max_lines: int) -> Tuple[List[Tuple[int, str]], bool]:
if max_lines <= 0 or len(entries) <= max_lines:
return entries, False
return entries[:max_lines], True
def expand_line_context(line_numbers: List[int], total_lines: int, context: int) -> List[int]:
if context <= 0:
return sorted(set(line_numbers))
included = set()
for num in line_numbers:
start = max(1, num - context)
end = min(total_lines, num + context)
for i in range(start, end + 1):
included.add(i)
return sorted(included)
def format_line_entries(lines: List[str], indices: List[int], ellipsis_gaps: bool = True) -> str:
if not indices:
return ""
chunks = []
prev = None
for num in indices:
if ellipsis_gaps and prev is not None and num > prev + 1:
chunks.append(" ...")
chunks.append(f" L{num}: {lines[num - 1]}")
prev = num
return "\n".join(chunks)
def filter_body_by_lines(
lines: List[str],
compiled: "re.Pattern",
invert: bool,
context_lines: int,
max_lines: int,
) -> Tuple[str, Dict[str, object]]:
matched_nums = []
for idx, line in enumerate(lines, start=1):
hit = compiled.search(line) is not None
if invert:
hit = not hit
if hit:
matched_nums.append(idx)
total = len(lines)
meta = {
"mode": "line",
"total_lines": total,
"matched_lines": len(matched_nums),
"invert": invert,
"truncated": False,
"byte_truncated": False,
}
if not matched_nums:
return "", meta
display_nums = expand_line_context(matched_nums, total, context_lines)
entries = [(n, lines[n - 1]) for n in display_nums]
entries, line_capped = cap_line_entries(entries, max_lines)
meta["truncated"] = line_capped
meta["display_lines"] = len(entries)
return format_line_entries(lines, [n for n, _ in entries], ellipsis_gaps=context_lines > 0), meta
def filter_body_multiline(
text: str,
compiled: "re.Pattern",
invert: bool,
max_lines: int,
dotall: bool,
) -> Tuple[str, Dict[str, object]]:
flags = compiled.flags
if dotall:
pattern = re.compile(compiled.pattern, flags | re.DOTALL | re.MULTILINE)
else:
pattern = re.compile(compiled.pattern, flags | re.MULTILINE)
matches = list(pattern.finditer(text))
if invert:
if matches:
return "", {"mode": "multiline" if not dotall else "full", "total_lines": text.count("\n") + (1 if text else 0), "matched_lines": 0, "invert": True, "truncated": False, "byte_truncated": False}
output = text
meta = {"mode": "multiline" if not dotall else "full", "matched_lines": 1, "invert": True, "truncated": False, "byte_truncated": False}
lines = text.splitlines()
if max_lines > 0 and len(lines) > max_lines:
output = "\n".join(lines[:max_lines])
meta["truncated"] = True
meta["total_lines"] = len(lines)
meta["display_lines"] = min(len(lines), max_lines) if max_lines > 0 else len(lines)
return output, meta
chunks = []
for match in matches:
snippet = match.group(0)
if "\n" in snippet:
snippet = snippet.replace("\n", "\\n")
start_line = text.count("\n", 0, match.start()) + 1
chunks.append((start_line, f" @{start_line}: {snippet}"))
entries, line_capped = cap_line_entries(chunks, max_lines if max_lines > 0 else len(chunks))
meta = {
"mode": "multiline" if not dotall else "full",
"total_lines": text.count("\n") + (1 if text else 0),
"matched_lines": len(matches),
"invert": False,
"truncated": line_capped,
"byte_truncated": False,
"display_lines": len(entries),
}
return "\n".join(line for _, line in entries), meta
def apply_body_limits_plain(text: str, max_lines: int) -> Tuple[str, Dict[str, object]]:
lines = text.splitlines()
meta = {
"mode": "plain",
"total_lines": len(lines),
"matched_lines": len(lines),
"invert": False,
"truncated": False,
"byte_truncated": False,
"display_lines": len(lines),
}
output = text
if max_lines > 0 and len(lines) > max_lines:
output = "\n".join(lines[:max_lines])
meta["truncated"] = True
meta["display_lines"] = max_lines
return output, meta
def format_response_body_output(
decoded_body: str,
filter_pattern: str,
filter_mode: str,
filter_invert: bool,
filter_ignore_case: bool,
max_lines: int,
max_bytes: int,
preview_lines: int,
context_lines: int,
compiled_filter=None,
) -> Tuple[str, Dict[str, object]]:
text = decoded_body.rstrip("\r\n")
if not text:
return "", {"mode": "empty", "total_lines": 0, "matched_lines": 0, "invert": filter_invert, "truncated": False, "byte_truncated": False, "display_lines": 0}
lines = text.splitlines()
mode = (filter_mode or "line").strip().lower()
if mode not in {"line", "multiline", "full"}:
mode = "line"
if filter_pattern:
compiled = compiled_filter or compile_response_filter(filter_pattern, filter_ignore_case)
if mode == "line":
output, meta = filter_body_by_lines(lines, compiled, filter_invert, context_lines, max_lines)
else:
output, meta = filter_body_multiline(text, compiled, filter_invert, max_lines, dotall=(mode == "full"))
meta["filter_pattern"] = filter_pattern
if not output and not filter_invert:
preview = min(max(preview_lines, 0), len(lines))
if preview > 0:
preview_text = format_line_entries(lines, list(range(1, preview + 1)), ellipsis_gaps=False)
preview_text, byte_truncated = truncate_utf8(preview_text, max_bytes)
return preview_text, {
**meta,
"preview": True,
"matched_lines": 0,
"display_lines": preview,
"byte_truncated": byte_truncated,
}
return "", {**meta, "preview": False, "matched_lines": 0, "display_lines": 0}
else:
output, meta = apply_body_limits_plain(text, max_lines)
output, byte_truncated = truncate_utf8(output, max_bytes)
if byte_truncated:
meta["byte_truncated"] = True
return output, meta
def print_response_body_summary(meta: Dict[str, object]):
mode = meta.get("mode")
if mode == "empty":
return
parts = [f"mode={mode}"]
if meta.get("filter_pattern"):
parts.append(f"pattern={meta['filter_pattern']!r}")
if meta.get("invert"):
parts.append("invert=true")
total = meta.get("total_lines")
matched = meta.get("matched_lines")
displayed = meta.get("display_lines")
if total is not None and matched is not None:
parts.append(f"matched {matched}/{total} lines")
if displayed is not None:
parts.append(f"showing {displayed}")
if meta.get("preview"):
parts.append("preview on zero match")
if meta.get("truncated"):
parts.append("line cap applied")
if meta.get("byte_truncated"):
parts.append("byte cap applied")
print(f"[body] {' | '.join(parts)}")
def main(): def main():
parser = argparse.ArgumentParser(description="Pure Python HTTP testing helper powered by httpx") parser = argparse.ArgumentParser(description="Pure Python HTTP testing helper powered by httpx")
parser.add_argument("--url", required=True) parser.add_argument("--url", required=True)
@@ -466,6 +690,16 @@ args:
parser.add_argument("--debug", dest="debug", action="store_true") parser.add_argument("--debug", dest="debug", action="store_true")
parser.add_argument("--response-encoding", dest="response_encoding", default="") parser.add_argument("--response-encoding", dest="response_encoding", default="")
parser.add_argument("--download", dest="download", default="") parser.add_argument("--download", dest="download", default="")
parser.add_argument("--response-filter", dest="response_filter", default="")
parser.add_argument("--response-filter-mode", dest="response_filter_mode", default="line")
parser.add_argument("--response-filter-invert", dest="response_filter_invert", action="store_true")
parser.add_argument("--no-response-filter-invert", dest="response_filter_invert", action="store_false")
parser.add_argument("--response-filter-ignore-case", dest="response_filter_ignore_case", action="store_true")
parser.add_argument("--no-response-filter-ignore-case", dest="response_filter_ignore_case", action="store_false")
parser.add_argument("--response-max-lines", dest="response_max_lines", type=int, default=0)
parser.add_argument("--response-max-bytes", dest="response_max_bytes", type=int, default=0)
parser.add_argument("--response-preview-lines", dest="response_preview_lines", type=int, default=5)
parser.add_argument("--response-context-lines", dest="response_context_lines", type=int, default=0)
parser.set_defaults( parser.set_defaults(
include_headers=False, include_headers=False,
auto_encode_url=False, auto_encode_url=False,
@@ -475,9 +709,22 @@ args:
show_command=False, show_command=False,
show_summary=False, show_summary=False,
debug=False, debug=False,
response_filter_invert=False,
response_filter_ignore_case=False,
) )
args = parser.parse_args() args = parser.parse_args()
response_filter = (args.response_filter or "").strip()
response_max_lines = max(0, args.response_max_lines or 0)
response_max_bytes = max(0, args.response_max_bytes or 0)
response_preview_lines = max(0, args.response_preview_lines if args.response_preview_lines is not None else 5)
response_context_lines = max(0, args.response_context_lines or 0)
compiled_response_filter = None
if response_filter:
compiled_response_filter = compile_response_filter(
response_filter, args.response_filter_ignore_case
)
repeat = max(1, args.repeat) repeat = max(1, args.repeat)
try: try:
delay_between = float(args.delay or "0") delay_between = float(args.delay or "0")
@@ -648,9 +895,37 @@ args:
for key, value in response.headers.items(): for key, value in response.headers.items():
print(f"{key}: {value}") print(f"{key}: {value}")
print("") print("")
output_body = decoded_body.rstrip() output_body, body_output_meta = format_response_body_output(
decoded_body,
response_filter,
args.response_filter_mode,
args.response_filter_invert,
args.response_filter_ignore_case,
response_max_lines,
response_max_bytes,
response_preview_lines,
response_context_lines,
compiled_filter=compiled_response_filter,
)
has_filter_or_cap = bool(
response_filter or response_max_lines > 0 or response_max_bytes > 0
)
if has_filter_or_cap and body_output_meta.get("mode") != "empty":
print_response_body_summary(body_output_meta)
if body_output_meta.get("preview") and not body_output_meta.get("matched_lines"):
print("[body] no regex match; showing preview:")
if output_body: if output_body:
print(output_body) print(output_body)
if body_output_meta.get("truncated") or body_output_meta.get("byte_truncated"):
omitted = (body_output_meta.get("total_lines") or 0) - (
body_output_meta.get("display_lines") or 0
)
if omitted > 0:
print(f"[body] ... {omitted} more line(s) omitted (use --download for full body)")
elif body_output_meta.get("mode") == "empty":
print("[no body]")
elif response_filter and not body_output_meta.get("preview"):
print("[body] no lines matched filter")
else: else:
print("[no body]") print("[no body]")
@@ -729,6 +1004,13 @@ description: |
- 连接探针:在无代理场景下额外进行 DNS/TCP/TLS 探测,粗粒度复刻 curl -w 指标 - 连接探针:在无代理场景下额外进行 DNS/TCP/TLS 探测,粗粒度复刻 curl -w 指标
- 可重复观测:repeat/delay + TTFB/total/speed_download 统计,便于盲注/时序测试 - 可重复观测:repeat/delay + TTFB/total/speed_download 统计,便于盲注/时序测试
- 扩展开关:additional_args 解析 http2/cert/verify/trust_env/max_redirects 等 httpx 选项 - 扩展开关:additional_args 解析 http2/cert/verify/trust_env/max_redirects 等 httpx 选项
- 响应体瘦身:response_filter 按行/块正则提取,配合 max_lines/max_bytes 限制 stdout,降低 Agent token 消耗
**响应过滤最佳实践:**
- 大页面/HTML:用 `response_filter` 抓 error|exception|password|token|uid 等关键字行
- 无 filter 时:设 `response_max_lines=80` 或 `response_max_bytes=8192` 防止整页灌入上下文
- 0 命中:自动预览前 `response_preview_lines` 行,避免误判「空响应」
- 完整留存:大 body 用 `download` 落盘,stdout 只保留摘要行
parameters: parameters:
- name: "url" - name: "url"
type: "string" type: "string"
@@ -836,6 +1118,56 @@ parameters:
description: "强制响应解码使用的编码(如GBK),覆盖自动探测" description: "强制响应解码使用的编码(如GBK),覆盖自动探测"
required: false required: false
flag: "--response-encoding" flag: "--response-encoding"
- name: "response_filter"
type: "string"
description: |
响应体正则过滤(仅影响 stdout,不影响 --download 与指标)。
默认 line 模式按行匹配;示例:'(error|exception|SQL|password|token|uid)'。
与 response_max_lines/response_max_bytes 配合可显著减少 token 消耗。
required: false
flag: "--response-filter"
- name: "response_filter_mode"
type: "string"
description: "过滤模式:line(按行,默认)、multiline(跨行块)、full(整段 DOTALL 匹配)"
required: false
default: "line"
flag: "--response-filter-mode"
- name: "response_filter_invert"
type: "bool"
description: "反向过滤:输出不匹配 regex 的行(用于剔除 HTML 噪音)"
required: false
default: false
flag: "--response-filter-invert"
- name: "response_filter_ignore_case"
type: "bool"
description: "正则忽略大小写"
required: false
default: false
flag: "--response-filter-ignore-case"
- name: "response_max_lines"
type: "int"
description: "stdout 最多输出行数(0=不限制);有 filter 时限制命中行数,无 filter 时截断全文"
required: false
default: 0
flag: "--response-max-lines"
- name: "response_max_bytes"
type: "int"
description: "stdout 响应体 UTF-8 字节上限(0=不限制),超出部分截断"
required: false
default: 0
flag: "--response-max-bytes"
- name: "response_preview_lines"
type: "int"
description: "filter 零命中时预览的前 N 行(默认 5,0=不预览)"
required: false
default: 5
flag: "--response-preview-lines"
- name: "response_context_lines"
type: "int"
description: "line 模式下命中行上下各保留 N 行上下文(类似 grep -C"
required: false
default: 0
flag: "--response-context-lines"
- name: "action" - name: "action"
type: "string" type: "string"
description: "保留字段:标识调用意图(request, spider等),脚本内部不使用" description: "保留字段:标识调用意图(request, spider等),脚本内部不使用"
+8 -23
View File
@@ -8,11 +8,8 @@ set -euo pipefail
# - data/ # - data/
# - venv/ (disabled with --no-venv) # - venv/ (disabled with --no-venv)
# - tools/ (user extensions; never overwritten by upgrade) # - tools/ (user extensions; never overwritten by upgrade)
#
# Optional preserves (may overwrite upstream updates):
# - roles/ # - roles/
# - skills/ # - skills/
# Enable with --preserve-custom
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
@@ -28,7 +25,6 @@ BACKUP_BASE_DIR="$ROOT_DIR/.upgrade-backup"
GITHUB_REPO="Ed1s0nZ/CyberStrikeAI" GITHUB_REPO="Ed1s0nZ/CyberStrikeAI"
TAG="" TAG=""
PRESERVE_CUSTOM=0
PRESERVE_VENV=1 PRESERVE_VENV=1
STOP_SERVICE=1 STOP_SERVICE=1
FORCE_STOP=0 FORCE_STOP=0
@@ -37,14 +33,12 @@ YES=0
usage() { usage() {
cat <<EOF cat <<EOF
Usage: Usage:
./upgrade.sh [--tag vX.Y.Z] [--preserve-custom] [--no-venv] [--no-stop] ./upgrade.sh [--tag vX.Y.Z] [--no-venv] [--no-stop]
[--force-stop] [--yes] [--force-stop] [--yes]
Options: Options:
--tag <tag> Specify GitHub Release tag (e.g. v1.3.28). --tag <tag> Specify GitHub Release tag (e.g. v1.3.28).
If omitted, the script uses the latest release. If omitted, the script uses the latest release.
--preserve-custom Preserve roles/skills (may overwrite upstream files).
tools/ is always preserved. Use with caution.
--no-venv Do not preserve venv/ (Python deps will be re-installed). --no-venv Do not preserve venv/ (Python deps will be re-installed).
--no-stop Do not try to stop the running service. --no-stop Do not try to stop the running service.
--force-stop If no process matching current directory is found, also stop --force-stop If no process matching current directory is found, also stop
@@ -52,7 +46,7 @@ Options:
--yes Do not ask for confirmation. --yes Do not ask for confirmation.
Description: Description:
The script backs up config.yaml/data/tools/ (and optionally venv/roles/skills) to The script backs up config.yaml/data/tools/roles/skills/ (and optionally venv/) to
.upgrade-backup/ .upgrade-backup/
EOF EOF
} }
@@ -177,11 +171,7 @@ confirm_or_exit() {
info " - Preserve venv/: no (will remove old venv and re-install deps)" info " - Preserve venv/: no (will remove old venv and re-install deps)"
fi fi
info " - Preserve tools/: yes (always)" info " - Preserve tools/: yes (always)"
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then info " - Preserve roles/skills: yes (always)"
info " - Preserve roles/skills: yes (may overwrite upstream updates)"
else
info " - Preserve roles/skills: no (will use upstream versions)"
fi
info " - Stop service: ${STOP_SERVICE}" info " - Stop service: ${STOP_SERVICE}"
echo "" echo ""
read -r -p "Continue? (y/N) " ans read -r -p "Continue? (y/N) " ans
@@ -299,11 +289,8 @@ sync_code() {
# User tool extensions: never replace or delete during upgrade. # User tool extensions: never replace or delete during upgrade.
rsync_excludes+=( "--exclude=tools/" ) rsync_excludes+=( "--exclude=tools/" )
rsync_excludes+=( "--exclude=roles/" )
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then rsync_excludes+=( "--exclude=skills/" )
rsync_excludes+=( "--exclude=roles/" )
rsync_excludes+=( "--exclude=skills/" )
fi
# Ensure this upgrade script itself is not deleted. # Ensure this upgrade script itself is not deleted.
rsync_excludes+=( "--exclude=upgrade.sh" ) rsync_excludes+=( "--exclude=upgrade.sh" )
@@ -324,10 +311,6 @@ main() {
TAG="${2:-}" TAG="${2:-}"
shift 2 shift 2
;; ;;
--preserve-custom)
PRESERVE_CUSTOM=1
shift 1
;;
--no-venv) --no-venv)
PRESERVE_VENV=0 PRESERVE_VENV=0
shift 1 shift 1
@@ -384,8 +367,10 @@ main() {
if [[ -d "$ROOT_DIR/tools" ]]; then if [[ -d "$ROOT_DIR/tools" ]]; then
backup_dir_tgz "tools" "$ROOT_DIR/tools" backup_dir_tgz "tools" "$ROOT_DIR/tools"
fi fi
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then if [[ -d "$ROOT_DIR/roles" ]]; then
backup_dir_tgz "roles" "$ROOT_DIR/roles" backup_dir_tgz "roles" "$ROOT_DIR/roles"
fi
if [[ -d "$ROOT_DIR/skills" ]]; then
backup_dir_tgz "skills" "$ROOT_DIR/skills" backup_dir_tgz "skills" "$ROOT_DIR/skills"
fi fi
+34 -3
View File
@@ -260,8 +260,14 @@
gap: 12px; gap: 12px;
justify-content: center; justify-content: center;
flex-wrap: wrap; flex-wrap: wrap;
max-width: 420px;
margin-inline: auto;
} }
.c2-actions > button {
flex: 1;
min-width: min(100%, 160px);
}
/* ============================================================================ /* ============================================================================
Listener Cards Listener Cards
============================================================================ */ ============================================================================ */
@@ -851,10 +857,35 @@
background: var(--c2-surface); background: var(--c2-surface);
border-radius: var(--c2-radius); border-radius: var(--c2-radius);
border: 1px solid var(--c2-border); border: 1px solid var(--c2-border);
overflow: hidden; overflow-x: auto;
overflow-y: visible;
} }
.c2-task-table { width: 100%; border-collapse: collapse; } /* 操作列:仅占按钮宽度,避免 100% 表格把余白摊到最右列 */
.c2-task-table th.c2-task-table-col-actions,
.c2-task-table td.c2-task-table-col-actions {
width: 1%;
white-space: nowrap;
text-align: right;
vertical-align: middle;
}
.c2-task-table-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: nowrap;
}
.c2-task-table-actions .btn-small,
.c2-task-table-actions .btn-sm {
min-height: 30px;
min-width: 52px;
justify-content: center;
}
.c2-task-table { width: 100%; border-collapse: collapse; table-layout: auto; }
.c2-task-table th { .c2-task-table th {
text-align: left; text-align: left;
@@ -1255,7 +1286,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 10050;
padding: 24px; padding: 24px;
animation: c2-fade-in 0.15s ease-out; animation: c2-fade-in 0.15s ease-out;
} }
+2208 -130
View File
File diff suppressed because it is too large Load Diff
+62 -1
View File
@@ -77,6 +77,7 @@
"settings": "System settings", "settings": "System settings",
"hitl": "Human-in-the-loop", "hitl": "Human-in-the-loop",
"c2": "C2", "c2": "C2",
"c2Manage": "C2 management",
"c2Listeners": "Listeners", "c2Listeners": "Listeners",
"c2Sessions": "Sessions", "c2Sessions": "Sessions",
"c2Tasks": "Tasks", "c2Tasks": "Tasks",
@@ -146,6 +147,7 @@
"active": "Active", "active": "Active",
"highFreq": "High frequency", "highFreq": "High frequency",
"noCallData": "No call data", "noCallData": "No call data",
"severityClickHint": "Click to view",
"lastUpdated": "Last updated", "lastUpdated": "Last updated",
"viewAll": "View all →", "viewAll": "View all →",
"recentVulns": "Recent vulnerabilities", "recentVulns": "Recent vulnerabilities",
@@ -834,7 +836,32 @@
}, },
"robots": { "robots": {
"title": "Bot settings", "title": "Bot settings",
"description": "Configure WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.", "description": "Configure WeChat (iLink), WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
"wechat": {
"title": "WeChat / iLink",
"subtitle": "Bind personal WeChat via QR code and chat with CyberStrikeAI on your phone",
"statusIdle": "Not bound",
"statusBound": "Connected",
"statusScanning": "Binding…",
"step1": "Generate QR",
"step2": "Scan in WeChat",
"step3": "Confirm",
"enabled": "Enable WeChat bot",
"bindButton": "Generate QR code and bind",
"bindHint": "Scan with WeChat to confirm; settings are saved automatically.",
"qrLoading": "Generating QR code…",
"verifyCodeLabel": "Code on your phone (only if WeChat asks)",
"rebindButton": "Re-bind",
"boundBotId": "Bound Bot ID: ",
"verifyCodeSubmit": "Submit",
"advanced": "Advanced settings",
"baseUrl": "API Base URL",
"botType": "Bot Type",
"botAgent": "Bot Agent",
"ilinkBotId": "iLink Bot ID (filled after bind)",
"boundSuccess": "Binding successful. WeChat bot is enabled.",
"openLink": "QR not showing? Open link in WeChat on your phone"
},
"wecom": { "wecom": {
"title": "WeCom", "title": "WeCom",
"enabled": "Enable WeCom bot", "enabled": "Enable WeCom bot",
@@ -1304,6 +1331,35 @@
"noCallsYet": "No calls yet", "noCallsYet": "No calls yet",
"unknownTool": "Unknown tool", "unknownTool": "Unknown tool",
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate", "successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
"topToolsTitle": "Top {{n}} tools by calls",
"barVolumeLegend": "Bar length: relative call volume; green/red: success vs failure share",
"clickToFilterTool": "Click a row to filter records below",
"toolRowAriaLabel": "{{name}}, {{total}} calls, {{rate}}% success rate. Click to filter records.",
"successRateAria": "Success rate {{rate}}%",
"filterByToolTitle": "Filtered by: {{tool}}",
"clearToolFilter": "Clear tool filter",
"successCount": "Success {{n}}",
"failedCount": "Failed {{n}}",
"rateHealthy": "Running smoothly",
"rateWarning": "Some failures detected",
"rateCritical": "High failure rate",
"statsSubtitle": "Refreshed {{time}} · {{count}} tools",
"distTitle": "Call distribution",
"distLegend": "Slice area shows share of all calls",
"distClickHint": "Click legend or slice to filter records",
"distHeaderHint": "{{n}} total calls",
"distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times",
"distOthersNoFilter": "Other tools cannot be filtered individually",
"distTotalCalls": "{{n}} total calls",
"distTop6Share": "Top {{n}} share of all calls",
"distOthers": "Other tools",
"distCallsUnit": "{{n}} calls",
"riskTitle": "Failure alerts",
"riskNone": "No recent failures",
"riskItem": "{{name}}: {{failed}} / {{total}} failed",
"selectedToolTitle": "Active filter",
"selectedToolEmpty": "Click a tool on the left to filter records below",
"selectedToolStats": "{{total}} calls · {{success}} ok · {{failed}} failed · {{rate}}% success",
"columnTool": "Tool", "columnTool": "Tool",
"columnStatus": "Status", "columnStatus": "Status",
"columnStartTime": "Start time", "columnStartTime": "Start time",
@@ -1484,6 +1540,11 @@
}, },
"vulnerabilityPage": { "vulnerabilityPage": {
"statTotal": "Total", "statTotal": "Total",
"statClickAll": "View all (clear severity filter)",
"statClickFilter": "Click to filter by this severity; click again to clear",
"advancedFilters": "Advanced filters",
"activeFilters": "Active filters",
"chipRemove": "Remove",
"filter": "Filter", "filter": "Filter",
"clear": "Clear", "clear": "Clear",
"vulnId": "Vuln ID", "vulnId": "Vuln ID",
+62 -1
View File
@@ -77,6 +77,7 @@
"settings": "系统设置", "settings": "系统设置",
"hitl": "人机协同", "hitl": "人机协同",
"c2": "C2", "c2": "C2",
"c2Manage": "C2 管理",
"c2Listeners": "监听器", "c2Listeners": "监听器",
"c2Sessions": "会话", "c2Sessions": "会话",
"c2Tasks": "任务", "c2Tasks": "任务",
@@ -146,6 +147,7 @@
"active": "活跃", "active": "活跃",
"highFreq": "高频", "highFreq": "高频",
"noCallData": "暂无调用数据", "noCallData": "暂无调用数据",
"severityClickHint": "点击查看",
"lastUpdated": "上次更新", "lastUpdated": "上次更新",
"viewAll": "查看全部 →", "viewAll": "查看全部 →",
"recentVulns": "最近漏洞", "recentVulns": "最近漏洞",
@@ -823,7 +825,32 @@
}, },
"robots": { "robots": {
"title": "机器人设置", "title": "机器人设置",
"description": "配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。", "description": "配置微信、企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
"wechat": {
"title": "微信 / iLink",
"subtitle": "扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话",
"statusIdle": "未绑定",
"statusBound": "已连接",
"statusScanning": "绑定中…",
"step1": "生成二维码",
"step2": "微信扫码",
"step3": "确认绑定",
"enabled": "启用微信机器人",
"bindButton": "生成二维码并绑定",
"bindHint": "用微信扫码确认后会自动保存并启用。",
"qrLoading": "正在生成二维码…",
"verifyCodeLabel": "手机显示的数字(仅部分账号需要)",
"rebindButton": "重新绑定",
"boundBotId": "已绑定 Bot ID",
"verifyCodeSubmit": "提交",
"advanced": "高级设置",
"baseUrl": "API Base URL",
"botType": "Bot Type",
"botAgent": "Bot Agent",
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
"boundSuccess": "绑定成功,微信机器人已启用。",
"openLink": "无法显示二维码?点击用手机微信打开链接"
},
"wecom": { "wecom": {
"title": "企业微信", "title": "企业微信",
"enabled": "启用企业微信机器人", "enabled": "启用企业微信机器人",
@@ -1293,6 +1320,35 @@
"noCallsYet": "暂无调用", "noCallsYet": "暂无调用",
"unknownTool": "未知工具", "unknownTool": "未知工具",
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%", "successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
"topToolsTitle": "工具调用 Top {{n}}",
"barVolumeLegend": "条长表示相对调用量,条内绿/红为成功/失败占比",
"clickToFilterTool": "点击行筛选下方执行记录",
"toolRowAriaLabel": "{{name}}{{total}} 次调用,成功率 {{rate}}%,点击查看执行记录",
"successRateAria": "成功率 {{rate}}%",
"filterByToolTitle": "筛选工具:{{tool}}",
"clearToolFilter": "清除工具筛选",
"successCount": "成功 {{n}}",
"failedCount": "失败 {{n}}",
"rateHealthy": "运行平稳",
"rateWarning": "存在失败调用",
"rateCritical": "失败率偏高",
"statsSubtitle": "最后刷新 {{time}} · 共 {{count}} 个工具",
"distTitle": "调用分布",
"distLegend": "扇区面积为占全部调用比例",
"distClickHint": "点击图例或扇区筛选执行记录",
"distHeaderHint": "共 {{n}} 次调用",
"distSegmentAria": "{{name}},占 {{pct}}%{{calls}} 次",
"distOthersNoFilter": "其他工具无法单独筛选",
"distTotalCalls": "共 {{n}} 次调用",
"distTop6Share": "Top {{n}} 占全部调用",
"distOthers": "其他工具",
"distCallsUnit": "{{n}} 次",
"riskTitle": "失败提醒",
"riskNone": "近期无失败调用",
"riskItem": "{{name}}:失败 {{failed}} / {{total}} 次",
"selectedToolTitle": "当前筛选",
"selectedToolEmpty": "点击左侧工具行,可筛选下方执行记录",
"selectedToolStats": "调用 {{total}} 次 · 成功 {{success}} · 失败 {{failed}} · 成功率 {{rate}}%",
"columnTool": "工具", "columnTool": "工具",
"columnStatus": "状态", "columnStatus": "状态",
"columnStartTime": "开始时间", "columnStartTime": "开始时间",
@@ -1473,6 +1529,11 @@
}, },
"vulnerabilityPage": { "vulnerabilityPage": {
"statTotal": "总漏洞数", "statTotal": "总漏洞数",
"statClickAll": "查看全部(清除严重度筛选)",
"statClickFilter": "点击按此严重度筛选;再次点击清除",
"advancedFilters": "高级筛选",
"activeFilters": "已选条件",
"chipRemove": "移除",
"filter": "筛选", "filter": "筛选",
"clear": "清除", "clear": "清除",
"vulnId": "漏洞ID", "vulnId": "漏洞ID",
+13 -8
View File
@@ -342,22 +342,27 @@ function formatMarkdown(text) {
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'], ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
ALLOW_DATA_ATTR: false, ALLOW_DATA_ATTR: false,
}; };
const raw = text == null ? '' : String(text);
const src = typeof window.normalizeAssistantMarkdownSource === 'function'
? window.normalizeAssistantMarkdownSource(raw)
: raw;
if (typeof DOMPurify !== 'undefined') { if (typeof DOMPurify !== 'undefined') {
if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(text)) { if (typeof marked !== 'undefined' && !/<[a-z][\s\S]*>/i.test(src)) {
try { try {
marked.setOptions({ marked.setOptions({
breaks: true, breaks: true,
gfm: true, gfm: true,
}); });
let parsedContent = marked.parse(text); const parsedContent = marked.parse(src, { async: false });
return DOMPurify.sanitize(parsedContent, sanitizeConfig); return DOMPurify.sanitize(parsedContent, sanitizeConfig);
} catch (e) { } catch (e) {
console.error('Markdown 解析失败:', e); console.error('Markdown 解析失败:', e);
return DOMPurify.sanitize(text, sanitizeConfig); return DOMPurify.sanitize(src, sanitizeConfig);
} }
} else { } else {
return DOMPurify.sanitize(text, sanitizeConfig); return DOMPurify.sanitize(src, sanitizeConfig);
} }
} else if (typeof marked !== 'undefined') { } else if (typeof marked !== 'undefined') {
try { try {
@@ -365,13 +370,13 @@ function formatMarkdown(text) {
breaks: true, breaks: true,
gfm: true, gfm: true,
}); });
return marked.parse(text); return marked.parse(src, { async: false });
} catch (e) { } catch (e) {
console.error('Markdown 解析失败:', e); console.error('Markdown 解析失败:', e);
return escapeHtml(text).replace(/\n/g, '<br>'); return escapeHtml(src).replace(/\n/g, '<br>');
} }
} else { } else {
return escapeHtml(text).replace(/\n/g, '<br>'); return escapeHtml(src).replace(/\n/g, '<br>');
} }
} }
+48 -20
View File
@@ -151,6 +151,25 @@
return div.innerHTML; return div.innerHTML;
} }
/** 任务列表操作按钮(查看/取消/删除)— 事件委托 */
function bindC2TaskActionDelegation() {
if (document.documentElement.dataset.c2TaskActionsBound === '1') return;
document.documentElement.dataset.c2TaskActionsBound = '1';
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-c2-task-action]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const action = btn.getAttribute('data-c2-task-action');
const id = btn.getAttribute('data-task-id');
if (!id) return;
if (action === 'view') C2.viewTask(id);
else if (action === 'cancel') C2.cancelTask(id);
else if (action === 'delete') C2.deleteTaskById(id);
});
}
bindC2TaskActionDelegation();
/** 监听器表单:Malleable Profile 下拉选项 HTMLvalue / 文本已转义) */ /** 监听器表单:Malleable Profile 下拉选项 HTMLvalue / 文本已转义) */
function listenerProfileSelectHtml(selectedProfileId) { function listenerProfileSelectHtml(selectedProfileId) {
const sel = selectedProfileId ? String(selectedProfileId) : ''; const sel = selectedProfileId ? String(selectedProfileId) : '';
@@ -1293,14 +1312,17 @@
return; return;
} }
container.innerHTML = tasks.map(t => ` container.innerHTML = tasks.map(t => {
const rawId = t.id || '';
return `
<div class="c2-task-item-compact"> <div class="c2-task-item-compact">
<span class="c2-task-status-dot ${t.status}"></span> <span class="c2-task-status-dot ${escapeHtml(t.status || '')}"></span>
<span class="c2-task-type">${t.taskType}</span> <span class="c2-task-type">${escapeHtml(t.taskType || '')}</span>
<span class="c2-task-meta">${escapeHtml(taskStatusLabel(t.status))} | ${formatDuration(t.durationMs)}</span> <span class="c2-task-meta">${escapeHtml(taskStatusLabel(t.status))} | ${formatDuration(t.durationMs)}</span>
<button class="btn-ghost btn-sm" onclick="C2.viewTask('${t.id}')">${escapeHtml(c2t('c2.tasks.view'))}</button> <button type="button" class="btn-secondary btn-small" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
</div> </div>
`).join(''); `;
}).join('');
}); });
}; };
@@ -1334,13 +1356,12 @@
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th> <th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</th> <th>${escapeHtml(c2t('c2.tasks.colDuration'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th> <th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
<th>${escapeHtml(c2t('c2.tasks.colActions'))}</th> <th class="c2-task-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${C2.tasks.map(t => { ${C2.tasks.map(t => {
const rawId = t.id || ''; const rawId = t.id || '';
const idJson = JSON.stringify(rawId);
const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId); const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId);
const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-'; const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-';
return ` return `
@@ -1356,12 +1377,14 @@
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td> <td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
<td>${formatDuration(t.durationMs)}</td> <td>${formatDuration(t.durationMs)}</td>
<td>${formatTime(t.createdAt)}</td> <td>${formatTime(t.createdAt)}</td>
<td> <td class="c2-task-table-col-actions">
<button type="button" class="btn-ghost btn-sm" onclick="C2.viewTask(${idJson})">${escapeHtml(c2t('c2.tasks.view'))}</button> <div class="c2-task-table-actions">
<button type="button" class="btn-secondary btn-small" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
${t.status === 'queued' || t.status === 'sent' ${t.status === 'queued' || t.status === 'sent'
? `<button type="button" class="btn-danger btn-sm" onclick="C2.cancelTask(${idJson})">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>` ? `<button type="button" class="btn-danger btn-small" data-c2-task-action="cancel" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''} : ''}
<button type="button" class="btn-secondary btn-sm c2-task-row-delete" onclick="C2.deleteTaskById(${idJson})" title="${delTitle}" aria-label="${delTitle}">${escapeHtml(c2t('c2.tasks.deleteBtn'))}</button> <button type="button" class="btn-danger btn-small" data-c2-task-action="delete" data-task-id="${escapeHtml(rawId)}" title="${delTitle}" aria-label="${delTitle}">${escapeHtml(c2t('c2.tasks.deleteBtn'))}</button>
</div>
</td> </td>
</tr> </tr>
`; `;
@@ -1387,10 +1410,10 @@
</div> </div>
<div class="c2-modal-body"> <div class="c2-modal-body">
<div class="c2-task-detail"> <div class="c2-task-detail">
<div><strong>${escapeHtml(c2t('c2.tasks.labelId'))}:</strong> ${t.id}</div> <div><strong>${escapeHtml(c2t('c2.tasks.labelId'))}:</strong> ${escapeHtml(t.id || '')}</div>
<div><strong>${escapeHtml(c2t('c2.tasks.labelSession'))}:</strong> ${t.sessionId}</div> <div><strong>${escapeHtml(c2t('c2.tasks.labelSession'))}:</strong> ${escapeHtml(t.sessionId || '')}</div>
<div><strong>${escapeHtml(c2t('c2.tasks.labelType'))}:</strong> ${t.taskType}</div> <div><strong>${escapeHtml(c2t('c2.tasks.labelType'))}:</strong> ${escapeHtml(t.taskType || '')}</div>
<div><strong>${escapeHtml(c2t('c2.tasks.labelStatus'))}:</strong> <span class="c2-status-badge ${t.status}">${escapeHtml(taskStatusLabel(t.status))}</span></div> <div><strong>${escapeHtml(c2t('c2.tasks.labelStatus'))}:</strong> <span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></div>
<div><strong>${escapeHtml(c2t('c2.tasks.labelCreated'))}:</strong> ${formatTime(t.createdAt)}</div> <div><strong>${escapeHtml(c2t('c2.tasks.labelCreated'))}:</strong> ${formatTime(t.createdAt)}</div>
<div><strong>${escapeHtml(c2t('c2.tasks.labelSent'))}:</strong> ${formatTime(t.sentAt)}</div> <div><strong>${escapeHtml(c2t('c2.tasks.labelSent'))}:</strong> ${formatTime(t.sentAt)}</div>
<div><strong>${escapeHtml(c2t('c2.tasks.labelCompleted'))}:</strong> ${formatTime(t.completedAt)}</div> <div><strong>${escapeHtml(c2t('c2.tasks.labelCompleted'))}:</strong> ${formatTime(t.completedAt)}</div>
@@ -1416,19 +1439,24 @@
renderTaskModal(local); renderTaskModal(local);
return; return;
} }
apiRequest('GET', `${API_BASE}/tasks/${id}`).then(data => { apiRequest('GET', `${API_BASE}/tasks/${encodeURIComponent(id)}`).then(data => {
if (data.error) {
showToast(String(data.error), 'error');
return;
}
if (data.task) renderTaskModal(data.task); if (data.task) renderTaskModal(data.task);
}); else showToast(c2t('c2.tasks.emptyAll'), 'warn');
}).catch(err => showToast(err.message || String(err), 'error'));
}; };
C2.cancelTask = function(id) { C2.cancelTask = function(id) {
apiRequest('POST', `${API_BASE}/tasks/${id}/cancel`, {}).then(data => { apiRequest('POST', `${API_BASE}/tasks/${encodeURIComponent(id)}/cancel`, {}).then(data => {
if (data.error) showToast(data.error, 'error'); if (data.error) showToast(String(data.error), 'error');
else { else {
showToast(c2t('c2.tasks.toastCancelled'), 'success'); showToast(c2t('c2.tasks.toastCancelled'), 'success');
C2.loadTasks(C2.tasksPage || 1); C2.loadTasks(C2.tasksPage || 1);
} }
}); }).catch(err => showToast(err.message || String(err), 'error'));
}; };
// ============================================================================ // ============================================================================
+83 -31
View File
@@ -534,34 +534,32 @@ function updateChatReasoningSummary() {
} }
function closeChatReasoningPanel() { function closeChatReasoningPanel() {
const panel = document.getElementById('chat-reasoning-panel'); const wrap = document.getElementById('chat-reasoning-wrapper');
const btn = document.getElementById('chat-reasoning-btn'); const toggle = document.getElementById('conversation-reasoning-toggle');
if (panel) panel.style.display = 'none'; if (wrap) wrap.classList.add('conversation-reasoning-collapsed');
if (btn) { if (toggle) toggle.setAttribute('aria-expanded', 'false');
btn.classList.remove('active'); }
btn.setAttribute('aria-expanded', 'false');
function toggleConversationReasoningCard() {
const wrap = document.getElementById('chat-reasoning-wrapper');
const toggle = document.getElementById('conversation-reasoning-toggle');
if (!wrap || !toggle) return;
wrap.classList.toggle('conversation-reasoning-collapsed');
const collapsed = wrap.classList.contains('conversation-reasoning-collapsed');
toggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if (!collapsed) {
if (typeof closeAgentModePanel === 'function') {
closeAgentModePanel();
}
if (typeof closeRoleSelectionPanel === 'function') {
closeRoleSelectionPanel();
}
updateChatReasoningSummary();
} }
} }
function toggleChatReasoningPanel() { function toggleChatReasoningPanel() {
const panel = document.getElementById('chat-reasoning-panel'); toggleConversationReasoningCard();
const btn = document.getElementById('chat-reasoning-btn');
if (!panel || !btn) return;
const isOpen = panel.style.display === 'flex';
if (isOpen) {
closeChatReasoningPanel();
return;
}
if (typeof closeAgentModePanel === 'function') {
closeAgentModePanel();
}
if (typeof closeRoleSelectionPanel === 'function') {
closeRoleSelectionPanel();
}
updateChatReasoningSummary();
panel.style.display = 'flex';
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
} }
function restoreChatReasoningControlsFromStorage() { function restoreChatReasoningControlsFromStorage() {
@@ -619,6 +617,7 @@ if (typeof window !== 'undefined') {
window.buildReasoningRequestPayload = buildReasoningRequestPayload; window.buildReasoningRequestPayload = buildReasoningRequestPayload;
window.closeChatReasoningPanel = closeChatReasoningPanel; window.closeChatReasoningPanel = closeChatReasoningPanel;
window.toggleChatReasoningPanel = toggleChatReasoningPanel; window.toggleChatReasoningPanel = toggleChatReasoningPanel;
window.toggleConversationReasoningCard = toggleConversationReasoningCard;
window.updateChatReasoningSummary = updateChatReasoningSummary; window.updateChatReasoningSummary = updateChatReasoningSummary;
} }
@@ -1845,7 +1844,10 @@ function refreshSystemReadyMessageBubbles() {
if (typeof marked !== 'undefined') { if (typeof marked !== 'undefined') {
try { try {
marked.setOptions({ breaks: true, gfm: true }); marked.setOptions({ breaks: true, gfm: true });
const parsed = marked.parse(text); const src = typeof window.normalizeAssistantMarkdownSource === 'function'
? window.normalizeAssistantMarkdownSource(text)
: text;
const parsed = marked.parse(src, { async: false });
formattedContent = typeof DOMPurify !== 'undefined' formattedContent = typeof DOMPurify !== 'undefined'
? DOMPurify.sanitize(parsed, defaultSanitizeConfig) ? DOMPurify.sanitize(parsed, defaultSanitizeConfig)
: parsed; : parsed;
@@ -1936,7 +1938,10 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
breaks: true, breaks: true,
gfm: true, gfm: true,
}); });
return marked.parse(raw); const src = typeof window.normalizeAssistantMarkdownSource === 'function'
? window.normalizeAssistantMarkdownSource(raw)
: raw;
return marked.parse(src, { async: false });
} catch (e) { } catch (e) {
console.error('Markdown 解析失败:', e); console.error('Markdown 解析失败:', e);
return null; return null;
@@ -2219,6 +2224,39 @@ function showCopySuccess(button) {
} }
} }
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
function dedupeConsecutiveProcessDetailRows(details) {
if (!Array.isArray(details) || details.length < 2) {
return details;
}
const out = [details[0]];
for (let i = 1; i < details.length; i++) {
const cur = details[i];
if (processDetailRowFingerprint(out[out.length - 1]) === processDetailRowFingerprint(cur)) {
continue;
}
out.push(cur);
}
return out;
}
function processDetailRowFingerprint(d) {
if (!d || typeof d !== 'object') {
return '';
}
const et = String(d.eventType || '');
const msg = String(d.message != null ? d.message : '').trim();
let dataKey = '';
try {
if (d.data != null) {
dataKey = JSON.stringify(d.data);
}
} catch (e) {
dataKey = String(d.data);
}
return et + '\0' + msg + '\0' + dataKey;
}
// 渲染过程详情 // 渲染过程详情
function renderProcessDetails(messageId, processDetails) { function renderProcessDetails(messageId, processDetails) {
const messageElement = document.getElementById(messageId); const messageElement = document.getElementById(messageId);
@@ -2318,6 +2356,10 @@ function renderProcessDetails(messageId, processDetails) {
} }
detailsContainer.dataset.lazyNotLoaded = '0'; detailsContainer.dataset.lazyNotLoaded = '0';
detailsContainer.dataset.loaded = '1'; detailsContainer.dataset.loaded = '1';
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
}
// 如果没有processDetails或为空,显示空状态 // 如果没有processDetails或为空,显示空状态
if (!processDetails || processDetails.length === 0) { if (!processDetails || processDetails.length === 0) {
// 显示空状态提示 // 显示空状态提示
@@ -2382,7 +2424,13 @@ function renderProcessDetails(messageId, processDetails) {
const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'); const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const index = data.index || 0; const index = data.index || 0;
const total = data.total || 0; const total = data.total || 0;
itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')'); const argsHint = typeof window.toolCallArgHint === 'function'
? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(data) : {})
: '';
const callTitle = typeof window.formatToolCallTimelineTitle === 'function'
? window.formatToolCallTimelineTitle(toolName, index, total, argsHint)
: (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')');
itemTitle = agPx + '🔧 ' + callTitle;
} else if (eventType === 'tool_result') { } else if (eventType === 'tool_result') {
const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'); const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const success = data.success !== false; const success = data.success !== false;
@@ -2412,12 +2460,16 @@ function renderProcessDetails(messageId, processDetails) {
: '⏸️ 用户中断并继续'; : '⏸️ 用户中断并继续';
} }
addTimelineItem(timeline, eventType, { const timelineOpts = {
title: itemTitle, title: itemTitle,
message: detail.message || '', message: detail.message || '',
data: data, data: data,
createdAt: detail.createdAt // 传递实际的事件创建时间 createdAt: detail.createdAt // 传递实际的事件创建时间
}); };
if (eventType === 'tool_call' && data._mergedResult) {
timelineOpts.mergedResult = data._mergedResult;
}
addTimelineItem(timeline, eventType, timelineOpts);
}); });
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理) // 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
@@ -7377,8 +7429,8 @@ document.addEventListener('click', function(event) {
} }
const reasoningWrap = document.getElementById('chat-reasoning-wrapper'); const reasoningWrap = document.getElementById('chat-reasoning-wrapper');
const reasoningPanel = document.getElementById('chat-reasoning-panel'); if (reasoningWrap && reasoningWrap.style.display !== 'none' &&
if (reasoningWrap && reasoningPanel && reasoningPanel.style.display === 'flex') { !reasoningWrap.classList.contains('conversation-reasoning-collapsed')) {
if (!reasoningWrap.contains(event.target)) { if (!reasoningWrap.contains(event.target)) {
closeChatReasoningPanel(); closeChatReasoningPanel();
} }
+431 -21
View File
@@ -202,7 +202,6 @@ async function refreshDashboard() {
openHighCount = pickOpenCount(openHighRes, highCount); openHighCount = pickOpenCount(openHighRes, highCount);
openMediumCount = pickOpenCount(openMediumRes, mediumCount); openMediumCount = pickOpenCount(openMediumRes, mediumCount);
openLowCount = pickOpenCount(openLowRes, lowCount); openLowCount = pickOpenCount(openLowRes, lowCount);
if (severityTotalEl) severityTotalEl.textContent = String(total);
severityIds.forEach(sev => { severityIds.forEach(sev => {
const count = bySeverity[sev] || 0; const count = bySeverity[sev] || 0;
const el = document.getElementById('dashboard-severity-' + sev); const el = document.getElementById('dashboard-severity-' + sev);
@@ -726,8 +725,8 @@ function renderDashboardAlertBanner(stats) {
try { sessionStorage.setItem(DASH_SESSION_ALERT_LAST_REASONS, reasonPartJoined); } catch (_) {} try { sessionStorage.setItem(DASH_SESSION_ALERT_LAST_REASONS, reasonPartJoined); } catch (_) {}
} }
// External MCP 健康度:从 /api/external-mcp/stats 解析出 running / total / down // External MCP 健康度:从 /api/external-mcp/stats 解析(后端字段为 total/enabled/disabled/connected
// 决定是否在「能力总览」第 6 行显示,并把 down 数返回给 alert banner 驱动告警 // 决定是否在「能力总览」第 6 行显示,并把「已启用但未连接」的数量返回给 alert banner。
function renderExternalMcpHealth(stats) { function renderExternalMcpHealth(stats) {
var row = document.getElementById('dashboard-resource-external-mcp-row'); var row = document.getElementById('dashboard-resource-external-mcp-row');
var textEl = document.getElementById('dashboard-resource-external-mcp-text'); var textEl = document.getElementById('dashboard-resource-external-mcp-text');
@@ -738,22 +737,29 @@ function renderExternalMcpHealth(stats) {
row.hidden = true; row.hidden = true;
return 0; return 0;
} }
// 兼容多种返回字段:{ total, running, stopped/error };常见命名都尝试一下
var total = Number(stats.total ?? stats.Total ?? 0) || 0; var total = Number(stats.total ?? stats.Total ?? 0) || 0;
var running = Number(stats.running ?? stats.Running ?? 0) || 0; var enabled = Number(stats.enabled ?? stats.Enabled ?? 0) || 0;
// 后端用 connected 表示已连接数;兼容旧字段 running
var connected = Number(stats.connected ?? stats.Connected ??
stats.running ?? stats.Running ?? 0) || 0;
if (total === 0) { if (total === 0) {
row.hidden = true; row.hidden = true;
return 0; return 0;
} }
var down = Math.max(0, total - running); // 未配置任何「已启用」的外部 MCP 时不展示健康行,也不告警(与 MCP 管理页口径一致)
if (enabled === 0) {
row.hidden = true;
return 0;
}
var down = Math.max(0, enabled - connected);
row.hidden = false; row.hidden = false;
textEl.textContent = formatNumber(running) + ' / ' + formatNumber(total); textEl.textContent = formatNumber(connected) + ' / ' + formatNumber(enabled);
if (healthEl) { if (healthEl) {
healthEl.classList.remove('is-ok', 'is-warning', 'is-danger'); healthEl.classList.remove('is-ok', 'is-warning', 'is-danger');
if (down === 0) { if (down === 0) {
healthEl.classList.add('is-ok'); healthEl.classList.add('is-ok');
healthEl.textContent = dt('dashboard.mcpAllRunning', null, '全部运行'); healthEl.textContent = dt('dashboard.mcpAllRunning', null, '全部运行');
} else if (down < total) { } else if (down < enabled) {
healthEl.classList.add('is-warning'); healthEl.classList.add('is-warning');
healthEl.textContent = dt('dashboard.mcpPartialDown', { count: down }, healthEl.textContent = dt('dashboard.mcpPartialDown', { count: down },
down + ' 个未运行'); down + ' 个未运行');
@@ -1383,6 +1389,17 @@ function dashboardBarTooltipOnOut(ev) {
if (dashboardBarTooltipEl) dashboardBarTooltipEl.style.display = 'none'; if (dashboardBarTooltipEl) dashboardBarTooltipEl.style.display = 'none';
} }
// 仪表盘 → 漏洞管理:带严重程度/状态筛选跳转
function navigateToVulnerabilitiesWithFilter(opts) {
opts = opts || {};
var params = new URLSearchParams();
if (opts.severity) params.set('severity', opts.severity);
if (opts.status) params.set('status', opts.status);
var qs = params.toString();
window.location.hash = qs ? 'vulnerabilities?' + qs : 'vulnerabilities';
}
window.navigateToVulnerabilitiesWithFilter = navigateToVulnerabilitiesWithFilter;
// 漏洞严重程度分布:半环形(donut)渲染 // 漏洞严重程度分布:半环形(donut)渲染
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器 // 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad // 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad
@@ -1395,9 +1412,31 @@ var SEVERITY_DONUT_CFG = {
rOuter: 165, rOuter: 165,
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感) rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
labelOffset: 14, labelOffset: 14,
gapRad: 0 gapRad: 0.012
}; };
// 三段渐变:[高光浅调, 中段饱和色, 深色边缘] —— 做出类似 3D 釉面的层次
var SEVERITY_DONUT_GRADIENTS = {
critical: ['#fecaca', '#f87171', '#dc2626'],
high: ['#fed7aa', '#fb923c', '#ea580c'],
medium: ['#fef08a', '#facc15', '#ca8a04'],
low: ['#99f6e4', '#2dd4bf', '#0f766e'],
info: ['#bfdbfe', '#60a5fa', '#2563eb']
};
var severityDonutCenterDisplayed = { total: null, hoverCount: null };
var severityDonutState = {
bySeverity: {},
total: 0,
hoverId: null,
bound: false
};
var severityDonutTooltipEl = null;
var severityDonutTooltipTimer = null;
var severityDonutHoverClearTimer = null;
var SEVERITY_DEFAULT_LABELS = { var SEVERITY_DEFAULT_LABELS = {
critical: '严重', critical: '严重',
high: '高危', high: '高危',
@@ -1415,17 +1454,65 @@ function severityLabel(id) {
return SEVERITY_DEFAULT_LABELS[id] || id; return SEVERITY_DEFAULT_LABELS[id] || id;
} }
function ensureSeverityDonutDefs() {
var defsEl = document.getElementById('dashboard-severity-donut-defs');
if (!defsEl || defsEl.hasChildNodes()) return;
var html = '';
html += '<linearGradient id="donut-track-face" x1="0%" y1="0%" x2="0%" y2="100%">';
html += '<stop offset="0%" stop-color="#f8fafc"/>';
html += '<stop offset="55%" stop-color="#e8eef5"/>';
html += '<stop offset="100%" stop-color="#dce5ef"/>';
html += '</linearGradient>';
html += '<radialGradient id="donut-track-vignette" cx="50%" cy="85%" r="75%" fx="50%" fy="85%">';
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>';
html += '<stop offset="70%" stop-color="#ffffff" stop-opacity="0"/>';
html += '</radialGradient>';
html += '<radialGradient id="donut-inner-gloss" cx="35%" cy="75%" r="55%">';
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>';
html += '<stop offset="55%" stop-color="#ffffff" stop-opacity="0.08"/>';
html += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>';
html += '</radialGradient>';
html += '<filter id="donut-segment-soften" x="-18%" y="-18%" width="136%" height="136%" color-interpolation-filters="sRGB">';
html += '<feGaussianBlur in="SourceAlpha" stdDeviation="0.8" result="blur"/>';
html += '<feOffset dx="0" dy="1.5" in="blur" result="off"/>';
html += '<feFlood flood-color="#0f172a" flood-opacity="0.13" result="flood"/>';
html += '<feComposite in="flood" in2="off" operator="in" result="shadow"/>';
html += '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
html += '</filter>';
Object.keys(SEVERITY_DONUT_GRADIENTS).forEach(function (id) {
var stops = SEVERITY_DONUT_GRADIENTS[id];
html += '<linearGradient id="donut-grad-' + id + '" x1="18%" y1="12%" x2="88%" y2="94%">';
html += '<stop offset="0%" stop-color="' + stops[0] + '"/>';
html += '<stop offset="52%" stop-color="' + stops[1] + '"/>';
html += '<stop offset="100%" stop-color="' + stops[2] + '"/>';
html += '</linearGradient>';
});
defsEl.innerHTML = html;
}
function renderSeverityDonut(bySeverity, total) { function renderSeverityDonut(bySeverity, total) {
var svgEl = document.getElementById('dashboard-severity-donut');
var trackEl = document.getElementById('dashboard-severity-donut-track'); var trackEl = document.getElementById('dashboard-severity-donut-track');
var leadersEl = document.getElementById('dashboard-severity-donut-leaders');
var segmentsEl = document.getElementById('dashboard-severity-donut-segments'); var segmentsEl = document.getElementById('dashboard-severity-donut-segments');
var hitsEl = document.getElementById('dashboard-severity-donut-hits');
var labelsEl = document.getElementById('dashboard-severity-donut-labels'); var labelsEl = document.getElementById('dashboard-severity-donut-labels');
if (!trackEl || !segmentsEl || !labelsEl) return; if (!trackEl || !segmentsEl || !labelsEl) return;
var cfg = SEVERITY_DONUT_CFG; severityDonutState.bySeverity = bySeverity && typeof bySeverity === 'object' ? bySeverity : {};
severityDonutState.total = total || 0;
severityDonutState.hoverId = null;
// 背景轨迹(完整半环)只渲染一次 var cfg = SEVERITY_DONUT_CFG;
ensureSeverityDonutDefs();
// 背景轨迹(完整半环):双层填充营造凹槽 + 高光
if (!trackEl.hasChildNodes()) { if (!trackEl.hasChildNodes()) {
trackEl.innerHTML = '<path class="donut-track" d="' + halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner) + '"/>'; var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
trackEl.innerHTML =
'<path class="donut-track-shadow" d="' + trackPath + '"/>' +
'<path class="donut-track" fill="url(#donut-track-face)" d="' + trackPath + '"/>' +
'<path class="donut-track-vignette" fill="url(#donut-track-vignette)" d="' + trackPath + '"/>';
} }
var ids = ['critical', 'high', 'medium', 'low', 'info']; var ids = ['critical', 'high', 'medium', 'low', 'info'];
@@ -1434,12 +1521,24 @@ function renderSeverityDonut(bySeverity, total) {
}); });
var visible = severities.filter(function (s) { return s.value > 0; }); var visible = severities.filter(function (s) { return s.value > 0; });
if (svgEl) {
svgEl.classList.remove('is-highlighting');
svgEl.removeAttribute('data-hover-severity');
}
if (!total || total <= 0 || visible.length === 0) { if (!total || total <= 0 || visible.length === 0) {
segmentsEl.innerHTML = ''; segmentsEl.innerHTML = '';
if (hitsEl) hitsEl.innerHTML = '';
labelsEl.innerHTML = ''; labelsEl.innerHTML = '';
if (leadersEl) leadersEl.innerHTML = '';
clearSeverityDonutLegendHighlight();
resetSeverityDonutCenter(false);
_clearSeverityDonutChartWrapHover();
if (svgEl) svgEl.classList.remove('donut-ready');
return; return;
} }
resetSeverityDonutCenter(true);
// 弧长按 value/total 计算;若严重度求和 < total(存在未分级),右侧会保留背景轨迹的空白 // 弧长按 value/total 计算;若严重度求和 < total(存在未分级),右侧会保留背景轨迹的空白
var sumVisible = visible.reduce(function (s, seg) { return s + seg.value; }, 0); var sumVisible = visible.reduce(function (s, seg) { return s + seg.value; }, 0);
var coverage = sumVisible / total; // 半环被实际段覆盖的比例 var coverage = sumVisible / total; // 半环被实际段覆盖的比例
@@ -1449,7 +1548,10 @@ function renderSeverityDonut(bySeverity, total) {
var arcsTotalRad = Math.max(0, Math.PI * coverage - totalGapRad); var arcsTotalRad = Math.max(0, Math.PI * coverage - totalGapRad);
var segmentsHtml = ''; var segmentsHtml = '';
var hitsHtml = '';
var glossHtml = '';
var labelsHtml = ''; var labelsHtml = '';
var leadersHtml = '';
var cumRad = 0; var cumRad = 0;
visible.forEach(function (seg, i) { visible.forEach(function (seg, i) {
@@ -1459,17 +1561,21 @@ function renderSeverityDonut(bySeverity, total) {
var angleEnd = angleStart - segRad; var angleEnd = angleStart - segRad;
var path = arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner, angleStart, angleEnd); var path = arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner, angleStart, angleEnd);
segmentsHtml += '<path class="donut-segment seg-' + seg.id + '" d="' + path + '"/>'; var pctOfTotal = (seg.value / total) * 100;
var pctRounded = Math.round(pctOfTotal);
var name = esc(severityLabel(seg.id));
var ariaLabel = name + ' ' + seg.value + ' (' + pctRounded + '%)';
segmentsHtml += '<path class="donut-segment seg-' + seg.id + '" data-severity="' + seg.id + '" data-count="' + seg.value + '" data-pct="' + pctRounded + '" fill="url(#donut-grad-' + seg.id + ')" d="' + path + '"/>';
hitsHtml += '<path class="donut-segment-hit seg-' + seg.id + '" data-severity="' + seg.id + '" fill="transparent" d="' + path + '" tabindex="0" role="button" aria-label="' + ariaLabel + '"/>';
glossHtml += '<path class="donut-segment-gloss seg-' + seg.id + '" data-severity="' + seg.id + '" fill="url(#donut-inner-gloss)" d="' + arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter - 2, cfg.rInner + 6, angleStart, angleEnd) + '" pointer-events="none"/>';
// 仅当占比 >= 5% 时显示外置标签,避免小段标签互相重叠 // 仅当占比 >= 5% 时显示外置标签,避免小段标签互相重叠
var pctOfTotal = (seg.value / total) * 100;
if (pctOfTotal >= 5) { if (pctOfTotal >= 5) {
var midAngle = (angleStart + angleEnd) / 2; var midAngle = (angleStart + angleEnd) / 2;
var labelR = cfg.rOuter + cfg.labelOffset; var labelR = cfg.rOuter + cfg.labelOffset + 6;
var sinMid = Math.sin(midAngle); var sinMid = Math.sin(midAngle);
var cosMid = Math.cos(midAngle); var cosMid = Math.cos(midAngle);
var lx = cfg.cx + labelR * cosMid; var lx = cfg.cx + labelR * cosMid;
// 顶部区域标签整体向上抬一些,避免与外弧贴住;侧边标签则不调整
var topLift = sinMid > 0.4 ? Math.round((sinMid - 0.3) * 10) : 0; var topLift = sinMid > 0.4 ? Math.round((sinMid - 0.3) * 10) : 0;
var ly = cfg.cy - labelR * sinMid - topLift; var ly = cfg.cy - labelR * sinMid - topLift;
@@ -1477,11 +1583,15 @@ function renderSeverityDonut(bySeverity, total) {
if (cosMid < -0.15) anchor = 'end'; if (cosMid < -0.15) anchor = 'end';
else if (cosMid > 0.15) anchor = 'start'; else if (cosMid > 0.15) anchor = 'start';
var pctText = Math.round(pctOfTotal) + '%'; var pctText = pctRounded + '%';
var name = esc(severityLabel(seg.id)); var arcR = cfg.rOuter + 4;
var lineX1 = cfg.cx + arcR * cosMid;
var lineY1 = cfg.cy - arcR * sinMid;
var lineX2 = cfg.cx + (cfg.rOuter + cfg.labelOffset - 2) * cosMid;
var lineY2 = cfg.cy - (cfg.rOuter + cfg.labelOffset - 2) * sinMid;
leadersHtml += '<line class="donut-leader label-' + seg.id + '" data-severity="' + seg.id + '" pathLength="100" x1="' + lineX1.toFixed(1) + '" y1="' + lineY1.toFixed(1) + '" x2="' + lineX2.toFixed(1) + '" y2="' + lineY2.toFixed(1) + '"/>';
// 两行:第一行 "数量 (百分比)"(弧色),第二行 "严重度名称"(同色但稍小) labelsHtml += '<text class="donut-label-text label-' + seg.id + '" data-severity="' + seg.id + '" text-anchor="' + anchor + '" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '">';
labelsHtml += '<text class="donut-label-text label-' + seg.id + '" text-anchor="' + anchor + '" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '">';
labelsHtml += '<tspan x="' + lx.toFixed(1) + '" dy="0">' + seg.value + ' <tspan class="donut-label-pct">(' + pctText + ')</tspan></tspan>'; labelsHtml += '<tspan x="' + lx.toFixed(1) + '" dy="0">' + seg.value + ' <tspan class="donut-label-pct">(' + pctText + ')</tspan></tspan>';
labelsHtml += '<tspan class="donut-label-name" x="' + lx.toFixed(1) + '" dy="14">' + name + '</tspan>'; labelsHtml += '<tspan class="donut-label-name" x="' + lx.toFixed(1) + '" dy="14">' + name + '</tspan>';
labelsHtml += '</text>'; labelsHtml += '</text>';
@@ -1491,8 +1601,308 @@ function renderSeverityDonut(bySeverity, total) {
if (i < visibleCount - 1) cumRad += cfg.gapRad; if (i < visibleCount - 1) cumRad += cfg.gapRad;
}); });
segmentsEl.innerHTML = segmentsHtml; if (leadersEl) leadersEl.innerHTML = leadersHtml;
segmentsEl.innerHTML = segmentsHtml + glossHtml;
if (hitsEl) hitsEl.innerHTML = hitsHtml;
labelsEl.innerHTML = labelsHtml; labelsEl.innerHTML = labelsHtml;
if (svgEl) {
svgEl.classList.remove('donut-ready');
void svgEl.offsetWidth;
requestAnimationFrame(function () {
svgEl.classList.add('donut-ready');
});
}
scheduleSeverityCenterCountUp(total);
attachSeverityDonutInteractivity();
}
function scheduleSeverityCenterCountUp(targetTotal) {
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
var totalEl = document.getElementById('dashboard-severity-total');
if (totalEl) totalEl.textContent = String(targetTotal);
severityDonutCenterDisplayed.total = targetTotal;
return;
}
var totalEl = document.getElementById('dashboard-severity-total');
if (!totalEl || severityDonutState.hoverId) return;
var from = typeof severityDonutCenterDisplayed.total === 'number' ? severityDonutCenterDisplayed.total : 0;
var to = targetTotal;
if (from === to) {
totalEl.textContent = String(to);
severityDonutCenterDisplayed.total = to;
return;
}
var start = null;
var dur = Math.min(520, 180 + Math.abs(to - from) * 28);
function tick(now) {
if (!start) start = now;
var t = Math.min(1, (now - start) / dur);
var eased = 1 - Math.pow(1 - t, 3);
var val = Math.round(from + (to - from) * eased);
totalEl.textContent = String(val);
if (t < 1) {
requestAnimationFrame(tick);
} else {
totalEl.textContent = String(to);
severityDonutCenterDisplayed.total = to;
}
}
requestAnimationFrame(tick);
}
function resetSeverityDonutCenter(skipTotalSnapshot) {
var totalEl = document.getElementById('dashboard-severity-total');
var labelEl = document.getElementById('dashboard-severity-center-label');
var centerEl = document.getElementById('dashboard-severity-center');
var n = severityDonutState.total || 0;
if (!skipTotalSnapshot && totalEl) totalEl.textContent = String(n);
if (!skipTotalSnapshot) severityDonutCenterDisplayed.total = n;
severityDonutCenterDisplayed.hoverCount = null;
if (labelEl) {
labelEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.totalVulns') : '总漏洞数');
labelEl.classList.remove('is-severity');
labelEl.removeAttribute('data-severity');
}
if (centerEl) centerEl.classList.remove('is-hovering');
}
function setSeverityDonutHover(severityId) {
var svgEl = document.getElementById('dashboard-severity-donut');
var centerEl = document.getElementById('dashboard-severity-center');
var totalEl = document.getElementById('dashboard-severity-total');
var labelEl = document.getElementById('dashboard-severity-center-label');
if (!severityId) {
severityDonutState.hoverId = null;
if (svgEl) {
svgEl.classList.remove('is-highlighting');
svgEl.removeAttribute('data-hover-severity');
}
clearSeverityDonutLegendHighlight();
resetSeverityDonutCenter(false);
_clearSeverityDonutChartWrapHover();
return;
}
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[severityId]) || 0;
severityDonutState.hoverId = severityId;
if (svgEl) {
svgEl.classList.add('is-highlighting');
svgEl.setAttribute('data-hover-severity', severityId);
}
highlightSeverityDonutParts(severityId);
highlightSeverityLegendItem(severityId);
if (totalEl) {
totalEl.textContent = String(count);
severityDonutCenterDisplayed.hoverCount = count;
}
if (labelEl) {
labelEl.textContent = severityLabel(severityId);
labelEl.classList.add('is-severity');
labelEl.setAttribute('data-severity', severityId);
}
if (centerEl) centerEl.classList.add('is-hovering');
var chartWrap = document.querySelector('.dashboard-severity-chart');
if (chartWrap) chartWrap.setAttribute('data-hover-severity', severityId);
}
function _clearSeverityDonutChartWrapHover() {
var chartWrap = document.querySelector('.dashboard-severity-chart');
if (chartWrap) chartWrap.removeAttribute('data-hover-severity');
}
function highlightSeverityDonutParts(severityId) {
var svgEl = document.getElementById('dashboard-severity-donut');
if (!svgEl) return;
svgEl.querySelectorAll('.donut-segment[data-severity], .donut-segment-gloss[data-severity], .donut-leader[data-severity], .donut-label-text[data-severity]').forEach(function (el) {
var match = el.getAttribute('data-severity') === severityId;
el.classList.toggle('is-active', match);
el.classList.toggle('is-dimmed', !match);
});
}
function highlightSeverityLegendItem(severityId) {
var legend = document.getElementById('dashboard-vuln-bars');
if (!legend) return;
legend.querySelectorAll('.dashboard-severity-legend-item').forEach(function (item) {
var match = item.getAttribute('data-severity') === severityId;
item.classList.toggle('is-active', match);
});
}
function clearSeverityDonutLegendHighlight() {
var legend = document.getElementById('dashboard-vuln-bars');
if (legend) {
legend.querySelectorAll('.dashboard-severity-legend-item.is-active').forEach(function (el) {
el.classList.remove('is-active');
});
}
var svgEl = document.getElementById('dashboard-severity-donut');
if (svgEl) {
svgEl.querySelectorAll('.is-active, .is-dimmed').forEach(function (el) {
el.classList.remove('is-active', 'is-dimmed');
});
}
}
function severityDonutTooltipText(severityId) {
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[severityId]) || 0;
var pct = severityDonutState.total > 0 ? Math.round((count / severityDonutState.total) * 100) : 0;
var hint = (typeof window.t === 'function' ? window.t('dashboard.severityClickHint') : '点击查看');
return severityLabel(severityId) + ' · ' + count + ' (' + pct + '%) — ' + hint;
}
function showSeverityDonutTooltip(ev, severityId) {
if (!severityDonutTooltipEl) {
severityDonutTooltipEl = document.createElement('div');
severityDonutTooltipEl.className = 'dashboard-severity-donut-tooltip';
severityDonutTooltipEl.setAttribute('role', 'tooltip');
document.body.appendChild(severityDonutTooltipEl);
}
clearTimeout(severityDonutTooltipTimer);
severityDonutTooltipTimer = setTimeout(function () {
severityDonutTooltipEl.textContent = severityDonutTooltipText(severityId);
severityDonutTooltipEl.style.display = 'block';
requestAnimationFrame(function () {
var x = ev.clientX;
var y = ev.clientY;
var ttRect = severityDonutTooltipEl.getBoundingClientRect();
var left = x - ttRect.width / 2;
var top = y - ttRect.height - 12;
if (top < 8) top = y + 16;
var pad = 8;
if (left < pad) left = pad;
if (left + ttRect.width > window.innerWidth - pad) left = window.innerWidth - ttRect.width - pad;
severityDonutTooltipEl.style.left = left + 'px';
severityDonutTooltipEl.style.top = top + 'px';
});
}, 120);
}
function hideSeverityDonutTooltip() {
clearTimeout(severityDonutTooltipTimer);
severityDonutTooltipTimer = null;
if (severityDonutTooltipEl) severityDonutTooltipEl.style.display = 'none';
}
function attachSeverityDonutInteractivity() {
var hitsEl = document.getElementById('dashboard-severity-donut-hits');
var legend = document.getElementById('dashboard-vuln-bars');
if (!hitsEl) return;
if (!severityDonutState.bound) {
severityDonutState.bound = true;
hitsEl.addEventListener('mouseover', severityDonutPointerOver);
hitsEl.addEventListener('mouseout', severityDonutPointerOut);
hitsEl.addEventListener('click', severityDonutClick);
hitsEl.addEventListener('keydown', severityDonutKeydown);
if (legend) {
legend.addEventListener('mouseover', severityLegendPointerOver);
legend.addEventListener('mouseout', severityLegendPointerOut);
legend.addEventListener('click', severityLegendClick);
legend.addEventListener('keydown', severityLegendKeydown);
}
}
legend && legend.querySelectorAll('.dashboard-severity-legend-item').forEach(function (item) {
if (!item.getAttribute('data-severity')) return;
var sev = item.getAttribute('data-severity');
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[sev]) || 0;
item.classList.toggle('is-zero', count === 0);
item.setAttribute('aria-label', severityDonutTooltipText(sev));
});
}
function severityDonutHitTarget(el) {
return el && el.closest && el.closest('.donut-segment-hit');
}
function severityDonutCancelHoverClear() {
clearTimeout(severityDonutHoverClearTimer);
severityDonutHoverClearTimer = null;
}
function severityDonutScheduleHoverClear() {
severityDonutCancelHoverClear();
severityDonutHoverClearTimer = setTimeout(function () {
severityDonutHoverClearTimer = null;
setSeverityDonutHover(null);
hideSeverityDonutTooltip();
}, 60);
}
function severityDonutPointerOver(ev) {
var target = severityDonutHitTarget(ev.target);
if (!target) return;
var id = target.getAttribute('data-severity');
if (!id) return;
severityDonutCancelHoverClear();
if (severityDonutState.hoverId === id) return;
setSeverityDonutHover(id);
showSeverityDonutTooltip(ev, id);
}
function severityDonutPointerOut(ev) {
var related = ev.relatedTarget;
if (related) {
if (severityDonutHitTarget(related)) return;
var legendItem = related.closest && related.closest('.dashboard-severity-legend-item[data-severity]');
if (legendItem) return;
var hitsRoot = document.getElementById('dashboard-severity-donut-hits');
if (hitsRoot && hitsRoot.contains(related)) return;
}
severityDonutScheduleHoverClear();
}
function severityDonutClick(ev) {
var target = severityDonutHitTarget(ev.target);
if (!target) return;
var id = target.getAttribute('data-severity');
if (!id) return;
ev.preventDefault();
navigateToVulnerabilitiesWithFilter({ severity: id });
}
function severityDonutKeydown(ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
var target = severityDonutHitTarget(ev.target);
if (!target) return;
ev.preventDefault();
var id = target.getAttribute('data-severity');
if (id) navigateToVulnerabilitiesWithFilter({ severity: id });
}
function severityLegendPointerOver(ev) {
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
if (!item) return;
var id = item.getAttribute('data-severity');
if (!id) return;
severityDonutCancelHoverClear();
setSeverityDonutHover(id);
showSeverityDonutTooltip(ev, id);
}
function severityLegendPointerOut(ev) {
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
var related = ev.relatedTarget && ev.relatedTarget.closest && ev.relatedTarget.closest('.dashboard-severity-legend-item[data-severity]');
if (item && item === related) return;
severityDonutScheduleHoverClear();
}
function severityLegendClick(ev) {
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
if (!item) return;
var id = item.getAttribute('data-severity');
if (!id) return;
ev.preventDefault();
navigateToVulnerabilitiesWithFilter({ severity: id });
}
function severityLegendKeydown(ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
if (!item) return;
ev.preventDefault();
var id = item.getAttribute('data-severity');
if (id) navigateToVulnerabilitiesWithFilter({ severity: id });
} }
// SVG 半环(背景轨迹)路径 // SVG 半环(背景轨迹)路径
+1012 -120
View File
File diff suppressed because it is too large Load Diff
+33 -3
View File
@@ -339,9 +339,23 @@ async function loadConfig(loadTools = true) {
// 填充机器人配置 // 填充机器人配置
const robots = currentConfig.robots || {}; const robots = currentConfig.robots || {};
const wechat = robots.wechat || {};
const wecom = robots.wecom || {}; const wecom = robots.wecom || {};
const dingtalk = robots.dingtalk || {}; const dingtalk = robots.dingtalk || {};
const lark = robots.lark || {}; const lark = robots.lark || {};
const wechatEnabled = document.getElementById('robot-wechat-enabled');
if (wechatEnabled) wechatEnabled.checked = wechat.enabled === true;
const wechatBase = document.getElementById('robot-wechat-base-url');
if (wechatBase) wechatBase.value = wechat.base_url || 'https://ilinkai.weixin.qq.com';
const wechatBotType = document.getElementById('robot-wechat-bot-type');
if (wechatBotType) wechatBotType.value = wechat.bot_type || '3';
const wechatBotAgent = document.getElementById('robot-wechat-bot-agent');
if (wechatBotAgent) wechatBotAgent.value = wechat.bot_agent || 'CyberStrikeAI/1.0';
const wechatBotId = document.getElementById('robot-wechat-ilink-bot-id');
if (wechatBotId) wechatBotId.value = wechat.ilink_bot_id || '';
if (typeof refreshWechatRobotBoundUI === 'function') {
refreshWechatRobotBoundUI({ ...wechat, bound: !!(wechat.bot_token && wechat.ilink_bot_id) });
}
const wecomEnabled = document.getElementById('robot-wecom-enabled'); const wecomEnabled = document.getElementById('robot-wecom-enabled');
if (wecomEnabled) wecomEnabled.checked = wecom.enabled === true; if (wecomEnabled) wecomEnabled.checked = wecom.enabled === true;
const wecomToken = document.getElementById('robot-wecom-token'); const wecomToken = document.getElementById('robot-wecom-token');
@@ -1087,6 +1101,7 @@ async function applySettings() {
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim(); const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {}; const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
const prevRobots = (currentConfig && currentConfig.robots) ? currentConfig.robots : {};
const config = { const config = {
openai: { openai: {
...prevOpenai, ...prevOpenai,
@@ -1118,7 +1133,7 @@ async function applySettings() {
return { return {
enabled: document.getElementById('multi-agent-enabled')?.checked === true, enabled: document.getElementById('multi-agent-enabled')?.checked === true,
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true, robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
batch_use_multi_agent: false, batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
plan_execute_loop_max_iterations: peLoop plan_execute_loop_max_iterations: peLoop
}; };
})(), })(),
@@ -1127,6 +1142,19 @@ async function applySettings() {
enabled: c2Enabled enabled: c2Enabled
}, },
robots: { robots: {
...(prevRobots.session && typeof prevRobots.session === 'object' ? { session: prevRobots.session } : {}),
wechat: {
enabled: document.getElementById('robot-wechat-enabled')?.checked === true,
base_url: document.getElementById('robot-wechat-base-url')?.value.trim() || 'https://ilinkai.weixin.qq.com',
bot_type: document.getElementById('robot-wechat-bot-type')?.value.trim() || '3',
bot_agent: document.getElementById('robot-wechat-bot-agent')?.value.trim() || 'CyberStrikeAI/1.0',
ilink_bot_id: document.getElementById('robot-wechat-ilink-bot-id')?.value.trim() || (prevRobots.wechat && prevRobots.wechat.ilink_bot_id) || '',
...(prevRobots.wechat && typeof prevRobots.wechat === 'object' ? {
bot_token: prevRobots.wechat.bot_token || '',
ilink_user_id: prevRobots.wechat.ilink_user_id || '',
get_updates_buf: prevRobots.wechat.get_updates_buf || ''
} : {})
},
wecom: { wecom: {
enabled: document.getElementById('robot-wecom-enabled')?.checked === true, enabled: document.getElementById('robot-wecom-enabled')?.checked === true,
token: document.getElementById('robot-wecom-token')?.value.trim() || '', token: document.getElementById('robot-wecom-token')?.value.trim() || '',
@@ -1138,13 +1166,15 @@ async function applySettings() {
dingtalk: { dingtalk: {
enabled: document.getElementById('robot-dingtalk-enabled')?.checked === true, enabled: document.getElementById('robot-dingtalk-enabled')?.checked === true,
client_id: document.getElementById('robot-dingtalk-client-id')?.value.trim() || '', client_id: document.getElementById('robot-dingtalk-client-id')?.value.trim() || '',
client_secret: document.getElementById('robot-dingtalk-client-secret')?.value.trim() || '' client_secret: document.getElementById('robot-dingtalk-client-secret')?.value.trim() || '',
allow_conversation_id_fallback: !!(prevRobots.dingtalk && prevRobots.dingtalk.allow_conversation_id_fallback)
}, },
lark: { lark: {
enabled: document.getElementById('robot-lark-enabled')?.checked === true, enabled: document.getElementById('robot-lark-enabled')?.checked === true,
app_id: document.getElementById('robot-lark-app-id')?.value.trim() || '', app_id: document.getElementById('robot-lark-app-id')?.value.trim() || '',
app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '', app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '',
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '' verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '',
allow_chat_id_fallback: !!(prevRobots.lark && prevRobots.lark.allow_chat_id_fallback)
} }
}, },
tools: [] tools: []
+534 -35
View File
@@ -61,6 +61,24 @@ let vulnerabilityPagination = {
totalPages: 1 totalPages: 1
}; };
const VULN_STAT_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
let vulnerabilityStatCardsBound = false;
let vulnerabilityFilterPanelBound = false;
let vulnerabilityFilterOptionsCache = null;
const VULNERABILITY_ADVANCED_OPEN_KEY = 'vulnerabilityAdvancedFiltersOpen';
const VULNERABILITY_DATALIST_MAX = 8;
const VULNERABILITY_DATALIST_MIN_QUERY = 2;
const VULN_FILTER_CHIP_FIELDS = [
{ key: 'id', labelKey: 'vulnerabilityPage.vulnId' },
{ key: 'status', labelKey: null, format: 'status' },
{ key: 'severity', labelKey: null, format: 'severity' },
{ key: 'conversation_id', labelKey: 'vulnerabilityPage.conversationId' },
{ key: 'task_id', labelKey: 'vulnerabilityPage.taskOrQueueId' },
{ key: 'conversation_tag', labelKey: 'vulnerabilityPage.conversationTag' },
{ key: 'task_tag', labelKey: 'vulnerabilityPage.taskTag' }
];
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= / ?id= 同步筛选(通知/对话菜单/任务管理联动) // 从地址栏 #vulnerabilities?conversation_id= / ?task_id= / ?id= 同步筛选(通知/对话菜单/任务管理联动)
function syncVulnerabilityFiltersFromLocationHash() { function syncVulnerabilityFiltersFromLocationHash() {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
@@ -72,19 +90,35 @@ function syncVulnerabilityFiltersFromLocationHash() {
const vid = (params.get('id') || '').trim(); const vid = (params.get('id') || '').trim();
const cid = (params.get('conversation_id') || '').trim(); const cid = (params.get('conversation_id') || '').trim();
const tid = (params.get('task_id') || '').trim(); const tid = (params.get('task_id') || '').trim();
if (!vid && !cid && !tid) { const sev = (params.get('severity') || '').trim();
const st = (params.get('status') || '').trim();
const convTag = (params.get('conversation_tag') || '').trim();
const taskTag = (params.get('task_tag') || '').trim();
if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag) {
return; return;
} }
vulnerabilityFilters.id = ''; vulnerabilityFilters.id = '';
vulnerabilityFilters.conversation_id = ''; vulnerabilityFilters.conversation_id = '';
vulnerabilityFilters.task_id = ''; vulnerabilityFilters.task_id = '';
vulnerabilityFilters.conversation_tag = '';
vulnerabilityFilters.task_tag = '';
vulnerabilityFilters.severity = '';
vulnerabilityFilters.status = '';
const idEl = document.getElementById('vulnerability-id-filter'); const idEl = document.getElementById('vulnerability-id-filter');
const convEl = document.getElementById('vulnerability-conversation-filter'); const convEl = document.getElementById('vulnerability-conversation-filter');
const taskEl = document.getElementById('vulnerability-task-filter'); const taskEl = document.getElementById('vulnerability-task-filter');
const convTagEl = document.getElementById('vulnerability-conversation-tag-filter');
const taskTagEl = document.getElementById('vulnerability-task-tag-filter');
const sevEl = document.getElementById('vulnerability-severity-filter');
const stEl = document.getElementById('vulnerability-status-filter');
if (idEl) idEl.value = ''; if (idEl) idEl.value = '';
if (convEl) convEl.value = ''; if (convEl) convEl.value = '';
if (taskEl) taskEl.value = ''; if (taskEl) taskEl.value = '';
if (convTagEl) convTagEl.value = '';
if (taskTagEl) taskTagEl.value = '';
if (sevEl) sevEl.value = '';
if (stEl) stEl.value = '';
if (vid) { if (vid) {
vulnerabilityFilters.id = vid; vulnerabilityFilters.id = vid;
@@ -98,18 +132,474 @@ function syncVulnerabilityFiltersFromLocationHash() {
vulnerabilityFilters.task_id = tid; vulnerabilityFilters.task_id = tid;
if (taskEl) taskEl.value = tid; if (taskEl) taskEl.value = tid;
} }
if (convTag) {
vulnerabilityFilters.conversation_tag = convTag;
if (convTagEl) convTagEl.value = convTag;
}
if (taskTag) {
vulnerabilityFilters.task_tag = taskTag;
if (taskTagEl) taskTagEl.value = taskTag;
}
if (sev) {
vulnerabilityFilters.severity = sev;
if (sevEl) sevEl.value = sev;
}
if (st) {
vulnerabilityFilters.status = st;
if (stEl) stEl.value = st;
}
vulnerabilityPagination.currentPage = 1; vulnerabilityPagination.currentPage = 1;
if (hasVulnerabilityAdvancedFiltersActive()) {
setVulnerabilityAdvancedFiltersOpen(true, false);
}
syncVulnerabilityStatCardActiveState();
updateVulnerabilityFilterPanelState();
renderVulnerabilityFilterChips();
} }
// 初始化漏洞管理页面 // 初始化漏洞管理页面
function initVulnerabilityPage() { function initVulnerabilityPage() {
// 从localStorage加载每页条数设置 // 从localStorage加载每页条数设置
vulnerabilityPagination.pageSize = getVulnerabilityPageSize(); vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
initVulnerabilityStatCards();
initVulnerabilityFilterPanel();
syncVulnerabilityFiltersFromLocationHash(); syncVulnerabilityFiltersFromLocationHash();
updateVulnerabilityFilterPanelState();
renderVulnerabilityFilterChips();
loadVulnerabilityFilterOptions();
loadVulnerabilityStats(); loadVulnerabilityStats();
loadVulnerabilities(); loadVulnerabilities();
} }
function initVulnerabilityStatCards() {
if (vulnerabilityStatCardsBound) {
syncVulnerabilityStatCardActiveState();
return;
}
const root = document.getElementById('vulnerability-stat-cards');
if (!root) return;
vulnerabilityStatCardsBound = true;
root.addEventListener('click', onVulnerabilityStatCardClick);
root.addEventListener('keydown', onVulnerabilityStatCardKeydown);
}
function onVulnerabilityStatCardClick(ev) {
const totalCard = ev.target.closest('.stat-card.stat-card-total');
if (totalCard) {
applyVulnerabilitySeverityFilter('');
return;
}
const card = ev.target.closest('.stat-card.is-clickable[data-severity]');
if (!card) return;
const sev = card.getAttribute('data-severity');
if (!sev) return;
const sevEl = document.getElementById('vulnerability-severity-filter');
const current = sevEl ? sevEl.value : vulnerabilityFilters.severity;
applyVulnerabilitySeverityFilter(current === sev ? '' : sev);
}
function onVulnerabilityStatCardKeydown(ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
const card = ev.target.closest('.stat-card.is-clickable');
if (!card || !card.contains(ev.target)) return;
ev.preventDefault();
card.click();
}
function applyVulnerabilitySeverityFilter(severity) {
const sevEl = document.getElementById('vulnerability-severity-filter');
if (sevEl) sevEl.value = severity || '';
applyVulnerabilityFilters();
}
function readVulnerabilityFiltersFromForm() {
vulnerabilityFilters.id = (document.getElementById('vulnerability-id-filter')?.value || '').trim();
vulnerabilityFilters.conversation_id = (document.getElementById('vulnerability-conversation-filter')?.value || '').trim();
vulnerabilityFilters.task_id = (document.getElementById('vulnerability-task-filter')?.value || '').trim();
vulnerabilityFilters.conversation_tag = (document.getElementById('vulnerability-conversation-tag-filter')?.value || '').trim();
vulnerabilityFilters.task_tag = (document.getElementById('vulnerability-task-tag-filter')?.value || '').trim();
vulnerabilityFilters.severity = document.getElementById('vulnerability-severity-filter')?.value || '';
vulnerabilityFilters.status = document.getElementById('vulnerability-status-filter')?.value || '';
return vulnerabilityFilters;
}
function hasVulnerabilityAdvancedFiltersActive() {
const f = vulnerabilityFilters;
return Boolean(f.conversation_id || f.task_id || f.conversation_tag || f.task_tag);
}
function hasAnyVulnerabilityFilterActive() {
const f = vulnerabilityFilters;
return Boolean(
f.id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status
);
}
function applyVulnerabilityFilters() {
readVulnerabilityFiltersFromForm();
vulnerabilityPagination.currentPage = 1;
syncVulnerabilityStatCardActiveState();
updateVulnerabilityLocationHashFromFilters();
updateVulnerabilityFilterPanelState();
renderVulnerabilityFilterChips();
loadVulnerabilityStats();
loadVulnerabilities();
}
function updateVulnerabilityLocationHashFromFilters() {
const hash = window.location.hash.slice(1);
const hashParts = hash.split('?');
if (hashParts[0] !== 'vulnerabilities') return;
const params = new URLSearchParams(hashParts.length >= 2 ? hashParts.slice(1).join('?') : '');
const f = vulnerabilityFilters;
const pairs = [
['id', f.id],
['conversation_id', f.conversation_id],
['task_id', f.task_id],
['conversation_tag', f.conversation_tag],
['task_tag', f.task_tag],
['severity', f.severity],
['status', f.status]
];
pairs.forEach(function (pair) {
if (pair[1]) {
params.set(pair[0], pair[1]);
} else {
params.delete(pair[0]);
}
});
const qs = params.toString();
const newHash = qs ? 'vulnerabilities?' + qs : 'vulnerabilities';
if (window.location.hash.slice(1) === newHash) return;
const newFull = '#' + newHash;
if (typeof history.replaceState === 'function') {
history.replaceState(null, '', window.location.pathname + window.location.search + newFull);
} else {
window.location.hash = newHash;
}
}
function toggleVulnerabilityAdvancedFilters(ev) {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
const toggleBtn = document.getElementById('vulnerability-advanced-toggle');
if (!toggleBtn) return;
const expanded = toggleBtn.getAttribute('aria-expanded') === 'true';
setVulnerabilityAdvancedFiltersOpen(!expanded, true);
}
window.toggleVulnerabilityAdvancedFilters = toggleVulnerabilityAdvancedFilters;
function initVulnerabilityFilterPanel() {
const panel = document.getElementById('vulnerability-filter-panel');
if (!panel) return;
if (vulnerabilityFilterPanelBound) {
updateVulnerabilityFilterPanelState();
return;
}
vulnerabilityFilterPanelBound = true;
let savedOpen = false;
try {
savedOpen = localStorage.getItem(VULNERABILITY_ADVANCED_OPEN_KEY) === 'true';
} catch (e) { /* ignore */ }
setVulnerabilityAdvancedFiltersOpen(savedOpen, false);
const stEl = document.getElementById('vulnerability-status-filter');
if (stEl) stEl.addEventListener('change', applyVulnerabilityFilters);
const textIds = [
'vulnerability-id-filter',
'vulnerability-conversation-filter',
'vulnerability-task-filter',
'vulnerability-conversation-tag-filter',
'vulnerability-task-tag-filter'
];
textIds.forEach(function (id) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
applyVulnerabilityFilters();
}
});
});
bindVulnerabilityFilterTypeaheads();
}
function setVulnerabilityAdvancedFiltersOpen(open, persist) {
const toggleBtn = document.getElementById('vulnerability-advanced-toggle');
const advanced = document.getElementById('vulnerability-advanced-filters');
const wrap = document.querySelector('#vulnerability-filter-panel .vulnerability-filter-advanced-wrap');
if (!toggleBtn || !advanced) return;
toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
advanced.hidden = !open;
advanced.classList.toggle('is-open', open);
if (wrap) wrap.classList.toggle('is-expanded', open);
if (persist) {
try {
localStorage.setItem(VULNERABILITY_ADVANCED_OPEN_KEY, open ? 'true' : 'false');
} catch (e) { /* ignore */ }
}
}
function countVulnerabilityAdvancedFiltersActive() {
const f = vulnerabilityFilters;
let n = 0;
if (f.conversation_id) n++;
if (f.task_id) n++;
if (f.conversation_tag) n++;
if (f.task_tag) n++;
return n;
}
function updateVulnerabilityAdvancedBadge() {
const badge = document.getElementById('vulnerability-advanced-badge');
if (!badge) return;
readVulnerabilityFiltersFromForm();
const n = countVulnerabilityAdvancedFiltersActive();
if (n > 0) {
badge.hidden = false;
badge.textContent = '(' + n + ')';
badge.setAttribute('aria-label', String(n));
} else {
badge.hidden = true;
badge.textContent = '';
badge.removeAttribute('aria-label');
}
}
function updateVulnerabilityFilterPanelState() {
const panel = document.getElementById('vulnerability-filter-panel');
if (!panel) return;
readVulnerabilityFiltersFromForm();
panel.classList.toggle('is-filtered', hasAnyVulnerabilityFilterActive());
updateVulnerabilityAdvancedBadge();
}
function formatVulnerabilityFilterChipValue(key, value) {
if (key === 'severity') return vulnSeverityLabel(value);
if (key === 'status') return vulnStatusLabel(value);
return value;
}
function renderVulnerabilityFilterChips() {
const wrap = document.getElementById('vulnerability-filter-chips');
const list = document.getElementById('vulnerability-filter-chips-list');
if (!wrap || !list) return;
readVulnerabilityFiltersFromForm();
const chips = [];
VULN_FILTER_CHIP_FIELDS.forEach(function (field) {
const val = vulnerabilityFilters[field.key];
if (!val) return;
const label = field.labelKey ? vulnT(field.labelKey) : '';
const displayVal = formatVulnerabilityFilterChipValue(field.key, val);
const text = label ? label + ': ' + displayVal : displayVal;
chips.push({ key: field.key, text: text });
});
if (!chips.length) {
wrap.hidden = true;
list.innerHTML = '';
return;
}
wrap.hidden = false;
const removeLabel = vulnT('vulnerabilityPage.chipRemove');
list.innerHTML = chips.map(function (chip) {
return (
'<button type="button" class="vulnerability-filter-chip" role="listitem" data-filter-key="' +
escapeHtml(chip.key) + '" title="' + escapeHtml(removeLabel) + '">' +
'<span>' + escapeHtml(chip.text) + '</span>' +
'<span class="vulnerability-filter-chip-remove" aria-hidden="true">×</span>' +
'</button>'
);
}).join('');
list.querySelectorAll('.vulnerability-filter-chip').forEach(function (btn) {
btn.addEventListener('click', function () {
const key = btn.getAttribute('data-filter-key');
if (key) removeVulnerabilityFilterByKey(key);
});
});
}
function removeVulnerabilityFilterByKey(key) {
const map = {
id: 'vulnerability-id-filter',
conversation_id: 'vulnerability-conversation-filter',
task_id: 'vulnerability-task-filter',
conversation_tag: 'vulnerability-conversation-tag-filter',
task_tag: 'vulnerability-task-tag-filter',
severity: 'vulnerability-severity-filter',
status: 'vulnerability-status-filter'
};
const elId = map[key];
if (elId) {
const el = document.getElementById(elId);
if (el) el.value = '';
}
if (Object.prototype.hasOwnProperty.call(vulnerabilityFilters, key)) {
vulnerabilityFilters[key] = '';
}
applyVulnerabilityFilters();
}
async function loadVulnerabilityFilterOptions() {
if (typeof apiFetch === 'undefined') return;
try {
const response = await apiFetch('/api/vulnerabilities/filter-options');
if (!response.ok) return;
vulnerabilityFilterOptionsCache = await response.json();
populateVulnerabilityDatalist(
'vulnerability-conversation-tag-suggestions',
vulnerabilityFilterOptionsCache.conversation_tags,
{ max: 20 }
);
populateVulnerabilityDatalist(
'vulnerability-task-tag-suggestions',
vulnerabilityFilterOptionsCache.task_tags,
{ max: 20 }
);
clearVulnerabilityDatalist('vulnerability-conversation-suggestions');
clearVulnerabilityDatalist('vulnerability-task-suggestions');
} catch (e) {
console.warn('加载漏洞筛选建议失败', e);
}
}
function clearVulnerabilityDatalist(listId) {
const list = document.getElementById(listId);
if (list) list.innerHTML = '';
}
function populateVulnerabilityDatalist(listId, values, opts) {
const list = document.getElementById(listId);
if (!list || !Array.isArray(values)) return;
const max = (opts && opts.max) || VULNERABILITY_DATALIST_MAX;
const seen = new Set();
const unique = [];
values.forEach(function (v) {
const s = String(v || '').trim();
if (!s || seen.has(s)) return;
seen.add(s);
unique.push(s);
if (unique.length >= max) return;
});
list.innerHTML = unique.slice(0, max).map(function (v) {
return '<option value="' + escapeHtml(v) + '"></option>';
}).join('');
}
function filterVulnerabilitySuggestionPool(pool, query) {
if (!Array.isArray(pool) || !query) return [];
const q = query.toLowerCase();
const out = [];
for (let i = 0; i < pool.length && out.length < VULNERABILITY_DATALIST_MAX; i++) {
const s = String(pool[i] || '').trim();
if (s && s.toLowerCase().indexOf(q) !== -1) out.push(s);
}
return out;
}
function updateVulnerabilityTypeaheadDatalist(inputId, listId, poolKey) {
const el = document.getElementById(inputId);
if (!el || !vulnerabilityFilterOptionsCache) return;
const q = el.value.trim();
if (q.length < VULNERABILITY_DATALIST_MIN_QUERY) {
clearVulnerabilityDatalist(listId);
return;
}
let pool = vulnerabilityFilterOptionsCache[poolKey] || [];
if (poolKey === 'task_ids') {
pool = (vulnerabilityFilterOptionsCache.task_ids || []).concat(vulnerabilityFilterOptionsCache.queue_ids || []);
}
populateVulnerabilityDatalist(listId, filterVulnerabilitySuggestionPool(pool, q));
}
function bindVulnerabilityFilterTypeaheads() {
const pairs = [
{ inputId: 'vulnerability-conversation-filter', listId: 'vulnerability-conversation-suggestions', poolKey: 'conversation_ids' },
{ inputId: 'vulnerability-task-filter', listId: 'vulnerability-task-suggestions', poolKey: 'task_ids' }
];
pairs.forEach(function (pair) {
const el = document.getElementById(pair.inputId);
if (!el) return;
el.addEventListener('input', function () {
updateVulnerabilityTypeaheadDatalist(pair.inputId, pair.listId, pair.poolKey);
});
el.addEventListener('blur', function () {
setTimeout(function () { clearVulnerabilityDatalist(pair.listId); }, 150);
});
});
['vulnerability-conversation-tag-filter', 'vulnerability-task-tag-filter'].forEach(function (inputId) {
const el = document.getElementById(inputId);
if (!el) return;
el.addEventListener('focus', function () {
if (!vulnerabilityFilterOptionsCache) return;
const listId = inputId === 'vulnerability-conversation-tag-filter'
? 'vulnerability-conversation-tag-suggestions'
: 'vulnerability-task-tag-suggestions';
const key = inputId === 'vulnerability-conversation-tag-filter' ? 'conversation_tags' : 'task_tags';
const q = el.value.trim();
if (q.length >= VULNERABILITY_DATALIST_MIN_QUERY) {
populateVulnerabilityDatalist(listId, filterVulnerabilitySuggestionPool(vulnerabilityFilterOptionsCache[key], q), { max: 20 });
}
});
});
}
function syncVulnerabilityStatCardActiveState() {
const sevEl = document.getElementById('vulnerability-severity-filter');
const sev = (sevEl && sevEl.value) || vulnerabilityFilters.severity || '';
const root = document.getElementById('vulnerability-stat-cards');
if (!root) return;
root.querySelectorAll('.stat-card.is-clickable').forEach(function (card) {
if (card.classList.contains('stat-card-total')) {
card.classList.toggle('is-active', !sev);
card.setAttribute('aria-pressed', sev ? 'false' : 'true');
} else {
const cardSev = card.getAttribute('data-severity');
const active = Boolean(sev && cardSev === sev);
card.classList.toggle('is-active', active);
card.setAttribute('aria-pressed', active ? 'true' : 'false');
}
});
}
function updateVulnerabilityStatStackedBar(bySeverity, total) {
const bar = document.getElementById('stat-stacked-bar');
if (!bar) return;
const segs = bar.querySelectorAll('.stat-stacked-seg');
if (!total) {
bar.classList.add('is-empty');
segs.forEach(function (seg) {
seg.style.flex = '0 0 0';
seg.style.display = 'none';
});
return;
}
bar.classList.remove('is-empty');
segs.forEach(function (seg) {
const sev = seg.getAttribute('data-sev');
const count = bySeverity[sev] || 0;
if (count <= 0) {
seg.style.display = 'none';
seg.style.flex = '0 0 0';
return;
}
seg.style.display = '';
const pct = Math.max((count / total) * 100, 0);
seg.style.flex = '1 1 ' + pct + '%';
});
}
// 加载漏洞统计 // 加载漏洞统计
async function loadVulnerabilityStats() { async function loadVulnerabilityStats() {
try { try {
@@ -153,15 +643,33 @@ function updateVulnerabilityStats(stats) {
by_status: {} by_status: {}
}; };
} }
document.getElementById('stat-total').textContent = stats.total || 0; const total = stats.total || 0;
const bySeverity = stats.by_severity || {}; const bySeverity = stats.by_severity || {};
document.getElementById('stat-critical').textContent = bySeverity.critical || 0;
document.getElementById('stat-high').textContent = bySeverity.high || 0; const totalEl = document.getElementById('stat-total');
document.getElementById('stat-medium').textContent = bySeverity.medium || 0; if (totalEl) {
document.getElementById('stat-low').textContent = bySeverity.low || 0; totalEl.textContent = String(total);
document.getElementById('stat-info').textContent = bySeverity.info || 0; totalEl.classList.toggle('is-zero', total === 0);
}
VULN_STAT_SEVERITIES.forEach(function (sev) {
const count = bySeverity[sev] || 0;
const valEl = document.getElementById('stat-' + sev);
const pctEl = document.getElementById('stat-' + sev + '-pct');
if (valEl) {
valEl.textContent = String(count);
valEl.classList.toggle('is-zero', count === 0);
}
if (pctEl) {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
pctEl.textContent = pct + '%';
pctEl.setAttribute('aria-hidden', total === 0 ? 'true' : 'false');
}
});
updateVulnerabilityStatStackedBar(bySeverity, total);
syncVulnerabilityStatCardActiveState();
} }
// 加载漏洞列表 // 加载漏洞列表
@@ -575,32 +1083,26 @@ function closeVulnerabilityModal() {
currentVulnerabilityId = null; currentVulnerabilityId = null;
} }
// 筛选漏洞 // 筛选漏洞(应用当前表单条件)
function filterVulnerabilities() { function filterVulnerabilities() {
vulnerabilityFilters.id = document.getElementById('vulnerability-id-filter').value.trim(); applyVulnerabilityFilters();
vulnerabilityFilters.conversation_id = document.getElementById('vulnerability-conversation-filter').value.trim();
vulnerabilityFilters.task_id = document.getElementById('vulnerability-task-filter').value.trim();
vulnerabilityFilters.conversation_tag = document.getElementById('vulnerability-conversation-tag-filter').value.trim();
vulnerabilityFilters.task_tag = document.getElementById('vulnerability-task-tag-filter').value.trim();
vulnerabilityFilters.severity = document.getElementById('vulnerability-severity-filter').value;
vulnerabilityFilters.status = document.getElementById('vulnerability-status-filter').value;
// 重置到第一页
vulnerabilityPagination.currentPage = 1;
loadVulnerabilityStats();
loadVulnerabilities();
} }
// 清除筛选 // 清除筛选
function clearVulnerabilityFilters() { function clearVulnerabilityFilters() {
document.getElementById('vulnerability-id-filter').value = ''; const fields = [
document.getElementById('vulnerability-conversation-filter').value = ''; 'vulnerability-id-filter',
document.getElementById('vulnerability-task-filter').value = ''; 'vulnerability-conversation-filter',
document.getElementById('vulnerability-conversation-tag-filter').value = ''; 'vulnerability-task-filter',
document.getElementById('vulnerability-task-tag-filter').value = ''; 'vulnerability-conversation-tag-filter',
document.getElementById('vulnerability-severity-filter').value = ''; 'vulnerability-task-tag-filter',
document.getElementById('vulnerability-status-filter').value = ''; 'vulnerability-severity-filter',
'vulnerability-status-filter'
];
fields.forEach(function (id) {
const el = document.getElementById(id);
if (el) el.value = '';
});
vulnerabilityFilters = { vulnerabilityFilters = {
id: '', id: '',
@@ -612,11 +1114,7 @@ function clearVulnerabilityFilters() {
status: '' status: ''
}; };
// 重置到第一页 applyVulnerabilityFilters();
vulnerabilityPagination.currentPage = 1;
loadVulnerabilityStats();
loadVulnerabilities();
} }
// 刷新漏洞 // 刷新漏洞
@@ -892,6 +1390,7 @@ window.onclick = function(event) {
document.addEventListener('languagechange', function () { document.addEventListener('languagechange', function () {
const page = document.getElementById('page-vulnerabilities'); const page = document.getElementById('page-vulnerabilities');
if (page && page.classList.contains('active')) { if (page && page.classList.contains('active')) {
renderVulnerabilityFilterChips();
loadVulnerabilities(); loadVulnerabilities();
} }
}); });
+89 -56
View File
@@ -1666,7 +1666,13 @@ function buildWebshellTimelineItemFromDetail(detail) {
var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具'); var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具');
var idx = data.index || 0; var idx = data.index || 0;
var total = data.total || 0; var total = data.total || 0;
title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''))); var wsHint = typeof window.toolCallArgHint === 'function'
? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(data) : {})
: '';
var wsCallTitle = typeof window.formatToolCallTimelineTitle === 'function'
? window.formatToolCallTimelineTitle(tn, idx, total, wsHint)
: ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
title = ap + '🔧 ' + wsCallTitle;
} else if (eventType === 'tool_result') { } else if (eventType === 'tool_result') {
var success = data.success !== false; var success = data.success !== false;
var tname = data.toolName || '工具'; var tname = data.toolName || '工具';
@@ -1695,6 +1701,9 @@ function buildWebshellTimelineItemFromDetail(detail) {
html += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' + escapeHtml(paramsLabel) + '</strong><pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre></div></div>'; html += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' + escapeHtml(paramsLabel) + '</strong><pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre></div></div>';
} }
} catch (e) {} } catch (e) {}
}
if (eventType === 'tool_call' && data && data._mergedResult && typeof window.buildToolResultSectionHtml === 'function') {
html += '<div class="webshell-ai-timeline-msg tool-result-slot">' + window.buildToolResultSectionHtml(data._mergedResult) + '</div>';
} else if (eventType === 'tool_result' && data) { } else if (eventType === 'tool_result' && data) {
var isError = data.isError || data.success === false; var isError = data.isError || data.success === false;
var noResultText = (typeof window.t === 'function') ? window.t('timeline.noResult') : '无结果'; var noResultText = (typeof window.t === 'function') ? window.t('timeline.noResult') : '无结果';
@@ -1712,6 +1721,9 @@ function buildWebshellTimelineItemFromDetail(detail) {
// 渲染「执行过程及调用工具」折叠块(默认折叠,刷新后加载历史时保留并可展开) // 渲染「执行过程及调用工具」折叠块(默认折叠,刷新后加载历史时保留并可展开)
function renderWebshellProcessDetailsBlock(processDetails, defaultCollapsed) { function renderWebshellProcessDetailsBlock(processDetails, defaultCollapsed) {
if (!processDetails || processDetails.length === 0) return null; if (!processDetails || processDetails.length === 0) return null;
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
}
var expandLabel = (typeof window.t === 'function') ? window.t('chat.expandDetail') : '展开详情'; var expandLabel = (typeof window.t === 'function') ? window.t('chat.expandDetail') : '展开详情';
var collapseLabel = (typeof window.t === 'function') ? window.t('tasks.collapseDetail') : '收起详情'; var collapseLabel = (typeof window.t === 'function') ? window.t('tasks.collapseDetail') : '收起详情';
var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具'; var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具';
@@ -2772,7 +2784,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
var html = '<span class="webshell-ai-timeline-title">' + escapeHtml(title || message || '') + '</span>'; var html = '<span class="webshell-ai-timeline-title">' + escapeHtml(title || message || '') + '</span>';
// 工具调用入参 // 工具调用入参 + 结果同卡
if (type === 'tool_call' && data) { if (type === 'tool_call' && data) {
try { try {
var args = data.argumentsObj; var args = data.argumentsObj;
@@ -2783,14 +2795,20 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
args = { _raw: String(data.arguments) }; args = { _raw: String(data.arguments) };
} }
} }
if (args && typeof args === 'object') { if (args == null || typeof args !== 'object') {
var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:'; args = {};
html += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' +
escapeHtml(paramsLabel) +
'</strong><pre class="tool-args">' +
escapeHtml(JSON.stringify(args, null, 2)) +
'</pre></div></div>';
} }
var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:';
var pendingResult = (typeof window.buildToolResultSectionHtml === 'function')
? window.buildToolResultSectionHtml({}, { pending: true })
: '';
html += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' +
escapeHtml(paramsLabel) +
'</strong><pre class="tool-args">' +
escapeHtml(JSON.stringify(args, null, 2)) +
'</pre></div>' +
(pendingResult ? '<div class="tool-result-slot">' + pendingResult + '</div>' : '') +
'</div>';
} catch (e) { } catch (e) {
// JSON 解析失败时忽略参数详情,避免打断主流程 // JSON 解析失败时忽略参数详情,避免打断主流程
} }
@@ -2829,6 +2847,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
var einoSubReplyStreams = new Map(); var einoSubReplyStreams = new Map();
var wsThinkingStreams = new Map(); // streamId → { el, buf } var wsThinkingStreams = new Map(); // streamId → { el, buf }
var wsToolResultStreams = new Map(); // toolCallId → { el, buf } var wsToolResultStreams = new Map(); // toolCallId → { el, buf }
var wsToolCallItems = new Map(); // toolCallId → DOM item(参数+结果同卡)
if (inputEl) inputEl.value = ''; if (inputEl) inputEl.value = '';
@@ -2905,11 +2924,16 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight; messagesContainer.scrollTop = messagesContainer.scrollHeight;
} else if (_et === 'response_delta') { } else if (_et === 'response_delta') {
var deltaText = (_em != null && _em !== '') ? String(_em) : ''; var deltaText = (_em != null && _em !== '') ? String(_em) : '';
if (deltaText) { var mergeBuf = (typeof window.mergeStreamBuffer === 'function')
var normR = (typeof window.normalizeStreamingDeltaJs === 'function') ? window.mergeStreamBuffer
? window.normalizeStreamingDeltaJs(streamingTarget, deltaText) : function (cur, dlt) {
: [streamingTarget + deltaText, deltaText]; var normR = (typeof window.normalizeStreamingDeltaJs === 'function')
streamingTarget = normR[0]; ? window.normalizeStreamingDeltaJs(cur, dlt)
: [cur + dlt, dlt];
return normR[0];
};
if (deltaText || (_ed && _ed.accumulated != null)) {
streamingTarget = mergeBuf(streamingTarget, deltaText, _ed);
webshellStreamingTypingId += 1; webshellStreamingTypingId += 1;
streamingTypingId = webshellStreamingTypingId; streamingTypingId = webshellStreamingTypingId;
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer); runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
@@ -2982,12 +3006,17 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' }); wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' });
} }
if (!streamingTarget) assistantDiv.textContent = '…'; if (!streamingTarget) assistantDiv.textContent = '…';
} else if ((_et === 'thinking_stream_delta' || _et === 'reasoning_chain_stream_delta') && _ed.streamId) { } else if ((_et === 'thinking_stream_delta' || _et === 'reasoning_chain_stream_delta') && _ed && _ed.streamId) {
var tsD = wsThinkingStreams.get(_ed.streamId); var tsD = wsThinkingStreams.get(_ed.streamId);
if (tsD) { if (tsD) {
var normT = (typeof window.normalizeStreamingDeltaJs === 'function') var mergeThink = (typeof window.mergeStreamBuffer === 'function')
? window.normalizeStreamingDeltaJs(tsD.buf, _em || '') : [tsD.buf + (_em || ''), _em || '']; ? window.mergeStreamBuffer
tsD.buf = normT[0]; : function (cur, dlt) {
var normT = (typeof window.normalizeStreamingDeltaJs === 'function')
? window.normalizeStreamingDeltaJs(cur, dlt) : [cur + dlt, dlt];
return normT[0];
};
tsD.buf = mergeThink(tsD.buf, _em || '', _ed);
if (typeof formatMarkdown === 'function') { if (typeof formatMarkdown === 'function') {
tsD.body.innerHTML = formatMarkdown(tsD.buf); tsD.body.innerHTML = formatMarkdown(tsD.buf);
} else { } else {
@@ -3035,11 +3064,16 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
var tn = _ed.toolName || '未知工具'; var tn = _ed.toolName || '未知工具';
var idx = _ed.index || 0; var idx = _ed.index || 0;
var total = _ed.total || 0; var total = _ed.total || 0;
var callTitle = wsTOr('chat.callTool', '') || ('调用工具: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')); var wsHintLive = typeof window.toolCallArgHint === 'function'
if (typeof window.t === 'function') { ? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(_ed) : {})
try { callTitle = window.t('chat.callTool', { name: tn, index: idx, total: total }); } catch (e) { /* */ } : '';
var callTitle = typeof window.formatToolCallTimelineTitle === 'function'
? window.formatToolCallTimelineTitle(tn, idx, total, wsHintLive)
: (wsTOr('chat.callTool', '') || ('调用工具: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
var callItem = appendTimelineItem('tool_call', webshellAgentPx(_ed) + '🔧 ' + callTitle, _em || '', _ed);
if (_ed.toolCallId && callItem) {
wsToolCallItems.set(_ed.toolCallId, callItem);
} }
appendTimelineItem('tool_call', webshellAgentPx(_ed) + '🔧 ' + callTitle, _em || '', _ed);
if (!streamingTarget) assistantDiv.textContent = '…'; if (!streamingTarget) assistantDiv.textContent = '…';
// ─── Tool result delta (streaming output) ─── // ─── Tool result delta (streaming output) ───
@@ -3049,22 +3083,18 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
if (trdDelta) { if (trdDelta) {
var trdState = wsToolResultStreams.get(trdKey); var trdState = wsToolResultStreams.get(trdKey);
if (!trdState) { if (!trdState) {
var trdName = _ed.toolName || '工具'; var callEl = wsToolCallItems.get(trdKey);
var runLabel = wsTOr('timeline.running', '执行中...'); trdState = { el: callEl || null, buf: '', onCall: !!callEl };
var trdItem = document.createElement('div');
trdItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-tool_result';
trdItem.innerHTML = '<span class="webshell-ai-timeline-title">' +
escapeHtml(webshellAgentPx(_ed) + '⏳ ' + runLabel + ' ' + trdName) +
'</span><div class="webshell-ai-timeline-msg"><div class="tool-result-section success">' +
'<pre class="tool-result"></pre></div></div>';
timelineContainer.appendChild(trdItem);
timelineContainer.classList.add('has-items');
trdState = { el: trdItem, buf: '' };
wsToolResultStreams.set(trdKey, trdState); wsToolResultStreams.set(trdKey, trdState);
} }
trdState.buf += trdDelta; trdState.buf += trdDelta;
var trdPre = trdState.el.querySelector('pre.tool-result'); if (trdState.el) {
if (trdPre) trdPre.textContent = trdState.buf; var trdPre = trdState.el.querySelector('pre.tool-result');
if (trdPre) {
trdPre.classList.remove('tool-result-pending');
trdPre.textContent = trdState.buf;
}
}
} }
if (!streamingTarget) assistantDiv.textContent = '…'; if (!streamingTarget) assistantDiv.textContent = '…';
@@ -3072,25 +3102,23 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
} else if (_et === 'tool_result' && _ed) { } else if (_et === 'tool_result' && _ed) {
var success = _ed.success !== false; var success = _ed.success !== false;
var tname = _ed.toolName || '工具'; var tname = _ed.toolName || '工具';
var titleText = wsTOr(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', '') || var merged = false;
(tname + (success ? ' 执行完成' : ' 执行失败')); if (_ed.toolCallId) {
if (typeof window.t === 'function') { var streamSt = wsToolResultStreams.get(_ed.toolCallId);
try { titleText = window.t(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', { name: tname }); } catch (e) { /* */ } var callElRes = wsToolCallItems.get(_ed.toolCallId) || (streamSt && streamSt.el);
if (callElRes && typeof window.mergeToolResultIntoCallItem === 'function') {
window.mergeToolResultIntoCallItem(callElRes, _ed);
merged = true;
wsToolResultStreams.delete(_ed.toolCallId);
wsToolCallItems.delete(_ed.toolCallId);
}
} }
// 如果有流式占位条目,更新标题 if (!merged) {
var trdExist = _ed.toolCallId ? wsToolResultStreams.get(_ed.toolCallId) : null; var titleText = wsTOr(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', '') ||
if (trdExist) { (tname + (success ? ' 执行完成' : ' 执行失败'));
var trdTitleEl = trdExist.el.querySelector('.webshell-ai-timeline-title'); if (typeof window.t === 'function') {
if (trdTitleEl) trdTitleEl.textContent = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText; try { titleText = window.t(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', { name: tname }); } catch (e) { /* */ }
// 更新结果内容 }
var resultText = _ed.result ? String(_ed.result) : (_em || '');
var trdPreEl = trdExist.el.querySelector('pre.tool-result');
if (trdPreEl && resultText) trdPreEl.textContent = resultText;
// 更新 section class
var trdSection = trdExist.el.querySelector('.tool-result-section');
if (trdSection) { trdSection.className = 'tool-result-section ' + (success ? 'success' : 'error'); }
wsToolResultStreams.delete(_ed.toolCallId);
} else {
var title = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText; var title = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText;
var sub = _em || (_ed.result ? String(_ed.result).slice(0, 300) : ''); var sub = _em || (_ed.result ? String(_ed.result).slice(0, 300) : '');
appendTimelineItem('tool_result', title, sub, _ed); appendTimelineItem('tool_result', title, sub, _ed);
@@ -3118,9 +3146,14 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
} else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) { } else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) {
var stD = einoSubReplyStreams.get(_ed.streamId); var stD = einoSubReplyStreams.get(_ed.streamId);
if (stD) { if (stD) {
var normS = (typeof window.normalizeStreamingDeltaJs === 'function') var mergeSub = (typeof window.mergeStreamBuffer === 'function')
? window.normalizeStreamingDeltaJs(stD.buf, _em || '') : [stD.buf + (_em || ''), _em || '']; ? window.mergeStreamBuffer
stD.buf = normS[0]; : function (cur, dlt) {
var normS = (typeof window.normalizeStreamingDeltaJs === 'function')
? window.normalizeStreamingDeltaJs(cur, dlt) : [cur + dlt, dlt];
return normS[0];
};
stD.buf = mergeSub(stD.buf, _em || '', _ed);
var preD = stD.el.querySelector('.webshell-eino-reply-stream-body'); var preD = stD.el.querySelector('.webshell-eino-reply-stream-body');
if (!preD) { if (!preD) {
preD = document.createElement('pre'); preD = document.createElement('pre');
+350
View File
@@ -0,0 +1,350 @@
// 微信 iLink 机器人:扫码绑定与状态轮询
let wechatBindSessionKey = null;
let wechatBindPollTimer = null;
function wechatT(key, fallback) {
return typeof t === 'function' ? t(key) : fallback;
}
function getWechatCard() {
return document.getElementById('robot-wechat-subsection');
}
function setWechatBadge(mode) {
const badge = document.getElementById('robot-wechat-status-badge');
if (!badge) return;
badge.classList.remove('robot-wechat-badge--idle', 'robot-wechat-badge--bound', 'robot-wechat-badge--scanning');
if (mode === 'bound') {
badge.classList.add('robot-wechat-badge--bound');
badge.textContent = wechatT('settings.robots.wechat.statusBound', '已连接');
} else if (mode === 'scanning') {
badge.classList.add('robot-wechat-badge--scanning');
badge.textContent = wechatT('settings.robots.wechat.statusScanning', '绑定中…');
} else {
badge.classList.add('robot-wechat-badge--idle');
badge.textContent = wechatT('settings.robots.wechat.statusIdle', '未绑定');
}
}
function setWechatCardBound(isBound) {
const card = getWechatCard();
if (card) card.classList.toggle('is-bound', !!isBound);
}
function updateWechatSteps(phase) {
const steps = document.querySelectorAll('.robot-wechat-step');
if (!steps.length) return;
const order = ['generate', 'scan', 'confirm'];
const idx = order.indexOf(phase);
steps.forEach((el, i) => {
el.classList.remove('is-active', 'is-done');
if (idx < 0) {
if (i === 0) el.classList.add('is-active');
} else if (i < idx) {
el.classList.add('is-done');
} else if (i === idx) {
el.classList.add('is-active');
}
});
}
function ensureWechatSteps() {
const panel = document.getElementById('robot-wechat-scan-panel');
if (!panel || panel.querySelector('.robot-wechat-steps')) return;
const ol = document.createElement('ol');
ol.className = 'robot-wechat-steps';
ol.innerHTML = `
<li class="robot-wechat-step is-active">${wechatT('settings.robots.wechat.step1', '生成二维码')}</li>
<li class="robot-wechat-step">${wechatT('settings.robots.wechat.step2', '微信扫码')}</li>
<li class="robot-wechat-step">${wechatT('settings.robots.wechat.step3', '确认绑定')}</li>`;
panel.insertBefore(ol, panel.firstChild);
}
function ensureWechatQrFrame() {
const img = document.getElementById('robot-wechat-qr-img');
if (!img || img.parentElement?.classList.contains('robot-wechat-qr-frame')) return;
const frame = document.createElement('div');
frame.className = 'robot-wechat-qr-frame';
img.parentNode.insertBefore(frame, img);
frame.appendChild(img);
let ph = document.getElementById('robot-wechat-qr-placeholder');
if (!ph) {
ph = document.createElement('div');
ph.id = 'robot-wechat-qr-placeholder';
ph.className = 'robot-wechat-qr-placeholder';
ph.setAttribute('aria-hidden', 'true');
ph.innerHTML = '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3h-3v-3zm4 0h3v3h-3v-3zm-4 4h3v3h-3v-3zm4 0h3v3h-3v-3z"/></svg>';
frame.appendChild(ph);
} else {
frame.appendChild(ph);
}
}
function stopWechatBindPoll() {
if (wechatBindPollTimer) {
clearTimeout(wechatBindPollTimer);
wechatBindPollTimer = null;
}
}
/** 已绑定:仅展示成功状态,不显示二维码/配对码 */
function showWechatBoundUI(wechat) {
const wc = wechat || {};
const wrap = document.getElementById('robot-wechat-qr-wrap');
const boundPanel = document.getElementById('robot-wechat-bound-panel');
const scanPanel = document.getElementById('robot-wechat-scan-panel');
const boundId = document.getElementById('robot-wechat-bound-id');
const btn = document.getElementById('robot-wechat-bind-btn');
stopWechatBindPoll();
wechatBindSessionKey = null;
setWechatBadge('bound');
setWechatCardBound(true);
if (wrap) wrap.hidden = false;
if (boundPanel) boundPanel.hidden = false;
if (scanPanel) scanPanel.hidden = true;
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
if (verifyWrap) verifyWrap.hidden = true;
const img = document.getElementById('robot-wechat-qr-img');
const ph = document.getElementById('robot-wechat-qr-placeholder');
if (img) {
img.removeAttribute('src');
img.hidden = true;
}
if (ph) ph.hidden = false;
if (boundId) {
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
if (id) {
boundId.textContent = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID') + id;
boundId.hidden = false;
} else {
boundId.textContent = '';
boundId.hidden = true;
}
}
if (btn) {
btn.textContent = wechatT('settings.robots.wechat.rebindButton', '重新绑定');
}
}
/** 扫码绑定进行中 */
function showWechatScanUI() {
const wrap = document.getElementById('robot-wechat-qr-wrap');
const boundPanel = document.getElementById('robot-wechat-bound-panel');
const scanPanel = document.getElementById('robot-wechat-scan-panel');
const btn = document.getElementById('robot-wechat-bind-btn');
setWechatBadge('scanning');
setWechatCardBound(false);
ensureWechatSteps();
updateWechatSteps('generate');
if (wrap) wrap.hidden = false;
if (boundPanel) boundPanel.hidden = true;
if (scanPanel) scanPanel.hidden = false;
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
if (verifyWrap) verifyWrap.hidden = true;
const verifyInput = document.getElementById('robot-wechat-verify-code');
if (verifyInput) verifyInput.value = '';
if (btn) {
btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定');
}
}
/** 未绑定且未在扫码:隐藏面板 */
function hideWechatQrWrap() {
const wrap = document.getElementById('robot-wechat-qr-wrap');
if (wrap) wrap.hidden = true;
setWechatBadge('idle');
setWechatCardBound(false);
}
function setWechatQrImage(data) {
ensureWechatQrFrame();
const img = document.getElementById('robot-wechat-qr-img');
const ph = document.getElementById('robot-wechat-qr-placeholder');
const linkEl = document.getElementById('robot-wechat-qr-link');
const openUrl = data.qrcode_open_url || data.qrcode_img_url || '';
if (img) {
if (data.qrcode_image_data_url) {
img.onload = () => {
img.hidden = false;
if (ph) ph.hidden = true;
};
img.onerror = () => {
img.hidden = true;
if (ph) ph.hidden = false;
};
img.src = data.qrcode_image_data_url;
updateWechatSteps('scan');
} else {
img.removeAttribute('src');
img.hidden = true;
if (ph) ph.hidden = false;
}
}
if (linkEl) {
if (openUrl) {
linkEl.href = openUrl;
linkEl.hidden = false;
} else {
linkEl.hidden = true;
}
}
}
function setWechatQrStatus(text, isError) {
const el = document.getElementById('robot-wechat-qr-status');
if (!el) return;
el.textContent = text || '';
el.classList.toggle('is-error', !!isError);
el.classList.toggle('is-success', !isError && !!text);
}
async function startWechatRobotBind() {
stopWechatBindPoll();
wechatBindSessionKey = null;
showWechatScanUI();
ensureWechatQrFrame();
const loading = document.getElementById('robot-wechat-qr-loading');
const img = document.getElementById('robot-wechat-qr-img');
const ph = document.getElementById('robot-wechat-qr-placeholder');
const btn = document.getElementById('robot-wechat-bind-btn');
if (loading) loading.hidden = false;
if (img) {
img.removeAttribute('src');
img.hidden = true;
}
if (ph) ph.hidden = false;
setWechatQrStatus('', false);
if (btn) btn.disabled = true;
const botType = document.getElementById('robot-wechat-bot-type')?.value.trim() || '3';
try {
const res = await apiFetch('/api/robot/wechat/qrcode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bot_type: botType })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || data.message || '获取二维码失败');
}
wechatBindSessionKey = data.session_key;
setWechatQrImage(data);
setWechatQrStatus(data.message || '请使用微信扫描二维码', false);
pollWechatBindStatus();
} catch (e) {
setWechatQrStatus(e.message || String(e), true);
setWechatBadge('idle');
} finally {
if (loading) loading.hidden = true;
if (btn) btn.disabled = false;
}
}
async function pollWechatBindStatus() {
if (!wechatBindSessionKey) return;
try {
const url = `/api/robot/wechat/qrcode/status?session_key=${encodeURIComponent(wechatBindSessionKey)}`;
const res = await apiFetch(url, { method: 'GET' });
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || '轮询失败');
}
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
switch (data.status) {
case 'confirmed':
stopWechatBindPoll();
updateWechatSteps('confirm');
document.getElementById('robot-wechat-enabled').checked = true;
if (data.ilink_bot_id) {
const idEl = document.getElementById('robot-wechat-ilink-bot-id');
if (idEl) idEl.value = data.ilink_bot_id;
}
if (typeof loadConfig === 'function') {
await loadConfig(false);
} else {
showWechatBoundUI({
ilink_bot_id: data.ilink_bot_id,
bound: true
});
}
return;
case 'need_verifycode':
updateWechatSteps('scan');
if (verifyWrap) verifyWrap.hidden = false;
setWechatQrStatus(data.message || '请输入手机微信显示的数字', false);
break;
case 'scaned':
updateWechatSteps('confirm');
if (verifyWrap) verifyWrap.hidden = true;
setWechatQrStatus('已扫码,请在手机上确认…', false);
break;
case 'binded_redirect':
stopWechatBindPoll();
showWechatBoundUI({ bound: true });
return;
case 'expired':
setWechatQrStatus('二维码已过期,请重新点击「生成二维码并绑定」', true);
setWechatBadge('scanning');
stopWechatBindPoll();
return;
default:
if (verifyWrap) verifyWrap.hidden = true;
break;
}
} catch (e) {
setWechatQrStatus(e.message || String(e), true);
}
wechatBindPollTimer = setTimeout(pollWechatBindStatus, 1500);
}
async function submitWechatVerifyCode() {
const code = document.getElementById('robot-wechat-verify-code')?.value.trim();
if (!code || !wechatBindSessionKey) return;
try {
const res = await apiFetch('/api/robot/wechat/qrcode/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_key: wechatBindSessionKey, verify_code: code })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '提交失败');
setWechatQrStatus(data.message || '已提交配对码,等待确认…', false);
pollWechatBindStatus();
} catch (e) {
setWechatQrStatus(e.message || String(e), true);
}
}
function refreshWechatRobotBoundUI(wechat) {
const wc = wechat || {};
const isBound = wc.bound || (wc.bot_token && wc.ilink_bot_id) || !!(wc.ilink_bot_id && wc.enabled);
if (isBound) {
showWechatBoundUI(wc);
} else {
hideWechatQrWrap();
const btn = document.getElementById('robot-wechat-bind-btn');
if (btn) {
btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定');
}
}
}
+231 -119
View File
@@ -190,10 +190,12 @@
<!-- C2 侧栏入口(带子菜单) --> <!-- C2 侧栏入口(带子菜单) -->
<div class="nav-item nav-item-has-submenu" data-page="c2" id="nav-c2"> <div class="nav-item nav-item-has-submenu" data-page="c2" id="nav-c2">
<div class="nav-item-content" data-title="C2" onclick="window.toggleSubmenu('c2')" data-i18n="nav.c2" data-i18n-attr="data-title" data-i18n-skip-text="true"> <div class="nav-item-content" data-title="C2" onclick="window.toggleSubmenu('c2')" data-i18n="nav.c2" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5z"></path> <path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M2 17l10 5 10-5"></path> <path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<path d="M2 12l10 5 10-5"></path> <circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
</svg> </svg>
<span data-i18n="nav.c2">C2</span> <span data-i18n="nav.c2">C2</span>
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -201,6 +203,7 @@
</svg> </svg>
</div> </div>
<div class="nav-submenu" id="submenu-c2"> <div class="nav-submenu" id="submenu-c2">
<div class="nav-submenu-item" data-page="c2" onclick="switchPage('c2')" data-i18n="nav.c2Manage">C2 管理</div>
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div> <div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div> <div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div> <div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
@@ -446,42 +449,46 @@
</div> </div>
</aside> </aside>
<div class="dashboard-severity-chart"> <div class="dashboard-severity-chart">
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true"> <svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" role="img" aria-labelledby="dashboard-severity-donut-title">
<title id="dashboard-severity-donut-title" data-i18n="dashboard.severityDistribution">漏洞严重程度分布</title>
<defs id="dashboard-severity-donut-defs"></defs>
<g id="dashboard-severity-donut-track"></g> <g id="dashboard-severity-donut-track"></g>
<g id="dashboard-severity-donut-leaders"></g>
<g id="dashboard-severity-donut-segments"></g> <g id="dashboard-severity-donut-segments"></g>
<g id="dashboard-severity-donut-hits"></g>
<g id="dashboard-severity-donut-labels"></g> <g id="dashboard-severity-donut-labels"></g>
</svg> </svg>
<div class="dashboard-severity-center"> <div class="dashboard-severity-center" id="dashboard-severity-center">
<div class="dashboard-severity-center-value" id="dashboard-severity-total">0</div> <div class="dashboard-severity-center-value" id="dashboard-severity-total">0</div>
<div class="dashboard-severity-center-label" data-i18n="dashboard.totalVulns">总漏洞数</div> <div class="dashboard-severity-center-label" id="dashboard-severity-center-label" data-i18n="dashboard.totalVulns">总漏洞数</div>
</div> </div>
</div> </div>
<div class="dashboard-severity-legend" id="dashboard-vuln-bars"> <div class="dashboard-severity-legend" id="dashboard-vuln-bars">
<div class="dashboard-severity-legend-item"> <div class="dashboard-severity-legend-item" data-severity="critical" role="button" tabindex="0">
<span class="dashboard-severity-legend-dot critical"></span> <span class="dashboard-severity-legend-dot critical"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityCritical">严重</span> <span class="dashboard-severity-legend-label" data-i18n="dashboard.severityCritical">严重</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-critical">0</span> <span class="dashboard-severity-legend-value" id="dashboard-severity-critical">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-critical-pct">0%</span> <span class="dashboard-severity-legend-pct" id="dashboard-severity-critical-pct">0%</span>
</div> </div>
<div class="dashboard-severity-legend-item"> <div class="dashboard-severity-legend-item" data-severity="high" role="button" tabindex="0">
<span class="dashboard-severity-legend-dot high"></span> <span class="dashboard-severity-legend-dot high"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityHigh">高危</span> <span class="dashboard-severity-legend-label" data-i18n="dashboard.severityHigh">高危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-high">0</span> <span class="dashboard-severity-legend-value" id="dashboard-severity-high">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-high-pct">0%</span> <span class="dashboard-severity-legend-pct" id="dashboard-severity-high-pct">0%</span>
</div> </div>
<div class="dashboard-severity-legend-item"> <div class="dashboard-severity-legend-item" data-severity="medium" role="button" tabindex="0">
<span class="dashboard-severity-legend-dot medium"></span> <span class="dashboard-severity-legend-dot medium"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityMedium">中危</span> <span class="dashboard-severity-legend-label" data-i18n="dashboard.severityMedium">中危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-medium">0</span> <span class="dashboard-severity-legend-value" id="dashboard-severity-medium">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-medium-pct">0%</span> <span class="dashboard-severity-legend-pct" id="dashboard-severity-medium-pct">0%</span>
</div> </div>
<div class="dashboard-severity-legend-item"> <div class="dashboard-severity-legend-item" data-severity="low" role="button" tabindex="0">
<span class="dashboard-severity-legend-dot low"></span> <span class="dashboard-severity-legend-dot low"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityLow">低危</span> <span class="dashboard-severity-legend-label" data-i18n="dashboard.severityLow">低危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-low">0</span> <span class="dashboard-severity-legend-value" id="dashboard-severity-low">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-low-pct">0%</span> <span class="dashboard-severity-legend-pct" id="dashboard-severity-low-pct">0%</span>
</div> </div>
<div class="dashboard-severity-legend-item"> <div class="dashboard-severity-legend-item" data-severity="info" role="button" tabindex="0">
<span class="dashboard-severity-legend-dot info"></span> <span class="dashboard-severity-legend-dot info"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityInfo">信息</span> <span class="dashboard-severity-legend-label" data-i18n="dashboard.severityInfo">信息</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-info">0</span> <span class="dashboard-severity-legend-value" id="dashboard-severity-info">0</span>
@@ -792,11 +799,51 @@
<div id="conversations-list" class="conversations-list"></div> <div id="conversations-list" class="conversations-list"></div>
</div> </div>
</div> </div>
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper conversation-reasoning-card conversation-reasoning-collapsed" style="display: none;">
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
<div class="conversation-reasoning-heading">
<span class="conversation-reasoning-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.75"/>
<path d="M16 16l5 5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
</span>
<div class="conversation-reasoning-heading-text">
<span class="conversation-reasoning-title" data-i18n="chat.reasoningPanelTitle">模型推理</span>
<span id="chat-reasoning-summary" class="conversation-reasoning-summary"></span>
</div>
</div>
</button>
<div id="conversation-reasoning-body" class="conversation-reasoning-body" role="region">
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
<div class="chat-reasoning-fields">
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
</select>
</div>
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="max">max</option>
</select>
</div>
</div>
</div>
</div>
<div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card"> <div class="hitl-sidebar-card hitl-sidebar-collapsed" id="hitl-sidebar-card">
<div class="hitl-sidebar-card-header" onclick="toggleHitlSidebarCard()"> <div class="hitl-sidebar-card-header" onclick="toggleHitlSidebarCard()">
<div class="hitl-sidebar-heading"> <div class="hitl-sidebar-heading">
<span class="hitl-sidebar-icon" aria-hidden="true"> <span class="hitl-sidebar-icon" aria-hidden="true">
<svg width="18" height="18" 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">
<path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"/> <path d="M12 2L4 5v6.09c0 5.05 3.41 9.76 8 10.91 4.59-1.15 8-5.86 8-10.91V5l-8-3z" stroke="currentColor" stroke-width="1.75" stroke-linejoin="round"/>
<path d="M9.5 12.5l2 2 3-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/> <path d="M9.5 12.5l2 2 3-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
@@ -981,49 +1028,6 @@
</div> </div>
<input type="hidden" id="agent-mode-select" value="react" autocomplete="off"> <input type="hidden" id="agent-mode-select" value="react" autocomplete="off">
</div> </div>
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper" style="display: none;">
<div class="chat-reasoning-inner">
<button type="button" id="chat-reasoning-btn" class="role-selector-btn chat-reasoning-btn" onclick="toggleChatReasoningPanel()" aria-expanded="false" aria-haspopup="dialog" aria-controls="chat-reasoning-panel" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
<span class="chat-reasoning-btn-icon" aria-hidden="true">🔎</span>
<span id="chat-reasoning-summary" class="role-selector-text chat-reasoning-btn-summary"></span>
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div id="chat-reasoning-panel" class="chat-reasoning-panel" style="display: none;" role="dialog" aria-labelledby="chat-reasoning-panel-title">
<div class="role-selection-panel-header chat-reasoning-panel-header">
<h3 id="chat-reasoning-panel-title" class="role-selection-panel-title" data-i18n="chat.reasoningPanelTitle">模型推理</h3>
<button type="button" class="role-selection-panel-close" onclick="closeChatReasoningPanel()" data-i18n="common.close" data-i18n-attr="title" title="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<p class="chat-reasoning-panel-hint" data-i18n="chat.reasoningPanelHint">仅 Eino 请求生效,与系统设置中的默认值合并。</p>
<div class="chat-reasoning-fields">
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-reasoning-mode"><span data-i18n="chat.reasoningModeLabel">模式</span></label>
<select id="chat-reasoning-mode" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
<option value="default" data-i18n="chat.reasoningModeDefault">跟随系统</option>
<option value="off" data-i18n="chat.reasoningModeOff">关闭</option>
<option value="on" data-i18n="chat.reasoningModeOn">开启</option>
<option value="auto" data-i18n="chat.reasoningModeAuto">自动</option>
</select>
</div>
<div class="chat-reasoning-field">
<label class="chat-reasoning-field-label" for="chat-reasoning-effort"><span data-i18n="chat.reasoningEffortLabel">推理强度</span></label>
<select id="chat-reasoning-effort" class="chat-reasoning-select" onchange="persistChatReasoningPrefs()">
<option value="" data-i18n="chat.reasoningEffortUnset">不指定</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="max">max</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="chat-input-with-files"> <div class="chat-input-with-files">
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div> <div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
@@ -1078,10 +1082,13 @@
<div class="page-content"> <div class="page-content">
<div class="monitor-sections"> <div class="monitor-sections">
<section class="monitor-section monitor-overview"> <section class="monitor-section monitor-overview">
<div class="section-header"> <div class="section-header monitor-stats-section-header">
<h3 data-i18n="mcp.execStats">执行统计</h3> <div class="monitor-stats-header-text">
<h3 data-i18n="mcp.execStats">执行统计</h3>
<p id="monitor-stats-subtitle" class="monitor-stats-subtitle" hidden></p>
</div>
</div> </div>
<div id="monitor-stats" class="monitor-stats-grid"> <div id="monitor-stats" class="mcp-exec-stats-root">
<div class="monitor-empty" data-i18n="mcpMonitor.loading">加载中...</div> <div class="monitor-empty" data-i18n="mcpMonitor.loading">加载中...</div>
</div> </div>
</section> </section>
@@ -1381,89 +1388,124 @@
<div class="page-header"> <div class="page-header">
<h2 data-i18n="vulnerability.title">漏洞管理</h2> <h2 data-i18n="vulnerability.title">漏洞管理</h2>
<div class="page-header-actions"> <div class="page-header-actions">
<button class="btn-secondary" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
<button class="btn-secondary" onclick="refreshVulnerabilities()" data-i18n="common.refresh">刷新</button> <button class="btn-secondary" onclick="refreshVulnerabilities()" data-i18n="common.refresh">刷新</button>
<button class="btn-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button> <button class="btn-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
</div> </div>
</div> </div>
<div class="page-content"> <div class="page-content">
<!-- 统计看板 --> <!-- 统计看板:点击卡片筛选严重度,与下方下拉/地址栏 hash 同步 -->
<div class="vulnerability-dashboard" id="vulnerability-dashboard"> <div class="vulnerability-dashboard" id="vulnerability-dashboard">
<div class="dashboard-stats"> <div class="dashboard-stats" id="vulnerability-stat-cards" role="group" aria-label="漏洞严重度统计">
<div class="stat-card"> <div class="stat-card stat-card-total is-clickable is-active" data-severity="" role="button" tabindex="0"
data-i18n="vulnerabilityPage.statClickAll" data-i18n-attr="title" title="查看全部(清除严重度筛选)">
<div class="stat-label" data-i18n="vulnerabilityPage.statTotal">总漏洞数</div> <div class="stat-label" data-i18n="vulnerabilityPage.statTotal">总漏洞数</div>
<div class="stat-value" id="stat-total">-</div> <div class="stat-value" id="stat-total">-</div>
<div class="stat-stacked-bar" id="stat-stacked-bar" aria-hidden="true">
<span class="stat-stacked-seg critical" data-sev="critical"></span>
<span class="stat-stacked-seg high" data-sev="high"></span>
<span class="stat-stacked-seg medium" data-sev="medium"></span>
<span class="stat-stacked-seg low" data-sev="low"></span>
<span class="stat-stacked-seg info" data-sev="info"></span>
</div>
</div> </div>
<div class="stat-card stat-critical"> <div class="stat-card stat-critical is-clickable" data-severity="critical" role="button" tabindex="0"
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
<div class="stat-label" data-i18n="dashboard.severityCritical">严重</div> <div class="stat-label" data-i18n="dashboard.severityCritical">严重</div>
<div class="stat-value" id="stat-critical">-</div> <div class="stat-value" id="stat-critical">-</div>
<div class="stat-pct" id="stat-critical-pct" aria-hidden="true"></div>
</div> </div>
<div class="stat-card stat-high"> <div class="stat-card stat-high is-clickable" data-severity="high" role="button" tabindex="0"
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
<div class="stat-label" data-i18n="dashboard.severityHigh">高危</div> <div class="stat-label" data-i18n="dashboard.severityHigh">高危</div>
<div class="stat-value" id="stat-high">-</div> <div class="stat-value" id="stat-high">-</div>
<div class="stat-pct" id="stat-high-pct" aria-hidden="true"></div>
</div> </div>
<div class="stat-card stat-medium"> <div class="stat-card stat-medium is-clickable" data-severity="medium" role="button" tabindex="0"
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
<div class="stat-label" data-i18n="dashboard.severityMedium">中危</div> <div class="stat-label" data-i18n="dashboard.severityMedium">中危</div>
<div class="stat-value" id="stat-medium">-</div> <div class="stat-value" id="stat-medium">-</div>
<div class="stat-pct" id="stat-medium-pct" aria-hidden="true"></div>
</div> </div>
<div class="stat-card stat-low"> <div class="stat-card stat-low is-clickable" data-severity="low" role="button" tabindex="0"
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
<div class="stat-label" data-i18n="dashboard.severityLow">低危</div> <div class="stat-label" data-i18n="dashboard.severityLow">低危</div>
<div class="stat-value" id="stat-low">-</div> <div class="stat-value" id="stat-low">-</div>
<div class="stat-pct" id="stat-low-pct" aria-hidden="true"></div>
</div> </div>
<div class="stat-card stat-info"> <div class="stat-card stat-info is-clickable" data-severity="info" role="button" tabindex="0"
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
<div class="stat-label" data-i18n="dashboard.severityInfo">信息</div> <div class="stat-label" data-i18n="dashboard.severityInfo">信息</div>
<div class="stat-value" id="stat-info">-</div> <div class="stat-value" id="stat-info">-</div>
<div class="stat-pct" id="stat-info-pct" aria-hidden="true"></div>
</div> </div>
</div> </div>
</div> </div>
<!-- 筛选和搜索 --> <!-- 筛选 -->
<div class="vulnerability-controls"> <div class="vulnerability-controls" id="vulnerability-filter-panel">
<div class="vulnerability-filters"> <div class="vulnerability-filter-toolbar">
<label> <div class="vulnerability-filter-primary">
<span data-i18n="vulnerabilityPage.vulnId">漏洞ID</span> <label class="vulnerability-filter-field vulnerability-filter-field--grow">
<input type="text" id="vulnerability-id-filter" data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞ID" /> <span class="sr-only" data-i18n="vulnerabilityPage.vulnId">漏洞ID</span>
</label> <input type="search" id="vulnerability-id-filter" autocomplete="off"
<label> data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞 ID,回车筛选" />
<span data-i18n="vulnerabilityPage.conversationId">会话ID</span> </label>
<input type="text" id="vulnerability-conversation-filter" data-i18n="vulnerabilityPage.filterConversation" data-i18n-attr="placeholder" placeholder="筛选特定会话" /> <label class="vulnerability-filter-field vulnerability-filter-field--status">
</label> <span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
<label> <select id="vulnerability-status-filter" data-i18n-attr="title" title="状态">
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务ID/队列ID</span> <option value="" data-i18n="knowledgePage.all">全部状态</option>
<input type="text" id="vulnerability-task-filter" data-i18n="vulnerabilityPage.filterTaskOrQueue" data-i18n-attr="placeholder" placeholder="筛选任务ID或队列ID" /> <option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
</label> <option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
<label> <option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span> <option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
<input type="text" id="vulnerability-conversation-tag-filter" data-i18n="vulnerabilityPage.filterConversationTag" data-i18n-attr="placeholder" placeholder="筛选对话标签" /> </select>
</label> </label>
<label> <button type="button" class="vulnerability-filter-clear-btn" onclick="clearVulnerabilityFilters()" data-i18n="vulnerabilityPage.clear">清除</button>
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span> </div>
<input type="text" id="vulnerability-task-tag-filter" data-i18n="vulnerabilityPage.filterTaskTag" data-i18n-attr="placeholder" placeholder="筛选任务标签" /> <select id="vulnerability-severity-filter" class="vulnerability-severity-sync" hidden aria-hidden="true" tabindex="-1">
</label> <option value=""></option>
<label> <option value="critical">critical</option>
<span data-i18n="vulnerabilityPage.severity">严重程度</span> <option value="high">high</option>
<select id="vulnerability-severity-filter"> <option value="medium">medium</option>
<option value="" data-i18n="knowledgePage.all">全部</option> <option value="low">low</option>
<option value="critical" data-i18n="dashboard.severityCritical">严重</option> <option value="info">info</option>
<option value="high" data-i18n="dashboard.severityHigh">高危</option> </select>
<option value="medium" data-i18n="dashboard.severityMedium">中危</option>
<option value="low" data-i18n="dashboard.severityLow">低危</option>
<option value="info" data-i18n="dashboard.severityInfo">信息</option>
</select>
</label>
<label>
<span data-i18n="vulnerabilityPage.status">状态</span>
<select id="vulnerability-status-filter">
<option value="" data-i18n="knowledgePage.all">全部</option>
<option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
</select>
</label>
<button class="btn-secondary" onclick="filterVulnerabilities()" data-i18n="vulnerabilityPage.filter">筛选</button>
<button class="btn-secondary" onclick="clearVulnerabilityFilters()" data-i18n="vulnerabilityPage.clear">清除</button>
<button class="btn-primary" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
</div> </div>
<div class="vulnerability-filter-advanced-wrap">
<button type="button" class="vulnerability-filter-advanced-toggle" id="vulnerability-advanced-toggle"
aria-expanded="false" aria-controls="vulnerability-advanced-filters"
onclick="toggleVulnerabilityAdvancedFilters(event)">
<span class="vulnerability-filter-advanced-chevron" aria-hidden="true"></span>
<span data-i18n="vulnerabilityPage.advancedFilters">高级筛选</span>
<span class="vulnerability-filter-advanced-badge" id="vulnerability-advanced-badge" hidden></span>
</button>
<div class="vulnerability-filter-advanced" id="vulnerability-advanced-filters" hidden>
<label class="vulnerability-filter-field">
<span data-i18n="vulnerabilityPage.conversationId">会话 ID</span>
<input type="text" id="vulnerability-conversation-filter" list="vulnerability-conversation-suggestions" placeholder="回车筛选" />
</label>
<label class="vulnerability-filter-field">
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务 / 队列 ID</span>
<input type="text" id="vulnerability-task-filter" list="vulnerability-task-suggestions" placeholder="回车筛选" />
</label>
<label class="vulnerability-filter-field">
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span>
<input type="text" id="vulnerability-conversation-tag-filter" list="vulnerability-conversation-tag-suggestions" placeholder="回车筛选" />
</label>
<label class="vulnerability-filter-field">
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span>
<input type="text" id="vulnerability-task-tag-filter" list="vulnerability-task-tag-suggestions" placeholder="回车筛选" />
</label>
</div>
</div>
<div class="vulnerability-filter-chips" id="vulnerability-filter-chips" hidden>
<div class="vulnerability-filter-chips-list" id="vulnerability-filter-chips-list" role="list"></div>
</div>
<datalist id="vulnerability-conversation-suggestions"></datalist>
<datalist id="vulnerability-task-suggestions"></datalist>
<datalist id="vulnerability-conversation-tag-suggestions"></datalist>
<datalist id="vulnerability-task-tag-suggestions"></datalist>
</div> </div>
<!-- 漏洞列表 --> <!-- 漏洞列表 -->
@@ -1608,11 +1650,13 @@
<div id="c2-main" class="c2-main"> <div id="c2-main" class="c2-main">
<div class="c2-welcome"> <div class="c2-welcome">
<div class="c2-welcome-icon"> <div class="c2-welcome-icon">
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs> <defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs>
<path d="M12 2L2 7l10 5 10-5-10-5z"></path> <path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
<path d="M2 17l10 5 10-5"></path> <path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
<path d="M2 12l10 5 10-5"></path> <circle cx="12" cy="12" r="2"></circle>
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
</svg> </svg>
</div> </div>
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3> <h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
@@ -2335,6 +2379,73 @@
<p class="settings-description" data-i18n="settings.robots.description">配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。</p> <p class="settings-description" data-i18n="settings.robots.description">配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。</p>
</div> </div>
<!-- 微信 / iLink -->
<div class="settings-subsection robot-wechat-card" id="robot-wechat-subsection">
<div class="robot-wechat-header">
<div class="robot-wechat-header-text">
<h4 data-i18n="settings.robots.wechat.title">微信 / iLink</h4>
<p class="robot-wechat-subtitle" data-i18n="settings.robots.wechat.subtitle">扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话</p>
</div>
<span id="robot-wechat-status-badge" class="robot-wechat-badge robot-wechat-badge--idle" data-i18n="settings.robots.wechat.statusIdle">未绑定</span>
</div>
<div class="settings-form robot-wechat-form">
<div class="robot-wechat-toolbar">
<label class="checkbox-label">
<input type="checkbox" id="robot-wechat-enabled" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settings.robots.wechat.enabled">启用微信机器人</span>
</label>
</div>
<div class="robot-wechat-action-row">
<button type="button" class="btn-primary" id="robot-wechat-bind-btn" onclick="startWechatRobotBind()" data-i18n="settings.robots.wechat.bindButton">生成二维码并绑定</button>
<p class="robot-wechat-hint" id="robot-wechat-bind-hint" data-i18n="settings.robots.wechat.bindHint">用微信扫码确认后会自动保存并启用。</p>
</div>
<div id="robot-wechat-qr-wrap" class="robot-wechat-panel" hidden>
<div id="robot-wechat-bound-panel" class="robot-wechat-bound-panel" hidden>
<div class="robot-wechat-bound-icon" aria-hidden="true">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<p class="robot-wechat-bound-msg" data-i18n="settings.robots.wechat.boundSuccess">绑定成功,微信机器人已启用</p>
<p id="robot-wechat-bound-id" class="robot-wechat-bound-id" hidden></p>
</div>
<div id="robot-wechat-scan-panel" class="robot-wechat-scan-panel" hidden>
<div id="robot-wechat-qr-loading" class="robot-wechat-qr-loading" hidden data-i18n="settings.robots.wechat.qrLoading">正在生成二维码…</div>
<img id="robot-wechat-qr-img" class="robot-wechat-qr-img" alt="" width="220" height="220" hidden />
<p class="robot-wechat-qr-fallback">
<a id="robot-wechat-qr-link" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n="settings.robots.wechat.openLink">无法显示二维码?点击用手机微信打开链接</a>
</p>
<p id="robot-wechat-qr-status" class="robot-wechat-qr-status"></p>
<div id="robot-wechat-verify-wrap" class="robot-wechat-verify-wrap" hidden>
<label for="robot-wechat-verify-code" data-i18n="settings.robots.wechat.verifyCodeLabel">手机显示的数字(仅部分账号需要)</label>
<div class="robot-wechat-verify-row">
<input type="text" id="robot-wechat-verify-code" inputmode="numeric" autocomplete="one-time-code" maxlength="8" placeholder="000000" />
<button type="button" class="btn-primary btn-sm" onclick="submitWechatVerifyCode()" data-i18n="settings.robots.wechat.verifyCodeSubmit">提交</button>
</div>
</div>
</div>
</div>
<details class="settings-collapsible robot-wechat-advanced" id="robot-wechat-advanced">
<summary data-i18n="settings.robots.wechat.advanced">高级设置</summary>
<div class="form-group">
<label for="robot-wechat-base-url" data-i18n="settings.robots.wechat.baseUrl">API Base URL</label>
<input type="text" id="robot-wechat-base-url" placeholder="https://ilinkai.weixin.qq.com" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-wechat-bot-type" data-i18n="settings.robots.wechat.botType">Bot Type</label>
<input type="text" id="robot-wechat-bot-type" placeholder="3" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-wechat-bot-agent" data-i18n="settings.robots.wechat.botAgent">Bot Agent</label>
<input type="text" id="robot-wechat-bot-agent" placeholder="CyberStrikeAI/1.0" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-wechat-ilink-bot-id" data-i18n="settings.robots.wechat.ilinkBotId">iLink Bot ID(绑定后自动填充)</label>
<input type="text" id="robot-wechat-ilink-bot-id" readonly autocomplete="off" />
</div>
</details>
</div>
</div>
<!-- 企业微信 --> <!-- 企业微信 -->
<div class="settings-subsection"> <div class="settings-subsection">
<h4 data-i18n="settings.robots.wecom.title">企业微信</h4> <h4 data-i18n="settings.robots.wecom.title">企业微信</h4>
@@ -3504,12 +3615,13 @@
<script src="/static/js/chat.js"></script> <script src="/static/js/chat.js"></script>
<script src="/static/js/hitl.js"></script> <script src="/static/js/hitl.js"></script>
<script src="/static/js/settings.js"></script> <script src="/static/js/settings.js"></script>
<script src="/static/js/wechat-robot.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script> <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
<script src="/static/js/terminal.js"></script> <script src="/static/js/terminal.js"></script>
<script src="/static/js/knowledge.js"></script> <script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script> <script src="/static/js/skills.js"></script>
<script src="/static/js/vulnerability.js?v=7"></script> <script src="/static/js/vulnerability.js?v=12"></script>
<script src="/static/js/webshell.js"></script> <script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script> <script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script> <script src="/static/js/tasks.js"></script>
File diff suppressed because it is too large Load Diff