mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-18 14:04:52 +02:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a4f3c7d35 | |||
| ead2ce3ecc | |||
| 8733f3a2d2 | |||
| 8642f3ba31 | |||
| 6a262a7367 | |||
| eb9192ddb3 | |||
| 5587e75628 | |||
| 74bbb453e2 | |||
| 66842f6206 | |||
| dc1779275d | |||
| 10dff937b1 | |||
| d4e1fe3bbe | |||
| 179976ae57 | |||
| 1c758bb98c | |||
| 17c4f38ee3 | |||
| cd7e57d121 | |||
| 0f2c3f65cc | |||
| 7779666e27 | |||
| c74bd4403b | |||
| 04d23ddb43 | |||
| 0874e84393 | |||
| 57f57f30b1 | |||
| f37d613a0c | |||
| 87d0ff9154 | |||
| b3418f39b8 | |||
| f9e1ca0e2d | |||
| 2c45879669 | |||
| 1cdcfa2c2d | |||
| eab5b73846 | |||
| d961ba1ec7 | |||
| 1ba5e57ec6 | |||
| 1216d25f96 | |||
| fde693408e | |||
| 352a81a869 | |||
| b2562b1010 | |||
| 0d8ba51087 | |||
| 0b847fcea3 | |||
| bf2f49fe62 | |||
| 75e64b1a86 | |||
| 2167735022 | |||
| 4ee292cc1f | |||
| 961205940f | |||
| ffe797bd06 | |||
| b6c864547e | |||
| da369c2edc | |||
| 54dc31a616 | |||
| 9e0b985221 | |||
| eb47077082 | |||
| f9a482857d | |||
| 679a68b12f | |||
| 840a26c7ef | |||
| 030e69c02d | |||
| d9683cdb44 | |||
| 60a063dd7d | |||
| 5f0c1805a7 | |||
| cb7e66001b | |||
| 4ea838f1d7 | |||
| 573648fc4b | |||
| f0e090abea | |||
| 549dcf518c | |||
| c74e20c54a | |||
| c94a9fd9e9 | |||
| ce9749a8ef | |||
| 145da12017 | |||
| 5111f4c311 | |||
| 8f6384a083 | |||
| 762f778e1e | |||
| 4a11ba8f14 | |||
| 86090af4df | |||
| 2dea6e36bd | |||
| 38ce695708 | |||
| 41fe90faa3 | |||
| 9f54bdb1bf | |||
| 08e727aa41 | |||
| 176c17d630 | |||
| 62710f6619 | |||
| e4dbb96b3e | |||
| 832532213a | |||
| eb04ac0c3a | |||
| 1946508325 | |||
| 89d1c5124f | |||
| 1e7a3299a5 | |||
| cae3a77331 | |||
| 2e1e57ce27 | |||
| 45b6ed2847 | |||
| 88eadf13a4 | |||
| dca5666b18 |
@@ -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
@@ -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` 字段后重启服务。
|
||||||
|
|
||||||
推荐的一键指令:
|
推荐的一键指令:
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-5
@@ -10,11 +10,22 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.8"
|
version: "v1.6.15"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
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 顶层 thinking(extended thinking),mode: off 关闭
|
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
||||||
reasoning:
|
reasoning:
|
||||||
mode: off # auto | on | off;off 时不附加任何推理扩展字段
|
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
||||||
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 # >0:ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
|
deep_model_retry_max_retries: 0 # >0:ChatModel 调用失败时的框架级最大重试次数(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 # true:Debug 附带 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~1,ParentBased+TraceIDRatio
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
database:
|
database:
|
||||||
path: data/conversations.db # SQLite 数据库文件路径,用于存储对话历史和消息
|
path: data/conversations.db # SQLite 数据库文件路径,用于存储对话历史和消息
|
||||||
|
|||||||
@@ -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" 问题
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
+78
-9
@@ -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 应用
|
||||||
@@ -59,7 +63,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 +94,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 +295,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)
|
||||||
@@ -528,18 +533,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 +585,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 +622,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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
|
// inMemory:tls_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: 已启用 TLS(tls_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)
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+133
-3
@@ -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"` // 0–1, 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,7 +391,8 @@ 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 机器人配置(企业微信、钉钉、飞书等)
|
||||||
@@ -323,8 +444,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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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]) + "…"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1249,6 +1249,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" {
|
||||||
|
|||||||
+69
-15
@@ -609,15 +609,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 +695,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配置
|
||||||
@@ -717,7 +755,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 +1156,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 +1224,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 +1329,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 +1476,11 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
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 +1493,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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+38
-1
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -262,7 +267,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 +303,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,
|
||||||
@@ -549,6 +573,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 尾缀后的累计展示
|
||||||
@@ -657,6 +683,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
})
|
})
|
||||||
|
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if !streamsMainAssistant(ev.AgentName) {
|
} else if !streamsMainAssistant(ev.AgentName) {
|
||||||
@@ -702,21 +729,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, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"orchestration": orchMode,
|
||||||
|
})
|
||||||
|
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lastAssistant = s
|
lastAssistant = s
|
||||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
||||||
@@ -933,6 +968,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 使用的 StreamingShell(cloudwego eino-ext local.Local)。
|
// einoStreamingShellWrap 包装 Eino filesystem 使用的 StreamingShell(cloudwego 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 方案为类 Unix;Windows 走原逻辑
|
// PTY 方案为类 Unix;Windows 走原逻辑
|
||||||
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:
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 "$@"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+1701
-130
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
@@ -1304,6 +1306,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 +1515,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",
|
||||||
|
|||||||
@@ -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": "最近漏洞",
|
||||||
@@ -1293,6 +1295,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 +1504,11 @@
|
|||||||
},
|
},
|
||||||
"vulnerabilityPage": {
|
"vulnerabilityPage": {
|
||||||
"statTotal": "总漏洞数",
|
"statTotal": "总漏洞数",
|
||||||
|
"statClickAll": "查看全部(清除严重度筛选)",
|
||||||
|
"statClickFilter": "点击按此严重度筛选;再次点击清除",
|
||||||
|
"advancedFilters": "高级筛选",
|
||||||
|
"activeFilters": "已选条件",
|
||||||
|
"chipRemove": "移除",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"vulnId": "漏洞ID",
|
"vulnId": "漏洞ID",
|
||||||
|
|||||||
+13
-8
@@ -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
@@ -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 下拉选项 HTML(value / 文本已转义) */
|
/** 监听器表单:Malleable Profile 下拉选项 HTML(value / 文本已转义) */
|
||||||
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'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
+67
-28
@@ -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,7 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
}
|
}
|
||||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||||
detailsContainer.dataset.loaded = '1';
|
detailsContainer.dataset.loaded = '1';
|
||||||
|
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||||
// 如果没有processDetails或为空,显示空状态
|
// 如果没有processDetails或为空,显示空状态
|
||||||
if (!processDetails || processDetails.length === 0) {
|
if (!processDetails || processDetails.length === 0) {
|
||||||
// 显示空状态提示
|
// 显示空状态提示
|
||||||
@@ -7377,8 +7416,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
@@ -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 半环(背景轨迹)路径
|
||||||
|
|||||||
+688
-49
@@ -273,6 +273,116 @@ function escapeHtmlLocal(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** fenced 块占位(BMP 私用区,正文几乎不会出现) */
|
||||||
|
const _MD_FENCE_PRE = '\n\uE000CSAI_FENCE_';
|
||||||
|
const _MD_FENCE_SUF = '_\uE000\n';
|
||||||
|
|
||||||
|
function _maskFencedCodeBlocksForMdPreprocess(md) {
|
||||||
|
const blocks = [];
|
||||||
|
const masked = String(md).replace(/```[\s\S]*?```/g, (m) => {
|
||||||
|
const i = blocks.length;
|
||||||
|
blocks.push(m);
|
||||||
|
return _MD_FENCE_PRE + i + _MD_FENCE_SUF;
|
||||||
|
});
|
||||||
|
return { masked, blocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _unmaskFencedCodeBlocksAfterMdPreprocess(s, blocks) {
|
||||||
|
let out = s;
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
out = out.split(_MD_FENCE_PRE + i + _MD_FENCE_SUF).join(blocks[i]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型/网关偶发把「思考」混进正文,用伪 XML 包裹(如 <redacted_thinking>…</redacted_thinking>)。
|
||||||
|
* 与 Markdown 列表混排时,结束标签常被吞进 <li>,其后 **、` 等行内语法全部无法解析;成对块整段移除。
|
||||||
|
* @param {string} segment
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function _stripXmlReasoningWrappersForMarkdown(segment) {
|
||||||
|
let t = String(segment);
|
||||||
|
const tags = ['redacted_thinking', 'redacted_reasoning'];
|
||||||
|
for (let i = 0; i < tags.length; i++) {
|
||||||
|
const name = tags[i];
|
||||||
|
const re = new RegExp('<\\s*' + name + '\\b[^>]*>[\\s\\S]*?<\\s*/\\s*' + name + '\\s*>', 'gi');
|
||||||
|
t = t.replace(re, '\n\n');
|
||||||
|
}
|
||||||
|
return t.replace(/\n{3,}/g, '\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解除 LLM 常用的块级 HTML 外壳(`<div>`、`<p>`、`<section>`、`<article>`、`<main>`)。
|
||||||
|
* 整段包在块级标签里时,CommonMark 不会在块内再解析 Markdown,导致 **、` 原样显示。
|
||||||
|
*/
|
||||||
|
function _unwrapHtmlBlockWrappersForMarkdown(segment) {
|
||||||
|
let s = segment;
|
||||||
|
let prev;
|
||||||
|
for (let i = 0; i < 30 && s !== prev; i++) {
|
||||||
|
prev = s;
|
||||||
|
s = s.replace(/<div(?:\s[^>]*)?>([\s\S]*?)<\/div>/gi, (_, inner) => String(inner).trim() + '\n\n');
|
||||||
|
s = s.replace(/<p(?:\s[^>]*)?>([\s\S]*?)<\/p>/gi, (_, inner) => String(inner).trim() + '\n\n');
|
||||||
|
s = s.replace(/<section(?:\s[^>]*)?>([\s\S]*?)<\/section>/gi, (_, inner) => String(inner).trim() + '\n\n');
|
||||||
|
s = s.replace(/<article(?:\s[^>]*)?>([\s\S]*?)<\/article>/gi, (_, inner) => String(inner).trim() + '\n\n');
|
||||||
|
s = s.replace(/<main(?:\s[^>]*)?>([\s\S]*?)<\/main>/gi, (_, inner) => String(inner).trim() + '\n\n');
|
||||||
|
s = s.replace(/\n{3,}/g, '\n\n');
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 HTML 列表 / 粘连的 `<li>` 还原为 Markdown 列表行,并去掉外层 `<ul>`,便于 marked 解析行内 **、` `
|
||||||
|
* @param {string} segment
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function _flattenOrphanHtmlLiInMarkdown(segment) {
|
||||||
|
let s = segment;
|
||||||
|
s = s.replace(/<li(?:\s[^>]*)?>([\s\S]*?)<\/li>/gi, (_, inner) => {
|
||||||
|
const body = String(inner).trim().replace(/\s*\n\s*/g, ' ');
|
||||||
|
return '- ' + body + '\n';
|
||||||
|
});
|
||||||
|
s = s.replace(/<\/?ul(?:\s[^>]*)?>/gi, '\n');
|
||||||
|
s = s.replace(/<\/?ol(?:\s[^>]*)?>/gi, '\n');
|
||||||
|
s = s.replace(/([0-9A-Za-z_\u4e00-\u9fff])\s*<li(?:\s[^>]*)?>\s*/g, (_, ch) => ch + '\n- ');
|
||||||
|
return s.replace(/\n{3,}/g, '\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行首 Unicode 项目符号 → Markdown 列表 `- `(模型常用 • 而非 `-`) */
|
||||||
|
function _normalizeUnicodeBulletMarkersToMdDash(segment) {
|
||||||
|
return segment
|
||||||
|
.replace(/^\s*\u2022\s+/gm, '- ')
|
||||||
|
.replace(/^\s*\u00b7\s+/gm, '- ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析前归一化助手 Markdown:去掉零宽字符,NFKC 将全角 * ` _ 等转为 ASCII,
|
||||||
|
* 避免 marked 无法识别强调/行内代码而原样显示 **、反引号;
|
||||||
|
* 并移除 <redacted_thinking> 等伪 XML 思考块、修正块级 HTML(`<div>`/`<p>`/…、`<ul>`/`<li>`)与 Unicode 项目符号 `•`,避免块级 HTML 吞掉 inline 解析。
|
||||||
|
* @param {string|null|undefined} text
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function normalizeAssistantMarkdownSource(text) {
|
||||||
|
if (text == null) return '';
|
||||||
|
let s = String(text);
|
||||||
|
s = s.replace(/[\u200B-\u200D\u200E\u200F\uFEFF\u2060]/g, '');
|
||||||
|
try {
|
||||||
|
s = s.normalize('NFKC');
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
s = _stripXmlReasoningWrappersForMarkdown(s);
|
||||||
|
const fb = _maskFencedCodeBlocksForMdPreprocess(s);
|
||||||
|
s = _unwrapHtmlBlockWrappersForMarkdown(fb.masked);
|
||||||
|
s = _flattenOrphanHtmlLiInMarkdown(s);
|
||||||
|
s = _normalizeUnicodeBulletMarkersToMdDash(s);
|
||||||
|
s = _unmaskFencedCodeBlocksAfterMdPreprocess(s, fb.blocks);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.normalizeAssistantMarkdownSource = normalizeAssistantMarkdownSource;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 与 internal/openai.normalizeStreamingDelta 一致:兼容网关/模型返回「累计全文」或整包重发,
|
* 与 internal/openai.normalizeStreamingDelta 一致:兼容网关/模型返回「累计全文」或整包重发,
|
||||||
* 避免前端 buffer += chunk 与后端已归一化的增量叠加导致逐段重复(如「响应中显示了响应中显示了」)。
|
* 避免前端 buffer += chunk 与后端已归一化的增量叠加导致逐段重复(如「响应中显示了响应中显示了」)。
|
||||||
@@ -316,10 +426,11 @@ function setTimelineItemContentStreamRich(contentEl, html) {
|
|||||||
|
|
||||||
function formatAssistantMarkdownContent(text) {
|
function formatAssistantMarkdownContent(text) {
|
||||||
const raw = text == null ? '' : String(text);
|
const raw = text == null ? '' : String(text);
|
||||||
|
const src = normalizeAssistantMarkdownSource(raw);
|
||||||
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(raw);
|
const parsed = marked.parse(src, { async: false });
|
||||||
if (typeof DOMPurify !== 'undefined') {
|
if (typeof DOMPurify !== 'undefined') {
|
||||||
return DOMPurify.sanitize(parsed, assistantMarkdownSanitizeConfig);
|
return DOMPurify.sanitize(parsed, assistantMarkdownSanitizeConfig);
|
||||||
}
|
}
|
||||||
@@ -661,7 +772,7 @@ function toggleProgressDetails(progressId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编排器开始输出最终回复时隐藏整条进度消息(迭代阶段保持展开可见;此处整行收起而非仅折叠时间线)
|
// 编排器开始输出最终回复时隐藏整条进度消息(过程已迁入助手气泡的「展开详情」,避免与进度卡重复)
|
||||||
function hideProgressMessageForFinalReply(progressId) {
|
function hideProgressMessageForFinalReply(progressId) {
|
||||||
if (!progressId) return;
|
if (!progressId) return;
|
||||||
const el = document.getElementById(progressId);
|
const el = document.getElementById(progressId);
|
||||||
@@ -859,7 +970,7 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除原来的进度消息
|
// 移除原来的进度消息(详情已快照到助手消息下的 process-details)
|
||||||
removeMessage(progressId);
|
removeMessage(progressId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1222,6 +1333,32 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'eino_trace_run':
|
||||||
|
case 'eino_trace_start':
|
||||||
|
case 'eino_trace_end':
|
||||||
|
case 'eino_trace_error': {
|
||||||
|
const d = event.data || {};
|
||||||
|
const comp = d.component != null ? String(d.component) : '';
|
||||||
|
const name = d.name != null ? String(d.name) : '';
|
||||||
|
let glyph = '◆';
|
||||||
|
if (event.type === 'eino_trace_run') glyph = '●';
|
||||||
|
else if (event.type === 'eino_trace_start') glyph = '▶';
|
||||||
|
else if (event.type === 'eino_trace_end') glyph = '■';
|
||||||
|
else if (event.type === 'eino_trace_error') glyph = '✖';
|
||||||
|
const title = '[Eino] ' + glyph + ' ' + (comp || 'component') + (name ? '/' + name : '');
|
||||||
|
const parts = [];
|
||||||
|
if (d.runId) parts.push('run=' + String(d.runId));
|
||||||
|
if (d.spanId) parts.push('span=' + String(d.spanId));
|
||||||
|
if (d.parentSpanId) parts.push('parent=' + String(d.parentSpanId));
|
||||||
|
if (d.inputSummary) parts.push(String(d.inputSummary));
|
||||||
|
if (d.outputSummary) parts.push(String(d.outputSummary));
|
||||||
|
if (d.error) parts.push(String(d.error));
|
||||||
|
if (event.message && String(event.message).trim()) parts.push(String(event.message));
|
||||||
|
const body = parts.join(' · ');
|
||||||
|
addTimelineItem(timeline, 'progress', { title, message: body, data: d });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'thinking_stream_start':
|
case 'thinking_stream_start':
|
||||||
case 'reasoning_chain_stream_start': {
|
case 'reasoning_chain_stream_start': {
|
||||||
@@ -1748,6 +1885,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
|
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
|
||||||
|
// 同一 progressId 再次 response_start 时先移除旧占位,避免多条「助手输出」卡片且仅最后一条收 delta
|
||||||
|
// 改为保留旧占位,让每一段 response_start 都能在时间线中完整展示。
|
||||||
// 创建时间线条目用于显示迭代过程中的输出
|
// 创建时间线条目用于显示迭代过程中的输出
|
||||||
const title = einoMainStreamPlanningTitle(responseData);
|
const title = einoMainStreamPlanningTitle(responseData);
|
||||||
const itemId = addTimelineItem(timeline, 'thinking', {
|
const itemId = addTimelineItem(timeline, 'thinking', {
|
||||||
@@ -2544,6 +2683,8 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
${escapeHtml(options.message || taskCancelledLabel)}
|
${escapeHtml(options.message || taskCancelledLabel)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
} else if (type === 'progress' && options.message) {
|
||||||
|
content += `<div class="timeline-item-content timeline-eino-trace"><pre class="tool-result">${escapeHtml(options.message)}</pre></div>`;
|
||||||
} else if (type === 'user_interrupt_continue' && options.message) {
|
} else if (type === 'user_interrupt_continue' && options.message) {
|
||||||
const streamBody = typeof formatTimelineStreamBody === 'function'
|
const streamBody = typeof formatTimelineStreamBody === 'function'
|
||||||
? formatTimelineStreamBody(options.message, options.data)
|
? formatTimelineStreamBody(options.message, options.data)
|
||||||
@@ -2841,6 +2982,9 @@ async function applyMonitorFilters() {
|
|||||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
const status = statusFilter ? statusFilter.value : 'all';
|
const status = statusFilter ? statusFilter.value : 'all';
|
||||||
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
||||||
|
if (toolFilter) {
|
||||||
|
toolFilter.classList.toggle('is-filter-active', tool !== 'all');
|
||||||
|
}
|
||||||
// 当筛选条件改变时,从后端重新获取数据
|
// 当筛选条件改变时,从后端重新获取数据
|
||||||
await refreshMonitorPanelWithFilter(status, tool);
|
await refreshMonitorPanelWithFilter(status, tool);
|
||||||
}
|
}
|
||||||
@@ -2904,20 +3048,410 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const MCP_STATS_TOP_N = 6;
|
||||||
|
|
||||||
|
function mcpMonitorT(key, params) {
|
||||||
|
if (typeof window.t !== 'function') return '';
|
||||||
|
return window.t('mcpMonitor.' + key, {
|
||||||
|
...(params || {}),
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonitorStatsEntries(statsMap) {
|
||||||
|
if (!statsMap || typeof statsMap !== 'object') return [];
|
||||||
|
return Object.entries(statsMap).map(([key, item]) => {
|
||||||
|
const stat = item && typeof item === 'object' ? { ...item } : {};
|
||||||
|
if (!stat.toolName) stat.toolName = key;
|
||||||
|
return stat;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCP_STATS_TOOL_CHEVRON = '<svg class="mcp-stats-tool-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||||
|
|
||||||
|
function getMcpStatsRateTone(rateNum) {
|
||||||
|
if (rateNum >= 95) return 'is-success';
|
||||||
|
if (rateNum >= 80) return 'is-warning';
|
||||||
|
return 'is-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMcpStatsRingStrokeClass(rateNum) {
|
||||||
|
if (rateNum >= 95) return '';
|
||||||
|
if (rateNum >= 80) return 'is-warning';
|
||||||
|
return 'is-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMcpStatsSuccessRing(percent) {
|
||||||
|
const p = Math.min(100, Math.max(0, parseFloat(percent) || 0));
|
||||||
|
const r = 15.9155;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
const offset = circumference - (p / 100) * circumference;
|
||||||
|
const strokeClass = getMcpStatsRingStrokeClass(p);
|
||||||
|
return `<div class="mcp-stats-ring-wrap" aria-hidden="true">
|
||||||
|
<svg class="mcp-stats-ring-svg" viewBox="0 0 36 36">
|
||||||
|
<circle class="mcp-stats-ring-track" cx="18" cy="18" r="${r}" fill="none" stroke-width="3"/>
|
||||||
|
<circle class="mcp-stats-ring-fill ${strokeClass}" cx="18" cy="18" r="${r}" fill="none" stroke-width="3"
|
||||||
|
stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"/>
|
||||||
|
</svg>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMcpStatsToolVolumeBar(total, success, failed, maxTotal) {
|
||||||
|
const volumePct = maxTotal > 0 && total > 0 ? (total / maxTotal) * 100 : 0;
|
||||||
|
const successPct = total > 0 ? (success / total) * 100 : 0;
|
||||||
|
const failPct = total > 0 ? (failed / total) * 100 : 0;
|
||||||
|
const legend = mcpMonitorT('barVolumeLegend') || '条长表示相对调用量';
|
||||||
|
const volumeTitle = `${total} / ${maxTotal}`;
|
||||||
|
return `<div class="mcp-stats-tool-bar-track" title="${escapeHtml(legend)} · ${escapeHtml(volumeTitle)}">
|
||||||
|
<div class="mcp-stats-tool-bar-fill" style="width:${volumePct.toFixed(2)}%">
|
||||||
|
<div class="mcp-stats-tool-bar-inner">
|
||||||
|
<span class="mcp-stats-tool-bar-seg mcp-stats-tool-bar-seg--success" style="width:${successPct.toFixed(2)}%"></span>
|
||||||
|
<span class="mcp-stats-tool-bar-seg mcp-stats-tool-bar-seg--fail" style="width:${failPct.toFixed(2)}%"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMcpToolRateClass(rateNum) {
|
||||||
|
if (rateNum >= 95) return 'is-success';
|
||||||
|
if (rateNum >= 80) return 'is-warning';
|
||||||
|
return 'is-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCP_STATS_DIST_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#14b8a6', '#ec4899'];
|
||||||
|
|
||||||
|
function mcpStatsDescribeDonutSegment(startPct, endPct, outerR, innerR) {
|
||||||
|
if (endPct <= startPct) return '';
|
||||||
|
const span = endPct - startPct;
|
||||||
|
const cx = 50;
|
||||||
|
const cy = 50;
|
||||||
|
const point = (pct, r) => {
|
||||||
|
const rad = ((pct / 100) * 360 - 90) * Math.PI / 180;
|
||||||
|
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
|
||||||
|
};
|
||||||
|
if (span >= 99.995) {
|
||||||
|
const [x1, y1] = point(0, outerR);
|
||||||
|
const [x2, y2] = point(50, outerR);
|
||||||
|
const [x3, y3] = point(50, innerR);
|
||||||
|
const [x4, y4] = point(0, innerR);
|
||||||
|
const [x5, y5] = point(50, outerR);
|
||||||
|
const [x6, y6] = point(100, outerR);
|
||||||
|
const [x7, y7] = point(100, innerR);
|
||||||
|
const [x8, y8] = point(50, innerR);
|
||||||
|
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${outerR} ${outerR} 0 0 1 ${x2.toFixed(3)} ${y2.toFixed(3)} A ${outerR} ${outerR} 0 0 1 ${x6.toFixed(3)} ${y6.toFixed(3)} L ${x7.toFixed(3)} ${y7.toFixed(3)} A ${innerR} ${innerR} 0 0 0 ${x8.toFixed(3)} ${y8.toFixed(3)} A ${innerR} ${innerR} 0 0 0 ${x4.toFixed(3)} ${y4.toFixed(3)} Z`;
|
||||||
|
}
|
||||||
|
const large = span > 50 ? 1 : 0;
|
||||||
|
const [x1, y1] = point(startPct, outerR);
|
||||||
|
const [x2, y2] = point(endPct, outerR);
|
||||||
|
const [x3, y3] = point(endPct, innerR);
|
||||||
|
const [x4, y4] = point(startPct, innerR);
|
||||||
|
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${outerR} ${outerR} 0 ${large} 1 ${x2.toFixed(3)} ${y2.toFixed(3)} L ${x3.toFixed(3)} ${y3.toFixed(3)} A ${innerR} ${innerR} 0 ${large} 0 ${x4.toFixed(3)} ${y4.toFixed(3)} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMcpStatsDistCenter(panel) {
|
||||||
|
if (!panel) return;
|
||||||
|
const label = panel.querySelector('.mcp-stats-dist-donut-label');
|
||||||
|
const value = panel.querySelector('.mcp-stats-dist-donut-value');
|
||||||
|
const unit = panel.querySelector('.mcp-stats-dist-donut-unit');
|
||||||
|
if (!label || !value) return;
|
||||||
|
label.textContent = panel.getAttribute('data-center-label') || '';
|
||||||
|
label.classList.add('is-default');
|
||||||
|
const centerVal = panel.getAttribute('data-center-value') || '';
|
||||||
|
const numEl = panel.querySelector('.mcp-stats-dist-donut-value-num');
|
||||||
|
if (numEl) numEl.textContent = centerVal;
|
||||||
|
else value.textContent = centerVal;
|
||||||
|
if (unit) {
|
||||||
|
unit.textContent = panel.getAttribute('data-center-suffix') || '%';
|
||||||
|
unit.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewMcpStatsDistCenter(panel, toolName, pct) {
|
||||||
|
if (!panel) return;
|
||||||
|
const label = panel.querySelector('.mcp-stats-dist-donut-label');
|
||||||
|
const value = panel.querySelector('.mcp-stats-dist-donut-value');
|
||||||
|
const unit = panel.querySelector('.mcp-stats-dist-donut-unit');
|
||||||
|
if (!label || !value) return;
|
||||||
|
const shortName = toolName.length > 14 ? `${toolName.slice(0, 13)}…` : toolName;
|
||||||
|
label.textContent = shortName;
|
||||||
|
label.classList.remove('is-default');
|
||||||
|
const numEl = panel.querySelector('.mcp-stats-dist-donut-value-num');
|
||||||
|
if (numEl) numEl.textContent = pct;
|
||||||
|
else value.textContent = pct;
|
||||||
|
if (unit) unit.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMcpStatsDistHover(toolName) {
|
||||||
|
const panel = document.querySelector('.mcp-stats-dist-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(toolName) : toolName.replace(/"/g, '\\"');
|
||||||
|
panel.querySelectorAll('.mcp-stats-dist-segment, .mcp-stats-dist-legend-item').forEach((el) => {
|
||||||
|
const t = el.getAttribute('data-tool-name') || '';
|
||||||
|
const match = toolName && t === toolName;
|
||||||
|
el.classList.toggle('is-highlighted', !!match);
|
||||||
|
el.classList.toggle('is-dimmed', !!toolName && !match && t);
|
||||||
|
});
|
||||||
|
if (toolName) {
|
||||||
|
const el = panel.querySelector(`[data-tool-name="${esc}"]`);
|
||||||
|
if (el) {
|
||||||
|
previewMcpStatsDistCenter(panel, toolName, el.getAttribute('data-pct') || '');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetMcpStatsDistCenter(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMonitorStatsToolFilter(toolName) {
|
||||||
|
if (!toolName) return;
|
||||||
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
|
if (toolFilter && toolFilter.value === toolName) {
|
||||||
|
clearMonitorToolFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filterMonitorByTool(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMcpStatsInsightPanel(topTools, totals, activeToolFilter = '') {
|
||||||
|
const distTitle = mcpMonitorT('distTitle') || '调用分布';
|
||||||
|
const distLegend = mcpMonitorT('distLegend') || '扇区面积为占全部调用比例';
|
||||||
|
const distClickHint = mcpMonitorT('distClickHint') || '点击图例或扇区筛选执行记录';
|
||||||
|
const distOthersTitle = mcpMonitorT('distOthersNoFilter') || '其他工具无法单独筛选';
|
||||||
|
const top6ShareLabel = mcpMonitorT('distTop6Share', { n: MCP_STATS_TOP_N }) || `Top ${MCP_STATS_TOP_N} 占全部调用`;
|
||||||
|
const othersLabel = mcpMonitorT('distOthers') || '其他工具';
|
||||||
|
const callsUnit = (n) => mcpMonitorT('distCallsUnit', { n }) || `${n} 次`;
|
||||||
|
|
||||||
|
const top6Total = topTools.reduce((s, t) => s + (t.totalCalls || 0), 0);
|
||||||
|
const top6SharePct = totals.total > 0 ? ((top6Total / totals.total) * 100).toFixed(1) : '0.0';
|
||||||
|
const otherCalls = Math.max(0, totals.total - top6Total);
|
||||||
|
|
||||||
|
let acc = 0;
|
||||||
|
const segments = [];
|
||||||
|
topTools.forEach((tool, i) => {
|
||||||
|
const calls = tool.totalCalls || 0;
|
||||||
|
if (calls <= 0 || totals.total <= 0) return;
|
||||||
|
const pct = (calls / totals.total) * 100;
|
||||||
|
segments.push({
|
||||||
|
color: MCP_STATS_DIST_COLORS[i % MCP_STATS_DIST_COLORS.length],
|
||||||
|
start: acc,
|
||||||
|
end: acc + pct,
|
||||||
|
name: tool.toolName || '',
|
||||||
|
calls,
|
||||||
|
pct: pct.toFixed(1),
|
||||||
|
isOthers: false,
|
||||||
|
});
|
||||||
|
acc += pct;
|
||||||
|
});
|
||||||
|
if (otherCalls > 0 && totals.total > 0) {
|
||||||
|
const pct = (otherCalls / totals.total) * 100;
|
||||||
|
segments.push({
|
||||||
|
color: '#cbd5e1',
|
||||||
|
start: acc,
|
||||||
|
end: acc + pct,
|
||||||
|
name: othersLabel,
|
||||||
|
calls: otherCalls,
|
||||||
|
pct: pct.toFixed(1),
|
||||||
|
isOthers: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentPathsHtml = segments.map((s) => {
|
||||||
|
const pathD = mcpStatsDescribeDonutSegment(s.start, s.end, 48, 30);
|
||||||
|
if (!pathD) return '';
|
||||||
|
const isActive = !s.isOthers && activeToolFilter && activeToolFilter === s.name;
|
||||||
|
const segAria = s.isOthers
|
||||||
|
? escapeHtml(s.name)
|
||||||
|
: escapeHtml(mcpMonitorT('distSegmentAria', { name: s.name, pct: s.pct, calls: s.calls })
|
||||||
|
|| `${s.name},占 ${s.pct}%,${s.calls} 次`);
|
||||||
|
return `<path class="mcp-stats-dist-segment${isActive ? ' is-active' : ''}${s.isOthers ? ' is-others' : ''}"
|
||||||
|
d="${pathD}"
|
||||||
|
fill="${s.color}"
|
||||||
|
data-tool-name="${s.isOthers ? '' : escapeHtml(s.name)}"
|
||||||
|
data-pct="${s.pct}"
|
||||||
|
data-calls="${s.calls}"
|
||||||
|
data-is-others="${s.isOthers ? '1' : '0'}"
|
||||||
|
tabindex="${s.isOthers ? '-1' : '0'}"
|
||||||
|
role="${s.isOthers ? 'presentation' : 'button'}"
|
||||||
|
aria-label="${segAria}" />`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const legendHtml = segments.map((s) => {
|
||||||
|
const isActive = !s.isOthers && activeToolFilter && activeToolFilter === s.name;
|
||||||
|
const inner = `
|
||||||
|
<span class="mcp-stats-dist-swatch" style="--swatch-color:${s.color}"></span>
|
||||||
|
<span class="mcp-stats-dist-legend-name" title="${escapeHtml(s.name)}">${escapeHtml(s.name)}</span>
|
||||||
|
<span class="mcp-stats-dist-legend-meta"><em>${s.pct}%</em><span>${escapeHtml(callsUnit(s.calls))}</span></span>`;
|
||||||
|
if (s.isOthers) {
|
||||||
|
return `<li class="mcp-stats-dist-legend-item is-others" title="${escapeHtml(distOthersTitle)}" data-is-others="1">${inner}</li>`;
|
||||||
|
}
|
||||||
|
const rowAria = mcpMonitorT('toolRowAriaLabel', { name: s.name, total: s.calls, rate: s.pct })
|
||||||
|
|| `${s.name},${s.calls} 次调用,占 ${s.pct}%`;
|
||||||
|
return `<li class="mcp-stats-dist-legend-item-wrap">
|
||||||
|
<button type="button" class="mcp-stats-dist-legend-item${isActive ? ' is-active' : ''}"
|
||||||
|
data-tool-name="${escapeHtml(s.name)}"
|
||||||
|
data-pct="${s.pct}"
|
||||||
|
data-calls="${s.calls}"
|
||||||
|
data-is-others="0"
|
||||||
|
aria-label="${escapeHtml(rowAria)}"
|
||||||
|
aria-pressed="${isActive ? 'true' : 'false'}">${inner}</button>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const centerLabel = `Top ${MCP_STATS_TOP_N}`;
|
||||||
|
const distHint = totals.total > 0
|
||||||
|
? (mcpMonitorT('distTotalCalls', { n: totals.total }) || `共 ${totals.total} 次调用`)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mcp-stats-tools-panel mcp-stats-dist-panel" aria-label="${escapeHtml(distTitle)}"
|
||||||
|
data-center-label="${escapeHtml(centerLabel)}"
|
||||||
|
data-center-value="${top6SharePct}"
|
||||||
|
data-center-suffix="%">
|
||||||
|
<div class="mcp-stats-tools-header">
|
||||||
|
<div class="mcp-stats-tools-heading">
|
||||||
|
<h4 class="mcp-stats-tools-title">${escapeHtml(distTitle)}</h4>
|
||||||
|
<span class="mcp-stats-tools-legend">${escapeHtml(distLegend)} · ${escapeHtml(distClickHint)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="mcp-stats-tools-hint">${escapeHtml(distHint)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-dist-body mcp-stats-dist-body--stacked">
|
||||||
|
<div class="mcp-stats-dist-chart-stage">
|
||||||
|
<div class="mcp-stats-dist-chart-wrap">
|
||||||
|
<svg class="mcp-stats-dist-svg" viewBox="0 0 100 100" role="img" aria-label="${escapeHtml(top6ShareLabel)} ${top6SharePct}%">
|
||||||
|
<g class="mcp-stats-dist-segments">${segmentPathsHtml}</g>
|
||||||
|
</svg>
|
||||||
|
<div class="mcp-stats-dist-donut-hole" aria-hidden="true">
|
||||||
|
<span class="mcp-stats-dist-donut-label is-default">${centerLabel}</span>
|
||||||
|
<span class="mcp-stats-dist-donut-value"><span class="mcp-stats-dist-donut-value-num">${top6SharePct}</span><span class="mcp-stats-dist-donut-unit">%</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="mcp-stats-dist-legend mcp-stats-dist-legend--grid">${legendHtml}</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderMcpStatsStackedBar(success, failed) {
|
||||||
|
const total = success + failed;
|
||||||
|
if (total <= 0) {
|
||||||
|
return '<div class="mcp-stats-stacked-bar" role="presentation"><div class="mcp-stats-stacked-bar-seg mcp-stats-stacked-bar-seg--success" style="flex:1"></div></div>';
|
||||||
|
}
|
||||||
|
const successFlex = Math.max(0, (success / total) * 100);
|
||||||
|
const failFlex = Math.max(0, (failed / total) * 100);
|
||||||
|
return `<div class="mcp-stats-stacked-bar" role="presentation">
|
||||||
|
<div class="mcp-stats-stacked-bar-seg mcp-stats-stacked-bar-seg--success" style="flex:${successFlex}"></div>
|
||||||
|
<div class="mcp-stats-stacked-bar-seg mcp-stats-stacked-bar-seg--fail" style="flex:${failFlex}"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMonitorStatsSubtitle(lastFetchedAt, toolCount) {
|
||||||
|
const subtitle = document.getElementById('monitor-stats-subtitle');
|
||||||
|
if (!subtitle) return;
|
||||||
|
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||||
|
const timeText = lastFetchedAt
|
||||||
|
? (lastFetchedAt.toLocaleString ? lastFetchedAt.toLocaleString(locale) : String(lastFetchedAt))
|
||||||
|
: '—';
|
||||||
|
const text = mcpMonitorT('statsSubtitle', { time: timeText, count: toolCount })
|
||||||
|
|| `最后刷新 ${timeText} · 共 ${toolCount} 个工具`;
|
||||||
|
subtitle.textContent = text;
|
||||||
|
subtitle.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMonitorByTool(toolName) {
|
||||||
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
|
if (!toolFilter || !toolName) return;
|
||||||
|
toolFilter.value = toolName;
|
||||||
|
toolFilter.classList.add('is-filter-active');
|
||||||
|
applyMonitorFilters();
|
||||||
|
const execSection = document.querySelector('.monitor-executions');
|
||||||
|
if (execSection && typeof execSection.scrollIntoView === 'function') {
|
||||||
|
execSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMonitorToolFilter() {
|
||||||
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
|
if (!toolFilter) return;
|
||||||
|
toolFilter.value = '';
|
||||||
|
toolFilter.classList.remove('is-filter-active');
|
||||||
|
applyMonitorFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
let monitorStatsPanelEventsBound = false;
|
||||||
|
|
||||||
|
function bindMonitorStatsPanelEvents() {
|
||||||
|
if (monitorStatsPanelEventsBound) return;
|
||||||
|
const root = document.getElementById('monitor-stats');
|
||||||
|
if (!root) return;
|
||||||
|
root.addEventListener('click', function (e) {
|
||||||
|
const clearBtn = e.target.closest('.mcp-stats-clear-filter');
|
||||||
|
if (clearBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearMonitorToolFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const distEl = e.target.closest('.mcp-stats-dist-segment[data-tool-name], .mcp-stats-dist-legend-item[data-tool-name]');
|
||||||
|
if (distEl && distEl.getAttribute('data-is-others') !== '1') {
|
||||||
|
const tool = distEl.getAttribute('data-tool-name');
|
||||||
|
if (tool) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMonitorStatsToolFilter(tool);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = e.target.closest('.mcp-stats-tool-row');
|
||||||
|
if (!row) return;
|
||||||
|
const tool = row.getAttribute('data-tool-name');
|
||||||
|
if (tool) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMonitorStatsToolFilter(tool);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
|
const distSeg = e.target.closest('.mcp-stats-dist-segment[data-tool-name]');
|
||||||
|
if (!distSeg || distSeg.getAttribute('data-is-others') === '1') return;
|
||||||
|
const tool = distSeg.getAttribute('data-tool-name');
|
||||||
|
if (tool) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMonitorStatsToolFilter(tool);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.addEventListener('mouseover', function (e) {
|
||||||
|
const el = e.target.closest('.mcp-stats-dist-segment[data-tool-name], .mcp-stats-dist-legend-item[data-tool-name]');
|
||||||
|
if (!el || el.getAttribute('data-is-others') === '1') return;
|
||||||
|
const tool = el.getAttribute('data-tool-name');
|
||||||
|
if (tool) setMcpStatsDistHover(tool);
|
||||||
|
});
|
||||||
|
root.addEventListener('mouseout', function (e) {
|
||||||
|
const el = e.target.closest('.mcp-stats-dist-segment[data-tool-name], .mcp-stats-dist-legend-item[data-tool-name]');
|
||||||
|
if (!el) return;
|
||||||
|
const related = e.relatedTarget;
|
||||||
|
const next = related && related.closest
|
||||||
|
? related.closest('.mcp-stats-dist-segment[data-tool-name], .mcp-stats-dist-legend-item[data-tool-name]')
|
||||||
|
: null;
|
||||||
|
if (next) return;
|
||||||
|
setMcpStatsDistHover('');
|
||||||
|
});
|
||||||
|
monitorStatsPanelEventsBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||||
const container = document.getElementById('monitor-stats');
|
const container = document.getElementById('monitor-stats');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = Object.values(statsMap);
|
const entries = normalizeMonitorStatsEntries(statsMap);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
const noStats = typeof window.t === 'function' ? window.t('mcpMonitor.noStatsData') : '暂无统计数据';
|
const noStats = mcpMonitorT('noStatsData') || '暂无统计数据';
|
||||||
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
|
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
|
||||||
|
const subtitle = document.getElementById('monitor-stats-subtitle');
|
||||||
|
if (subtitle) subtitle.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算总体汇总
|
|
||||||
const totals = entries.reduce(
|
const totals = entries.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.total += item.totalCalls || 0;
|
acc.total += item.totalCalls || 0;
|
||||||
@@ -2932,59 +3466,154 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
|||||||
{ total: 0, success: 0, failed: 0, lastCallTime: null }
|
{ total: 0, success: 0, failed: 0, lastCallTime: null }
|
||||||
);
|
);
|
||||||
|
|
||||||
const successRate = totals.total > 0 ? ((totals.success / totals.total) * 100).toFixed(1) : '0.0';
|
const successRateNum = totals.total > 0 ? (totals.success / totals.total) * 100 : 0;
|
||||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
|
const successRate = successRateNum.toFixed(1);
|
||||||
const lastUpdatedText = lastFetchedAt ? (lastFetchedAt.toLocaleString ? lastFetchedAt.toLocaleString(locale || 'en-US') : String(lastFetchedAt)) : 'N/A';
|
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||||
const noCallsYet = typeof window.t === 'function' ? window.t('mcpMonitor.noCallsYet') : '暂无调用';
|
const noCallsYet = mcpMonitorT('noCallsYet') || '暂无调用';
|
||||||
const lastCallText = totals.lastCallTime ? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale || 'en-US') : String(totals.lastCallTime)) : noCallsYet;
|
const lastCallText = totals.lastCallTime
|
||||||
const totalCallsLabel = typeof window.t === 'function' ? window.t('mcpMonitor.totalCalls') : '总调用次数';
|
? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale) : String(totals.lastCallTime))
|
||||||
const successFailedLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successFailed', { success: totals.success, failed: totals.failed }) : `成功 ${totals.success} / 失败 ${totals.failed}`;
|
: noCallsYet;
|
||||||
const successRateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successRate') : '成功率';
|
|
||||||
const statsFromAll = typeof window.t === 'function' ? window.t('mcpMonitor.statsFromAllTools') : '统计自全部工具调用';
|
|
||||||
const lastCallLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastCall') : '最近一次调用';
|
|
||||||
const lastRefreshLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastRefreshTime') : '最后刷新时间';
|
|
||||||
|
|
||||||
let html = `
|
const totalCallsLabel = mcpMonitorT('totalCalls') || '总调用次数';
|
||||||
<div class="monitor-stat-card">
|
const successRateLabel = mcpMonitorT('successRate') || '成功率';
|
||||||
<h4>${escapeHtml(totalCallsLabel)}</h4>
|
const lastCallLabel = mcpMonitorT('lastCall') || '最近一次调用';
|
||||||
<div class="monitor-stat-value">${totals.total}</div>
|
const statsFromAll = mcpMonitorT('statsFromAllTools') || '统计自全部工具调用';
|
||||||
<div class="monitor-stat-meta">${escapeHtml(successFailedLabel)}</div>
|
const successPill = mcpMonitorT('successCount', { n: totals.success }) || `成功 ${totals.success}`;
|
||||||
</div>
|
const failedPill = mcpMonitorT('failedCount', { n: totals.failed }) || `失败 ${totals.failed}`;
|
||||||
<div class="monitor-stat-card">
|
const rateTone = getMcpStatsRateTone(successRateNum);
|
||||||
<h4>${escapeHtml(successRateLabel)}</h4>
|
let rateSubText = mcpMonitorT('rateHealthy') || '运行平稳';
|
||||||
<div class="monitor-stat-value">${successRate}%</div>
|
if (successRateNum < 80) rateSubText = mcpMonitorT('rateCritical') || '失败率偏高';
|
||||||
<div class="monitor-stat-meta">${escapeHtml(statsFromAll)}</div>
|
else if (successRateNum < 95) rateSubText = mcpMonitorT('rateWarning') || '存在失败调用';
|
||||||
</div>
|
|
||||||
<div class="monitor-stat-card">
|
const toolFilterEl = document.getElementById('monitor-tool-filter');
|
||||||
<h4>${escapeHtml(lastCallLabel)}</h4>
|
const activeToolFilter = toolFilterEl ? toolFilterEl.value.trim() : '';
|
||||||
<div class="monitor-stat-value" style="font-size:1rem;">${escapeHtml(lastCallText)}</div>
|
|
||||||
<div class="monitor-stat-meta">${escapeHtml(lastRefreshLabel)}:${escapeHtml(lastUpdatedText)}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 显示最多前4个工具的统计(过滤掉 totalCalls 为 0 的工具)
|
|
||||||
const topTools = entries
|
const topTools = entries
|
||||||
.filter(tool => (tool.totalCalls || 0) > 0)
|
.filter(tool => (tool.totalCalls || 0) > 0)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
|
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
|
||||||
.slice(0, 4);
|
.slice(0, MCP_STATS_TOP_N);
|
||||||
|
|
||||||
const unknownToolLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknownTool') : '未知工具';
|
const maxToolCalls = topTools.length > 0 ? (topTools[0].totalCalls || 0) : 0;
|
||||||
topTools.forEach(tool => {
|
const unknownToolLabel = mcpMonitorT('unknownTool') || '未知工具';
|
||||||
const toolSuccessRate = tool.totalCalls > 0 ? ((tool.successCalls || 0) / tool.totalCalls * 100).toFixed(1) : '0.0';
|
const topToolsTitle = mcpMonitorT('topToolsTitle', { n: MCP_STATS_TOP_N }) || `工具调用 Top ${MCP_STATS_TOP_N}`;
|
||||||
const toolMeta = typeof window.t === 'function' ? window.t('mcpMonitor.successFailedRate', { success: tool.successCalls || 0, failed: tool.failedCalls || 0, rate: toolSuccessRate }) : `成功 ${tool.successCalls || 0} / 失败 ${tool.failedCalls || 0} · 成功率 ${toolSuccessRate}%`;
|
const toolsHint = mcpMonitorT('clickToFilterTool') || '点击行筛选下方执行记录';
|
||||||
html += `
|
const barLegend = mcpMonitorT('barVolumeLegend') || '条长表示相对调用量';
|
||||||
<div class="monitor-stat-card">
|
const successRateAria = mcpMonitorT('successRateAria', { rate: successRate }) || `成功率 ${successRate}%`;
|
||||||
<h4>${escapeHtml(tool.toolName || unknownToolLabel)}</h4>
|
|
||||||
<div class="monitor-stat-value">${tool.totalCalls || 0}</div>
|
const iconCalls = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
|
||||||
<div class="monitor-stat-meta">
|
const iconRate = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
${escapeHtml(toolMeta)}
|
const iconTime = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||||
</div>
|
|
||||||
</div>
|
let toolRowsHtml = '';
|
||||||
|
topTools.forEach((tool, index) => {
|
||||||
|
const name = tool.toolName || unknownToolLabel;
|
||||||
|
const total = tool.totalCalls || 0;
|
||||||
|
const success = tool.successCalls || 0;
|
||||||
|
const failed = tool.failedCalls || 0;
|
||||||
|
const toolRateNum = total > 0 ? (success / total) * 100 : 0;
|
||||||
|
const toolRate = toolRateNum.toFixed(1);
|
||||||
|
const isActive = activeToolFilter && activeToolFilter === name;
|
||||||
|
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|
||||||
|
|| `${name},${total} 次调用,成功率 ${toolRate}%`;
|
||||||
|
const rateClass = getMcpToolRateClass(toolRateNum);
|
||||||
|
toolRowsHtml += `
|
||||||
|
<li class="mcp-stats-tool-item">
|
||||||
|
<button type="button" class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
|
||||||
|
data-tool-name="${escapeHtml(name)}"
|
||||||
|
aria-label="${escapeHtml(rowAria)}"
|
||||||
|
aria-pressed="${isActive ? 'true' : 'false'}">
|
||||||
|
<span class="mcp-stats-tool-rank">${index + 1}</span>
|
||||||
|
<div class="mcp-stats-tool-main">
|
||||||
|
<div class="mcp-stats-tool-top">
|
||||||
|
<span class="mcp-stats-tool-name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||||
|
<span class="mcp-stats-tool-metrics">
|
||||||
|
<span class="mcp-stats-tool-count">${total}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span class="mcp-stats-tool-rate ${rateClass}">${toolRate}%</span>
|
||||||
|
${failed > 0 ? `<span class="mcp-stats-tool-fail-badge">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${renderMcpStatsToolVolumeBar(total, success, failed, maxToolCalls)}
|
||||||
|
</div>
|
||||||
|
${MCP_STATS_TOOL_CHEVRON}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
container.innerHTML = `<div class="monitor-stats-grid">${html}</div>`;
|
const clearFilterBtn = activeToolFilter
|
||||||
|
? `<button type="button" class="mcp-stats-clear-filter">${escapeHtml(mcpMonitorT('clearToolFilter') || '清除工具筛选')}</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="mcp-exec-stats">
|
||||||
|
<div class="mcp-stats-kpi-row">
|
||||||
|
<article class="mcp-stats-kpi-card mcp-stats-kpi-card--calls">
|
||||||
|
<div class="mcp-stats-kpi-head">
|
||||||
|
<span class="mcp-stats-kpi-label">${escapeHtml(totalCallsLabel)}</span>
|
||||||
|
<span class="mcp-stats-kpi-icon mcp-stats-kpi-icon--calls" aria-hidden="true">${iconCalls}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-kpi-value">${totals.total}</div>
|
||||||
|
${renderMcpStatsStackedBar(totals.success, totals.failed)}
|
||||||
|
<div class="mcp-stats-kpi-sub">
|
||||||
|
<span class="mcp-stats-pill mcp-stats-pill--success">${escapeHtml(successPill)}</span>
|
||||||
|
<span class="mcp-stats-pill mcp-stats-pill--fail">${escapeHtml(failedPill)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="mcp-stats-kpi-card mcp-stats-kpi-card--rate">
|
||||||
|
<div class="mcp-stats-kpi-head">
|
||||||
|
<span class="mcp-stats-kpi-label">${escapeHtml(successRateLabel)}</span>
|
||||||
|
<span class="mcp-stats-kpi-icon mcp-stats-kpi-icon--rate" aria-hidden="true">${iconRate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-kpi-body" role="img" aria-label="${escapeHtml(successRateAria)}">
|
||||||
|
<div class="mcp-stats-kpi-value">${successRate}%</div>
|
||||||
|
${renderMcpStatsSuccessRing(successRate)}
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-kpi-sub">
|
||||||
|
<span class="mcp-stats-kpi-sub-text ${rateTone}">${escapeHtml(rateSubText)}</span>
|
||||||
|
<span class="mcp-stats-kpi-sub-text">${escapeHtml(statsFromAll)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="mcp-stats-kpi-card mcp-stats-kpi-card--time">
|
||||||
|
<div class="mcp-stats-kpi-head">
|
||||||
|
<span class="mcp-stats-kpi-label">${escapeHtml(lastCallLabel)}</span>
|
||||||
|
<span class="mcp-stats-kpi-icon mcp-stats-kpi-icon--time" aria-hidden="true">${iconTime}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-kpi-value mcp-stats-kpi-value--time">${escapeHtml(lastCallText)}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
${topTools.length > 0 ? `
|
||||||
|
<div class="mcp-stats-split">
|
||||||
|
<div class="mcp-stats-split-left">
|
||||||
|
<div class="mcp-stats-tools-panel">
|
||||||
|
<div class="mcp-stats-tools-header">
|
||||||
|
<div class="mcp-stats-tools-heading">
|
||||||
|
<h4 class="mcp-stats-tools-title">${escapeHtml(topToolsTitle)}</h4>
|
||||||
|
<span class="mcp-stats-tools-legend">${escapeHtml(barLegend)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="mcp-stats-tools-hint">${escapeHtml(toolsHint)}</span>
|
||||||
|
</div>
|
||||||
|
<ol class="mcp-stats-tool-list" aria-label="${escapeHtml(topToolsTitle)}">${toolRowsHtml}</ol>
|
||||||
|
${clearFilterBtn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-split-right">
|
||||||
|
${renderMcpStatsInsightPanel(topTools, totals, activeToolFilter)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
bindMonitorStatsPanelEvents();
|
||||||
|
if (toolFilterEl && activeToolFilter) {
|
||||||
|
toolFilterEl.classList.add('is-filter-active');
|
||||||
|
} else if (toolFilterEl) {
|
||||||
|
toolFilterEl.classList.remove('is-filter-active');
|
||||||
|
}
|
||||||
|
updateMonitorStatsSubtitle(lastFetchedAt, entries.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||||
@@ -3481,4 +4110,14 @@ document.addEventListener('languagechange', function () {
|
|||||||
updateBatchActionsState();
|
updateBatchActionsState();
|
||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
refreshProgressAndTimelineI18n();
|
refreshProgressAndTimelineI18n();
|
||||||
|
if (monitorState.stats && Object.keys(monitorState.stats).length > 0) {
|
||||||
|
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
bindMonitorStatsPanelEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.filterMonitorByTool = filterMonitorByTool;
|
||||||
|
window.clearMonitorToolFilter = clearMonitorToolFilter;
|
||||||
|
|||||||
@@ -1087,6 +1087,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 +1119,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 +1128,7 @@ async function applySettings() {
|
|||||||
enabled: c2Enabled
|
enabled: c2Enabled
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
|
...(prevRobots.session && typeof prevRobots.session === 'object' ? { session: prevRobots.session } : {}),
|
||||||
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 +1140,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
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+163
-119
@@ -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>
|
||||||
@@ -3509,7 +3553,7 @@
|
|||||||
<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
Reference in New Issue
Block a user