mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-06 06:13:58 +02:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70bf5c93bf | |||
| 08bd278d8c | |||
| 22746d64a3 | |||
| 199392a5d5 | |||
| aafb4cb584 | |||
| 96e3dd397c | |||
| ec0f17145b | |||
| ed53da0999 | |||
| dc440fc511 | |||
| 009ae59033 | |||
| f348b3245a | |||
| 0018c5219c | |||
| 01a3e3677a | |||
| a12ecdb46f | |||
| 9f59230d74 | |||
| 085c6a1c72 | |||
| 7b3860971f | |||
| f6f7b7b237 | |||
| d5cf4b3b16 | |||
| 3e58d8355b | |||
| eb01ade63b | |||
| d1dc15fa44 | |||
| 73a39ef868 | |||
| a022baef03 | |||
| 59312d428e | |||
| 951d14ef14 | |||
| 0eb22da6e9 | |||
| 5fd9ef0514 | |||
| 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 |
@@ -174,9 +174,11 @@ The `run.sh` script will automatically:
|
||||
- ✅ Build the project
|
||||
- ✅ 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:**
|
||||
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:
|
||||
```yaml
|
||||
openai:
|
||||
@@ -197,14 +199,16 @@ The `run.sh` script will automatically:
|
||||
|
||||
**Alternative Launch Methods:**
|
||||
```bash
|
||||
# Direct Go run (requires manual setup)
|
||||
go run cmd/server/main.go
|
||||
# Direct Go run (set up env yourself); add --https to match run.sh defaults
|
||||
go run cmd/server/main.go --https
|
||||
|
||||
# Manual build
|
||||
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.
|
||||
|
||||
### Version Update (No Breaking Changes)
|
||||
|
||||
+8
-4
@@ -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**(首次使用前必填)
|
||||
- 启动后访问 http://localhost:8080
|
||||
- 启动后在浏览器打开 **`https://127.0.0.1:8080/`**(或 **`https://localhost:8080/`**;端口以 `config.yaml` 中 **`server.port`** 为准,默认 8080),并按提示信任自签证书。若使用 **`./run.sh --http`**,则改用 **`http://`** 访问。
|
||||
- 进入 `设置` → 填写 API 配置信息:
|
||||
```yaml
|
||||
openai:
|
||||
@@ -196,14 +198,16 @@ chmod +x run.sh && ./run.sh
|
||||
|
||||
**其他启动方式:**
|
||||
```bash
|
||||
# 直接运行(需手动配置环境)
|
||||
go run cmd/server/main.go
|
||||
# 直接运行(需自行配环境);与 run.sh 默认一致可加 --https
|
||||
go run cmd/server/main.go --https
|
||||
|
||||
# 手动编译
|
||||
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` 等)会自动使用该环境。
|
||||
|
||||
### CyberStrikeAI 版本更新(无兼容性问题)
|
||||
|
||||
+43
-3
@@ -9,22 +9,62 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
||||
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
|
||||
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 {
|
||||
fmt.Printf("加载配置失败: %v\n", err)
|
||||
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 为空时,自动生成随机密钥并写回配置
|
||||
if err := config.EnsureMCPAuth(*configPath, cfg); err != nil {
|
||||
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
||||
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
@@ -44,7 +84,7 @@ func main() {
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// 创建应用
|
||||
application, err := app.New(cfg, log)
|
||||
application, err := app.New(cfg, log, cp)
|
||||
if err != nil {
|
||||
log.Fatal("应用初始化失败", "error", err)
|
||||
}
|
||||
|
||||
+22
-3
@@ -10,11 +10,22 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.13"
|
||||
version: "v1.6.17"
|
||||
# 服务器配置
|
||||
server:
|
||||
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:
|
||||
password: # Web 登录密码,请修改为强密码
|
||||
@@ -43,7 +54,7 @@ openai:
|
||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
||||
reasoning:
|
||||
mode: off # auto | on | off;off 时不附加任何推理扩展字段
|
||||
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
||||
effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展)
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
@@ -224,6 +235,14 @@ knowledge:
|
||||
# 用于在手机端通过企业微信/钉钉/飞书与 CyberStrikeAI 对话,无需部署在服务器上也可使用
|
||||
# 在系统设置 -> 机器人设置 中可配置
|
||||
robots:
|
||||
wechat: # 微信 iLink(个人微信 ClawBot,扫码绑定)
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
ilink_bot_id: ""
|
||||
ilink_user_id: ""
|
||||
base_url: https://ilinkai.weixin.qq.com
|
||||
bot_type: "3"
|
||||
bot_agent: CyberStrikeAI/1.0
|
||||
wecom: # 企业微信
|
||||
enabled: false
|
||||
token: ""
|
||||
|
||||
@@ -33,6 +33,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.34.0
|
||||
go.opentelemetry.io/otel/trace v1.34.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/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -88,7 +89,6 @@ require (
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||
|
||||
@@ -245,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-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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
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/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
||||
+32
-8
@@ -598,11 +598,17 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
thinkingStreamSeq++
|
||||
thinkingStreamId := fmt.Sprintf("thinking-stream-%s-%d-%d", conversationID, i+1, thinkingStreamSeq)
|
||||
thinkingStreamStarted := false
|
||||
var thinkingWire string
|
||||
|
||||
response, err := a.callOpenAIStreamWithToolCalls(ctx, messages, tools, func(delta string) error {
|
||||
if delta == "" {
|
||||
return nil
|
||||
}
|
||||
var deltaOut string
|
||||
thinkingWire, deltaOut = openai.NormalizeStreamingDelta(thinkingWire, delta)
|
||||
if deltaOut == "" {
|
||||
return nil
|
||||
}
|
||||
if !thinkingStreamStarted {
|
||||
thinkingStreamStarted = true
|
||||
sendProgress("thinking_stream_start", " ", map[string]interface{}{
|
||||
@@ -611,10 +617,10 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
"toolStream": false,
|
||||
})
|
||||
}
|
||||
sendProgress("thinking_stream_delta", delta, map[string]interface{}{
|
||||
sendProgress("thinking_stream_delta", deltaOut, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"streamId": thinkingStreamId,
|
||||
"iteration": i + 1,
|
||||
})
|
||||
}, thinkingWire))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -827,10 +833,16 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "summary",
|
||||
})
|
||||
var summaryWire string
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
sendProgress("response_delta", delta, map[string]interface{}{
|
||||
var deltaOut string
|
||||
summaryWire, deltaOut = openai.NormalizeStreamingDelta(summaryWire, delta)
|
||||
if deltaOut == "" {
|
||||
return nil
|
||||
}
|
||||
sendProgress("response_delta", deltaOut, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
}, summaryWire))
|
||||
return nil
|
||||
})
|
||||
if strings.TrimSpace(streamText) != "" {
|
||||
@@ -874,10 +886,16 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "summary",
|
||||
})
|
||||
var summaryWire string
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
sendProgress("response_delta", delta, map[string]interface{}{
|
||||
var deltaOut string
|
||||
summaryWire, deltaOut = openai.NormalizeStreamingDelta(summaryWire, delta)
|
||||
if deltaOut == "" {
|
||||
return nil
|
||||
}
|
||||
sendProgress("response_delta", deltaOut, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
}, summaryWire))
|
||||
return nil
|
||||
})
|
||||
if strings.TrimSpace(streamText) != "" {
|
||||
@@ -921,10 +939,16 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "max_iter_summary",
|
||||
})
|
||||
var summaryWire string
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
sendProgress("response_delta", delta, map[string]interface{}{
|
||||
var deltaOut string
|
||||
summaryWire, deltaOut = openai.NormalizeStreamingDelta(summaryWire, delta)
|
||||
if deltaOut == "" {
|
||||
return nil
|
||||
}
|
||||
sendProgress("response_delta", deltaOut, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
}, summaryWire))
|
||||
return nil
|
||||
})
|
||||
if strings.TrimSpace(streamText) != "" {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseTraceMessages 解析落库的 last_react_input(OpenAI 风格 messages JSON 数组)。
|
||||
func ParseTraceMessages(traceInputJSON string) ([]ChatMessage, error) {
|
||||
traceInputJSON = strings.TrimSpace(traceInputJSON)
|
||||
if traceInputJSON == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var raw []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(traceInputJSON), &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ChatMessage, 0, len(raw))
|
||||
for _, msgMap := range raw {
|
||||
msg := ChatMessage{}
|
||||
role, _ := msgMap["role"].(string)
|
||||
if role == "" {
|
||||
continue
|
||||
}
|
||||
msg.Role = role
|
||||
if content, ok := msgMap["content"].(string); ok {
|
||||
msg.Content = content
|
||||
}
|
||||
if rc, ok := msgMap["reasoning_content"].(string); ok && strings.TrimSpace(rc) != "" {
|
||||
msg.ReasoningContent = rc
|
||||
}
|
||||
if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil {
|
||||
if toolCallsArray, ok := toolCallsRaw.([]interface{}); ok {
|
||||
for _, tcRaw := range toolCallsArray {
|
||||
tcMap, ok := tcRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
toolCall := ToolCall{}
|
||||
if id, ok := tcMap["id"].(string); ok {
|
||||
toolCall.ID = id
|
||||
}
|
||||
if toolType, ok := tcMap["type"].(string); ok {
|
||||
toolCall.Type = toolType
|
||||
}
|
||||
if funcMap, ok := tcMap["function"].(map[string]interface{}); ok {
|
||||
toolCall.Function = FunctionCall{}
|
||||
if name, ok := funcMap["name"].(string); ok {
|
||||
toolCall.Function.Name = name
|
||||
}
|
||||
if argsRaw, ok := funcMap["arguments"]; ok {
|
||||
if argsStr, ok := argsRaw.(string); ok {
|
||||
var argsMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(argsStr), &argsMap); err == nil {
|
||||
toolCall.Function.Arguments = argsMap
|
||||
}
|
||||
} else if argsMap, ok := argsRaw.(map[string]interface{}); ok {
|
||||
toolCall.Function.Arguments = argsMap
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolCall.ID != "" {
|
||||
msg.ToolCalls = append(msg.ToolCalls, toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
|
||||
msg.ToolCallID = toolCallID
|
||||
}
|
||||
if tn, ok := msgMap["tool_name"].(string); ok && strings.TrimSpace(tn) != "" {
|
||||
msg.ToolName = strings.TrimSpace(tn)
|
||||
} else if tn, ok := msgMap["name"].(string); ok && strings.TrimSpace(tn) != "" && strings.EqualFold(msg.Role, "tool") {
|
||||
msg.ToolName = strings.TrimSpace(tn)
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ExtractLastUserTurnMessages 仅保留最后一次 user 提问起的消息(不含更早的用户轮次;跳过 system)。
|
||||
// 与「继续对话」续跑所用轨迹范围一致:当前任务轮次,而非整段多轮对话历史。
|
||||
func ExtractLastUserTurnMessages(msgs []ChatMessage) []ChatMessage {
|
||||
if len(msgs) == 0 {
|
||||
return msgs
|
||||
}
|
||||
lastUser := -1
|
||||
for i, m := range msgs {
|
||||
if strings.EqualFold(m.Role, "user") {
|
||||
lastUser = i
|
||||
}
|
||||
}
|
||||
if lastUser < 0 {
|
||||
return msgs
|
||||
}
|
||||
trimmed := msgs[lastUser:]
|
||||
out := make([]ChatMessage, 0, len(trimmed))
|
||||
for _, m := range trimmed {
|
||||
if strings.EqualFold(m.Role, "system") {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtractLastUserTurnTraceJSON 在 JSON 轨迹上裁剪为最后一次 user 起的片段(供落库格式直接处理)。
|
||||
func ExtractLastUserTurnTraceJSON(traceInputJSON string) string {
|
||||
traceInputJSON = strings.TrimSpace(traceInputJSON)
|
||||
if traceInputJSON == "" {
|
||||
return traceInputJSON
|
||||
}
|
||||
var arr []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(traceInputJSON), &arr); err != nil {
|
||||
return traceInputJSON
|
||||
}
|
||||
lastUser := -1
|
||||
for i, m := range arr {
|
||||
if r, _ := m["role"].(string); strings.EqualFold(r, "user") {
|
||||
lastUser = i
|
||||
}
|
||||
}
|
||||
if lastUser <= 0 {
|
||||
return traceInputJSON
|
||||
}
|
||||
trimmed := arr[lastUser:]
|
||||
b, err := json.Marshal(trimmed)
|
||||
if err != nil {
|
||||
return traceInputJSON
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// MergeAssistantTraceOutput 将 last_react_output 合并进轨迹最后一条 assistant(与 loadHistoryFromAgentTrace 一致)。
|
||||
func MergeAssistantTraceOutput(msgs []ChatMessage, assistantOut string) []ChatMessage {
|
||||
assistantOut = strings.TrimSpace(assistantOut)
|
||||
if assistantOut == "" || len(msgs) == 0 {
|
||||
return msgs
|
||||
}
|
||||
out := append([]ChatMessage(nil), msgs...)
|
||||
last := &out[len(out)-1]
|
||||
if strings.EqualFold(last.Role, "assistant") && len(last.ToolCalls) == 0 {
|
||||
last.Content = assistantOut
|
||||
return out
|
||||
}
|
||||
out = append(out, ChatMessage{
|
||||
Role: "assistant",
|
||||
Content: assistantOut,
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// MessagesToTraceJSON 将消息带序列化为 JSON(跳过 system)。
|
||||
func MessagesToTraceJSON(msgs []ChatMessage) (string, error) {
|
||||
filtered := make([]ChatMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if strings.EqualFold(m.Role, "system") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
b, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractLastUserTurnTraceJSON(t *testing.T) {
|
||||
raw := []map[string]interface{}{
|
||||
{"role": "user", "content": "old question"},
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "new target 1.1.1.1"},
|
||||
{"role": "assistant", "tool_calls": []interface{}{map[string]interface{}{
|
||||
"id": "c1", "type": "function",
|
||||
"function": map[string]interface{}{"name": "nmap", "arguments": "{}"},
|
||||
}}},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "open ports"},
|
||||
}
|
||||
b, _ := json.Marshal(raw)
|
||||
out := ExtractLastUserTurnTraceJSON(string(b))
|
||||
var trimmed []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &trimmed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(trimmed) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(trimmed))
|
||||
}
|
||||
if trimmed[0]["content"] != "new target 1.1.1.1" {
|
||||
t.Fatalf("unexpected first message: %v", trimmed[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLastUserTurnMessagesSkipsSystem(t *testing.T) {
|
||||
msgs := []ChatMessage{
|
||||
{Role: "system", Content: "sys"},
|
||||
{Role: "user", Content: "q"},
|
||||
{Role: "assistant", Content: "a"},
|
||||
}
|
||||
out := ExtractLastUserTurnMessages(msgs)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("expected 2, got %d", len(out))
|
||||
}
|
||||
if out[0].Role != "user" {
|
||||
t.Fatal("expected user first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAssistantTraceOutput(t *testing.T) {
|
||||
msgs := []ChatMessage{
|
||||
{Role: "user", Content: "q"},
|
||||
{Role: "assistant", Content: "draft"},
|
||||
}
|
||||
out := MergeAssistantTraceOutput(msgs, "final summary")
|
||||
if out[len(out)-1].Content != "final summary" {
|
||||
t.Fatalf("expected merged output, got %q", out[len(out)-1].Content)
|
||||
}
|
||||
}
|
||||
+94
-11
@@ -3,8 +3,10 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -30,6 +32,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
// App 应用
|
||||
@@ -53,6 +56,7 @@ type App struct {
|
||||
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
|
||||
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
|
||||
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
|
||||
wechatCancel context.CancelFunc // 微信 iLink 长轮询取消函数
|
||||
c2Manager *c2.Manager // C2 管理器(未启用 C2 时为 nil)
|
||||
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
|
||||
c2WatchdogCancel context.CancelFunc // 看门狗取消函数
|
||||
@@ -60,7 +64,7 @@ type App struct {
|
||||
}
|
||||
|
||||
// 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)
|
||||
router := gin.Default()
|
||||
|
||||
@@ -292,10 +296,10 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
}()
|
||||
}
|
||||
|
||||
// 获取配置文件路径
|
||||
configPath := "config.yaml"
|
||||
if len(os.Args) > 1 {
|
||||
configPath = os.Args[1]
|
||||
// 配置文件路径必须由入口传入(与 flag -config 一致)。勿再用 os.Args[1],否则 ./cyberstrike-ai --https 会把 --https 当成路径。
|
||||
configPath = strings.TrimSpace(configPath)
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml"
|
||||
}
|
||||
|
||||
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
|
||||
@@ -446,9 +450,11 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
configHandler.SetRetrieverUpdater(knowledgeRetriever)
|
||||
}
|
||||
|
||||
// 设置机器人连接重启器,前端应用配置后无需重启服务即可使钉钉/飞书新配置生效
|
||||
// 设置机器人连接重启器,前端应用配置后无需重启服务即可使钉钉/飞书/微信新配置生效
|
||||
configHandler.SetRobotRestarter(app)
|
||||
|
||||
wechatRobotHandler := handler.NewWechatRobotHandler(cfg, configHandler, log.Logger)
|
||||
|
||||
configHandler.SetC2Runtime(app)
|
||||
configHandler.SetC2ToolRegistrar(func() error {
|
||||
if app.config.C2.EnabledEffective() && app.c2Manager != nil {
|
||||
@@ -466,6 +472,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
notificationHandler,
|
||||
conversationHandler,
|
||||
robotHandler,
|
||||
wechatRobotHandler,
|
||||
groupHandler,
|
||||
configHandler,
|
||||
externalMCPHandler,
|
||||
@@ -530,18 +537,49 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// 启动主服务器
|
||||
// 启动主服务器(可选 HTTPS + HTTP/2,见 config server.tls_*)
|
||||
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
||||
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}
|
||||
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 服务器
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
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))
|
||||
}
|
||||
if mcpServer != nil {
|
||||
@@ -551,7 +589,36 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
var err error
|
||||
switch {
|
||||
case tlsMode != mainTLSOff && httpRedirect:
|
||||
var tlsConfReady *tls.Config
|
||||
tlsConfReady, err = ensureMainTLSConfigCerts(tlsMode, tlsConf, certFile, keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("加载 TLS 证书: %w", err)
|
||||
}
|
||||
srv.TLSConfig = tlsConfReady
|
||||
var ln net.Listener
|
||||
ln, err = net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mainMux = newMainServerMux(ln, srv, portFromListenAddr(addr), a.logger.Logger)
|
||||
err = mainMux.Serve()
|
||||
case tlsMode == mainTLSOff:
|
||||
err = srv.ListenAndServe()
|
||||
case tlsMode == mainTLSFromFiles:
|
||||
err = srv.ListenAndServeTLS(certFile, keyFile)
|
||||
case tlsMode == mainTLSInMemorySelfSigned:
|
||||
var ln net.Listener
|
||||
ln, err = tls.Listen("tcp", addr, srv.TLSConfig)
|
||||
if err == nil {
|
||||
err = srv.Serve(ln)
|
||||
}
|
||||
default:
|
||||
err = srv.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -612,9 +679,14 @@ func (a *App) startRobotConnections() {
|
||||
a.dingCancel = cancel
|
||||
go robot.StartDing(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.Wechat.Enabled && cfg.Robots.Wechat.BotToken != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.wechatCancel = cancel
|
||||
go robot.StartWechat(ctx, cfg.Robots, a.robotHandler, cfg.Version, a.logger.Logger)
|
||||
}
|
||||
}
|
||||
|
||||
// RestartRobotConnections 重启钉钉/飞书长连接,使前端应用配置后立即生效(实现 handler.RobotRestarter)
|
||||
// RestartRobotConnections 重启钉钉/飞书/微信长连接,使前端应用配置后立即生效(实现 handler.RobotRestarter)
|
||||
func (a *App) RestartRobotConnections() {
|
||||
a.robotMu.Lock()
|
||||
if a.dingCancel != nil {
|
||||
@@ -625,6 +697,10 @@ func (a *App) RestartRobotConnections() {
|
||||
a.larkCancel()
|
||||
a.larkCancel = nil
|
||||
}
|
||||
if a.wechatCancel != nil {
|
||||
a.wechatCancel()
|
||||
a.wechatCancel = nil
|
||||
}
|
||||
a.robotMu.Unlock()
|
||||
// 给旧 goroutine 一点时间退出
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
@@ -640,6 +716,7 @@ func setupRoutes(
|
||||
notificationHandler *handler.NotificationHandler,
|
||||
conversationHandler *handler.ConversationHandler,
|
||||
robotHandler *handler.RobotHandler,
|
||||
wechatRobotHandler *handler.WechatRobotHandler,
|
||||
groupHandler *handler.GroupHandler,
|
||||
configHandler *handler.ConfigHandler,
|
||||
externalMCPHandler *handler.ExternalMCPHandler,
|
||||
@@ -688,6 +765,12 @@ func setupRoutes(
|
||||
// 机器人测试(需登录):POST /api/robot/test,body: {"platform":"dingtalk","user_id":"test","text":"帮助"},用于验证机器人逻辑
|
||||
protected.POST("/robot/test", robotHandler.HandleRobotTest)
|
||||
|
||||
// 微信 iLink 扫码绑定(需登录)
|
||||
protected.POST("/robot/wechat/qrcode", wechatRobotHandler.HandleWechatQRCode)
|
||||
protected.GET("/robot/wechat/qrcode/status", wechatRobotHandler.HandleWechatQRCodeStatus)
|
||||
protected.POST("/robot/wechat/qrcode/verify", wechatRobotHandler.HandleWechatVerifyCode)
|
||||
protected.GET("/robot/wechat/status", wechatRobotHandler.HandleWechatStatus)
|
||||
|
||||
// Agent Loop
|
||||
protected.POST("/agent-loop", agentHandler.AgentLoop)
|
||||
// Agent Loop 流式输出
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func NewBuilder(db *database.DB, openAIConfig *config.OpenAIConfig, logger *zap.
|
||||
}
|
||||
}
|
||||
|
||||
// BuildChainFromConversation 从对话构建攻击链(简化版本:用户输入+最后一轮ReAct输入+大模型输出)
|
||||
// BuildChainFromConversation 从对话构建攻击链(单次 LLM 调用;输入为当前任务轮次的 last_react 轨迹,与继续对话续跑范围一致)。
|
||||
func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID string) (*Chain, error) {
|
||||
b.logger.Info("开始构建攻击链(简化版本)", zap.String("conversationId", conversationID))
|
||||
|
||||
@@ -157,33 +157,34 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
|
||||
var reactInputFinal string
|
||||
var dataSource string // 记录数据来源
|
||||
|
||||
// 如果成功获取到保存的ReAct数据,直接使用
|
||||
if reactInputJSON != "" && modelOutput != "" {
|
||||
// 计算 ReAct 输入的哈希值,用于追踪
|
||||
hash := sha256.Sum256([]byte(reactInputJSON))
|
||||
reactInputHash := hex.EncodeToString(hash[:])[:16] // 使用前16字符作为短标识
|
||||
// 优先使用落库的代理轨迹(与继续对话 loadHistoryFromAgentTrace 同源),并裁剪为「当前任务轮次」
|
||||
if reactInputJSON != "" {
|
||||
trimmedJSON := agent.ExtractLastUserTurnTraceJSON(reactInputJSON)
|
||||
hash := sha256.Sum256([]byte(trimmedJSON))
|
||||
reactInputHash := hex.EncodeToString(hash[:])[:16]
|
||||
|
||||
// 统计消息数量
|
||||
var messageCount int
|
||||
var tempMessages []interface{}
|
||||
if json.Unmarshal([]byte(reactInputJSON), &tempMessages) == nil {
|
||||
messageCount = len(tempMessages)
|
||||
if msgs, parseErr := agent.ParseTraceMessages(trimmedJSON); parseErr == nil {
|
||||
messageCount = len(msgs)
|
||||
msgs = agent.MergeAssistantTraceOutput(msgs, modelOutput)
|
||||
reactInputFinal = b.formatAgentTraceFromChatMessages(msgs)
|
||||
} else {
|
||||
b.logger.Warn("解析代理轨迹失败,回退原始 JSON 格式化", zap.Error(parseErr))
|
||||
reactInputFinal = b.formatAgentTraceInputFromJSON(trimmedJSON)
|
||||
if strings.TrimSpace(modelOutput) != "" {
|
||||
reactInputFinal += "\n\n## 助手结论(last_react_output)\n\n" + modelOutput
|
||||
}
|
||||
}
|
||||
|
||||
dataSource = "database_last_agent_trace"
|
||||
b.logger.Info("使用保存的ReAct数据构建攻击链",
|
||||
dataSource = "last_user_turn_agent_trace"
|
||||
b.logger.Info("使用当前任务轮次代理轨迹构建攻击链(与续跑上下文范围一致)",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("dataSource", dataSource),
|
||||
zap.Int("reactInputSize", len(reactInputJSON)),
|
||||
zap.Int("traceInputSizeBeforeTrim", len(reactInputJSON)),
|
||||
zap.Int("traceInputSizeAfterTrim", len(trimmedJSON)),
|
||||
zap.Int("messageCount", messageCount),
|
||||
zap.String("reactInputHash", reactInputHash),
|
||||
zap.Int("modelOutputSize", len(modelOutput)))
|
||||
|
||||
// 从保存的ReAct输入(JSON格式)中提取用户输入
|
||||
// userInput = b.extractUserInputFromReActInput(reactInputJSON)
|
||||
|
||||
// 将JSON格式的messages转换为可读格式
|
||||
reactInputFinal = b.formatAgentTraceInputFromJSON(reactInputJSON)
|
||||
} else {
|
||||
// 2. 如果没有保存的ReAct数据,从对话消息构建
|
||||
dataSource = "messages_table"
|
||||
@@ -243,8 +244,15 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 构建简化的prompt,一次性传递给大模型
|
||||
prompt := b.buildSimplePrompt(reactInputFinal, modelOutput)
|
||||
// 3. 按 token 预算压缩输入,再构建 prompt(避免超出模型上下文)
|
||||
reactInputFinal, modelOutput, _ = b.fitAttackChainPayload(reactInputFinal, modelOutput)
|
||||
|
||||
// 4. 构建 prompt 并单次调用大模型(助手结论已并入轨迹时不再重复传入)
|
||||
promptAssistantOut := modelOutput
|
||||
if reactInputJSON != "" {
|
||||
promptAssistantOut = ""
|
||||
}
|
||||
prompt := b.buildSimplePrompt(reactInputFinal, promptAssistantOut)
|
||||
// fmt.Println(prompt)
|
||||
// 6. 调用AI生成攻击链(一次性,不做任何处理)
|
||||
chainJSON, err := b.callAIForChainGeneration(ctx, prompt)
|
||||
@@ -366,10 +374,17 @@ func (b *Builder) formatProcessDetailsForAttackChain(details []database.ProcessD
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// buildAgentTraceInput 构建最后一轮ReAct的输入(历史消息+当前用户输入)
|
||||
// buildAgentTraceInput 构建最后一轮 ReAct 的输入(从最后一条 user 消息起,不含更早轮次)。
|
||||
func (b *Builder) buildAgentTraceInput(messages []database.Message) string {
|
||||
start := 0
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
if strings.EqualFold(messages[i].Role, "user") {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, msg := range messages {
|
||||
for _, msg := range messages[start:] {
|
||||
builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", msg.Role, msg.Content))
|
||||
}
|
||||
return builder.String()
|
||||
@@ -396,67 +411,66 @@ func (b *Builder) buildAgentTraceInput(messages []database.Message) string {
|
||||
// return ""
|
||||
// }
|
||||
|
||||
// formatAgentTraceInputFromJSON 将JSON格式的messages数组转换为可读的字符串格式
|
||||
// formatAgentTraceInputFromJSON 将 JSON 轨迹转为可读文本(会先按当前任务轮次裁剪)。
|
||||
func (b *Builder) formatAgentTraceInputFromJSON(reactInputJSON string) string {
|
||||
var messages []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(reactInputJSON), &messages); err != nil {
|
||||
trimmed := agent.ExtractLastUserTurnTraceJSON(reactInputJSON)
|
||||
msgs, err := agent.ParseTraceMessages(trimmed)
|
||||
if err != nil {
|
||||
b.logger.Warn("解析ReAct输入JSON失败", zap.Error(err))
|
||||
return reactInputJSON // 如果解析失败,返回原始JSON
|
||||
return trimmed
|
||||
}
|
||||
return b.formatAgentTraceFromChatMessages(msgs)
|
||||
}
|
||||
|
||||
// formatAgentTraceFromChatMessages 将代理消息带格式化为攻击链分析输入(与续跑轨迹字段一致)。
|
||||
func (b *Builder) formatAgentTraceFromChatMessages(msgs []agent.ChatMessage) string {
|
||||
var builder strings.Builder
|
||||
for _, msg := range messages {
|
||||
role, _ := msg["role"].(string)
|
||||
content, _ := msg["content"].(string)
|
||||
for _, msg := range msgs {
|
||||
role := msg.Role
|
||||
content := msg.Content
|
||||
|
||||
// 处理assistant消息:提取tool_calls信息
|
||||
if role == "assistant" {
|
||||
if toolCalls, ok := msg["tool_calls"].([]interface{}); ok && len(toolCalls) > 0 {
|
||||
// 如果有文本内容,先显示
|
||||
if content != "" {
|
||||
builder.WriteString(fmt.Sprintf("[%s]: %s\n", role, content))
|
||||
}
|
||||
// 详细显示每个工具调用
|
||||
builder.WriteString(fmt.Sprintf("[%s] 工具调用 (%d个):\n", role, len(toolCalls)))
|
||||
for i, toolCall := range toolCalls {
|
||||
if tc, ok := toolCall.(map[string]interface{}); ok {
|
||||
toolCallID, _ := tc["id"].(string)
|
||||
if funcData, ok := tc["function"].(map[string]interface{}); ok {
|
||||
toolName, _ := funcData["name"].(string)
|
||||
arguments, _ := funcData["arguments"].(string)
|
||||
builder.WriteString(fmt.Sprintf(" [工具调用 %d]\n", i+1))
|
||||
builder.WriteString(fmt.Sprintf(" ID: %s\n", toolCallID))
|
||||
builder.WriteString(fmt.Sprintf(" 工具名称: %s\n", toolName))
|
||||
builder.WriteString(fmt.Sprintf(" 参数: %s\n", arguments))
|
||||
}
|
||||
if strings.EqualFold(role, "assistant") && len(msg.ToolCalls) > 0 {
|
||||
if content != "" {
|
||||
builder.WriteString(fmt.Sprintf("[%s]: %s\n", role, content))
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("[%s] 工具调用 (%d个):\n", role, len(msg.ToolCalls)))
|
||||
for i, tc := range msg.ToolCalls {
|
||||
args := ""
|
||||
if tc.Function.Arguments != nil {
|
||||
if b, err := json.Marshal(tc.Function.Arguments); err == nil {
|
||||
args = string(b)
|
||||
}
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
continue
|
||||
builder.WriteString(fmt.Sprintf(" [工具调用 %d]\n", i+1))
|
||||
builder.WriteString(fmt.Sprintf(" ID: %s\n", tc.ID))
|
||||
builder.WriteString(fmt.Sprintf(" 工具名称: %s\n", tc.Function.Name))
|
||||
builder.WriteString(fmt.Sprintf(" 参数: %s\n", args))
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理tool消息:显示tool_call_id和完整内容
|
||||
if role == "tool" {
|
||||
toolCallID, _ := msg["tool_call_id"].(string)
|
||||
if toolCallID != "" {
|
||||
builder.WriteString(fmt.Sprintf("[%s] (tool_call_id: %s):\n%s\n\n", role, toolCallID, content))
|
||||
if strings.EqualFold(role, "tool") {
|
||||
if msg.ToolCallID != "" {
|
||||
builder.WriteString(fmt.Sprintf("[%s] (tool_call_id: %s):\n%s\n\n", role, msg.ToolCallID, content))
|
||||
} else {
|
||||
builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", role, content))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 其他消息类型(system, user等)正常显示
|
||||
builder.WriteString(fmt.Sprintf("[%s]: %s\n\n", role, content))
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
// buildSimplePrompt 构建简化的prompt
|
||||
func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
return fmt.Sprintf(`你是专业的安全测试分析师和攻击链构建专家。你的任务是根据对话记录和工具执行结果,构建一个逻辑清晰、有教育意义的攻击链图,完整展现渗透测试的思维过程和执行路径。
|
||||
return fmt.Sprintf(`你是专业的安全测试分析师和攻击链构建专家。你的任务是根据**当前任务轮次**的对话记录和工具执行结果,一次性输出攻击链 JSON(不要分多轮追问)。
|
||||
|
||||
## 输入范围(与「继续对话」续跑一致)
|
||||
- 下方「ReAct 轨迹」仅包含**最后一次用户提问之后**的消息与工具结果(last_react 当前任务轮次),不含更早的用户提问轮次。
|
||||
- 「助手结论」为同轮任务的最终输出摘要(last_react_output);节点须与轨迹中的实际工具执行一致,严禁编造。
|
||||
|
||||
## 核心目标
|
||||
|
||||
@@ -618,12 +632,9 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
5. **漏洞确认**:如何确认漏洞存在?(action→vulnerability)
|
||||
6. **攻击路径**:完整的攻击路径是什么?(从target到vulnerability的路径)
|
||||
|
||||
## 最后一轮ReAct输入
|
||||
## 当前任务 ReAct 轨迹(含工具执行;助手结论见轨迹末尾 assistant)
|
||||
|
||||
%s
|
||||
|
||||
## 大模型输出
|
||||
|
||||
%s
|
||||
|
||||
## 输出格式
|
||||
@@ -752,7 +763,15 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
9. **不要过度精简**:如果实际执行步骤较多,可以适当增加节点数量(最多20个),确保不遗漏关键步骤。
|
||||
10. **输出前验证**:在输出JSON前,必须验证所有边都满足source < target的条件,确保DAG结构正确。
|
||||
|
||||
现在开始分析并构建攻击链:`, reactInput, modelOutput)
|
||||
现在开始分析并构建攻击链:`, reactInput, assistantOutSection(modelOutput))
|
||||
}
|
||||
|
||||
func assistantOutSection(modelOutput string) string {
|
||||
modelOutput = strings.TrimSpace(modelOutput)
|
||||
if modelOutput == "" {
|
||||
return ""
|
||||
}
|
||||
return "\n## 助手结论(补充)\n\n" + modelOutput + "\n"
|
||||
}
|
||||
|
||||
// saveChain 保存攻击链到数据库
|
||||
@@ -812,7 +831,7 @@ func (b *Builder) callAIForChainGeneration(ctx context.Context, prompt string) (
|
||||
},
|
||||
},
|
||||
"temperature": 0.3,
|
||||
"max_completion_tokens": 80000,
|
||||
"max_completion_tokens": attackChainMaxCompletionTokens(b.maxTokens),
|
||||
}
|
||||
|
||||
var apiResponse struct {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package attackchain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
attackChainTruncationMarker = "\n\n...[攻击链输入已截断 / attack chain input truncated]...\n\n"
|
||||
attackChainSystemReserve = 256
|
||||
attackChainSafetyReserve = 2048
|
||||
)
|
||||
|
||||
// attackChainMaxCompletionTokens 为攻击链 JSON 输出预留的 completion token 上限。
|
||||
func attackChainMaxCompletionTokens(maxTotal int) int {
|
||||
const capTokens = 16384
|
||||
if maxTotal <= 0 {
|
||||
return 8192
|
||||
}
|
||||
v := maxTotal / 8
|
||||
if v < 4096 {
|
||||
v = 4096
|
||||
}
|
||||
if v > capTokens {
|
||||
v = capTokens
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (b *Builder) modelName() string {
|
||||
if b.openAIConfig != nil && b.openAIConfig.Model != "" {
|
||||
return b.openAIConfig.Model
|
||||
}
|
||||
return "gpt-4"
|
||||
}
|
||||
|
||||
func (b *Builder) countTokens(text string) int {
|
||||
if text == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := b.tokenCounter.Count(b.modelName(), text)
|
||||
if err != nil {
|
||||
return utf8.RuneCountInString(text) / 4
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// attackChainPayloadTokenBudget 计算 reactInput + modelOutput 可用的 token 预算。
|
||||
func (b *Builder) attackChainPayloadTokenBudget() int {
|
||||
maxTotal := b.maxTokens
|
||||
if maxTotal <= 0 {
|
||||
maxTotal = 100000
|
||||
}
|
||||
templateTok := b.countTokens(b.buildSimplePrompt("", ""))
|
||||
completion := attackChainMaxCompletionTokens(maxTotal)
|
||||
reserve := templateTok + attackChainSystemReserve + completion + attackChainSafetyReserve
|
||||
budget := maxTotal - reserve
|
||||
minBudget := maxTotal * 35 / 100
|
||||
if budget < minBudget {
|
||||
budget = minBudget
|
||||
}
|
||||
if budget < 4096 {
|
||||
budget = 4096
|
||||
}
|
||||
return budget
|
||||
}
|
||||
|
||||
// fitAttackChainPayload 在构建最终 prompt 前压缩 ReAct 轨迹与模型输出,避免超出模型上下文。
|
||||
func (b *Builder) fitAttackChainPayload(reactInput, modelOutput string) (string, string, bool) {
|
||||
budget := b.attackChainPayloadTokenBudget()
|
||||
modelBudget := budget * 15 / 100
|
||||
if modelBudget < 512 {
|
||||
modelBudget = 512
|
||||
}
|
||||
reactBudget := budget - modelBudget
|
||||
|
||||
origReactTok := b.countTokens(reactInput)
|
||||
origModelTok := b.countTokens(modelOutput)
|
||||
truncated := false
|
||||
|
||||
outModel := modelOutput
|
||||
if origModelTok > modelBudget {
|
||||
outModel = truncateTextByTokens(b, modelOutput, modelBudget)
|
||||
truncated = true
|
||||
}
|
||||
|
||||
outReact := reactInput
|
||||
perToolLimits := []int{12000, 6000, 3000, 1500, 800}
|
||||
for _, lim := range perToolLimits {
|
||||
compact := compactFormattedToolBodies(outReact, lim)
|
||||
if compact != outReact {
|
||||
outReact = compact
|
||||
truncated = true
|
||||
}
|
||||
if b.countTokens(outReact) <= reactBudget {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if b.countTokens(outReact) > reactBudget {
|
||||
outReact = truncateTextByTokens(b, outReact, reactBudget)
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if truncated {
|
||||
b.logger.Info("攻击链输入已按 token 预算截断",
|
||||
zap.Int("maxTotalTokens", b.maxTokens),
|
||||
zap.Int("payloadBudget", budget),
|
||||
zap.Int("reactBudget", reactBudget),
|
||||
zap.Int("modelBudget", modelBudget),
|
||||
zap.Int("reactInputTokensBefore", origReactTok),
|
||||
zap.Int("reactInputTokensAfter", b.countTokens(outReact)),
|
||||
zap.Int("modelOutputTokensBefore", origModelTok),
|
||||
zap.Int("modelOutputTokensAfter", b.countTokens(outModel)),
|
||||
zap.Int("maxCompletionTokens", attackChainMaxCompletionTokens(b.maxTokens)),
|
||||
)
|
||||
}
|
||||
|
||||
return outReact, outModel, truncated
|
||||
}
|
||||
|
||||
// compactFormattedToolBodies 缩短格式化 trace 中 [tool] 消息的正文,保留工具头与调用 ID。
|
||||
func compactFormattedToolBodies(s string, maxRunesPerBody int) string {
|
||||
if maxRunesPerBody <= 0 || s == "" {
|
||||
return s
|
||||
}
|
||||
const marker = "[tool]"
|
||||
var out strings.Builder
|
||||
remaining := s
|
||||
changed := false
|
||||
for {
|
||||
idx := strings.Index(remaining, marker)
|
||||
if idx < 0 {
|
||||
out.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
out.WriteString(remaining[:idx])
|
||||
remaining = remaining[idx:]
|
||||
nl := strings.IndexByte(remaining, '\n')
|
||||
if nl < 0 {
|
||||
out.WriteString(remaining)
|
||||
break
|
||||
}
|
||||
header := remaining[:nl+1]
|
||||
remaining = remaining[nl+1:]
|
||||
bodyEnd := strings.Index(remaining, "\n\n[")
|
||||
var body, rest string
|
||||
if bodyEnd < 0 {
|
||||
body = remaining
|
||||
rest = ""
|
||||
} else {
|
||||
body = remaining[:bodyEnd]
|
||||
rest = remaining[bodyEnd:]
|
||||
}
|
||||
if runeLen(body) > maxRunesPerBody {
|
||||
body = truncateRunesWithNotice(body, maxRunesPerBody)
|
||||
changed = true
|
||||
}
|
||||
out.WriteString(header)
|
||||
out.WriteString(body)
|
||||
remaining = rest
|
||||
if rest == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return s
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func truncateTextByTokens(b *Builder, text string, maxTokens int) string {
|
||||
if maxTokens <= 0 || text == "" {
|
||||
return ""
|
||||
}
|
||||
if b.countTokens(text) <= maxTokens {
|
||||
return text
|
||||
}
|
||||
markerTok := b.countTokens(attackChainTruncationMarker)
|
||||
usable := maxTokens - markerTok
|
||||
if usable < 256 {
|
||||
usable = maxTokens / 2
|
||||
}
|
||||
headBudget := usable * 60 / 100
|
||||
tailBudget := usable - headBudget
|
||||
head := takeTokensFromStart(b, text, headBudget)
|
||||
tail := takeTokensFromEnd(b, text, tailBudget)
|
||||
return head + attackChainTruncationMarker + tail
|
||||
}
|
||||
|
||||
func takeTokensFromStart(b *Builder, text string, maxTokens int) string {
|
||||
rs := []rune(text)
|
||||
if len(rs) == 0 || maxTokens <= 0 {
|
||||
return ""
|
||||
}
|
||||
lo, hi := 0, len(rs)
|
||||
for lo < hi {
|
||||
mid := (lo + hi + 1) / 2
|
||||
if b.countTokens(string(rs[:mid])) <= maxTokens {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return string(rs[:lo])
|
||||
}
|
||||
|
||||
func takeTokensFromEnd(b *Builder, text string, maxTokens int) string {
|
||||
rs := []rune(text)
|
||||
if len(rs) == 0 || maxTokens <= 0 {
|
||||
return ""
|
||||
}
|
||||
lo, hi := 0, len(rs)
|
||||
for lo < hi {
|
||||
mid := (lo + hi) / 2
|
||||
if b.countTokens(string(rs[mid:])) <= maxTokens {
|
||||
hi = mid
|
||||
} else {
|
||||
lo = mid + 1
|
||||
}
|
||||
}
|
||||
return string(rs[lo:])
|
||||
}
|
||||
|
||||
func truncateRunesWithNotice(s string, maxRunes int) string {
|
||||
rs := []rune(s)
|
||||
if len(rs) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
const notice = "\n...[工具输出已截断 / tool output truncated]...\n"
|
||||
noticeRunes := []rune(notice)
|
||||
keep := maxRunes - len(noticeRunes)
|
||||
if keep < 200 {
|
||||
keep = maxRunes * 2 / 3
|
||||
}
|
||||
if keep < 1 {
|
||||
return notice
|
||||
}
|
||||
head := keep * 70 / 100
|
||||
tail := keep - head
|
||||
return string(rs[:head]) + notice + string(rs[len(rs)-tail:])
|
||||
}
|
||||
|
||||
func runeLen(s string) int {
|
||||
return len([]rune(s))
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package attackchain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func testBuilder(maxTotal int) *Builder {
|
||||
return &Builder{
|
||||
logger: zap.NewNop(),
|
||||
openAIConfig: &config.OpenAIConfig{Model: "gpt-4"},
|
||||
tokenCounter: agent.NewTikTokenCounter(),
|
||||
maxTokens: maxTotal,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactFormattedToolBodies(t *testing.T) {
|
||||
long := strings.Repeat("x", 20000)
|
||||
in := "[user]: hi\n\n[tool] (tool_call_id: abc):\n" + long + "\n\n[assistant]: done\n"
|
||||
out := compactFormattedToolBodies(in, 500)
|
||||
if strings.Contains(out, strings.Repeat("x", 10000)) {
|
||||
t.Fatal("expected tool body to be truncated")
|
||||
}
|
||||
if !strings.Contains(out, "[user]: hi") {
|
||||
t.Fatal("expected user header preserved")
|
||||
}
|
||||
if !strings.Contains(out, "[assistant]: done") {
|
||||
t.Fatal("expected assistant header preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitAttackChainPayloadWithinBudget(t *testing.T) {
|
||||
b := testBuilder(32000)
|
||||
react := strings.Repeat("scan ", 50000)
|
||||
model := strings.Repeat("result ", 10000)
|
||||
r, m, truncated := b.fitAttackChainPayload(react, model)
|
||||
if !truncated {
|
||||
t.Fatal("expected truncation for large payload")
|
||||
}
|
||||
prompt := b.buildSimplePrompt(r, m)
|
||||
total := b.countTokens(prompt) + attackChainMaxCompletionTokens(b.maxTokens) + attackChainSystemReserve
|
||||
if total > b.maxTokens+attackChainSafetyReserve {
|
||||
t.Fatalf("prompt still too large: estimated %d > max %d", total, b.maxTokens)
|
||||
}
|
||||
_ = m
|
||||
}
|
||||
|
||||
func TestAttackChainMaxCompletionTokens(t *testing.T) {
|
||||
if got := attackChainMaxCompletionTokens(120000); got != 15000 && got != 16384 {
|
||||
// 120000/8 = 15000
|
||||
if got < 4096 || got > 16384 {
|
||||
t.Fatalf("unexpected completion cap: %d", got)
|
||||
}
|
||||
}
|
||||
if got := attackChainMaxCompletionTokens(0); got != 8192 {
|
||||
t.Fatalf("expected default 8192, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -239,13 +239,15 @@ func (m *Manager) StartListener(id string) (*database.C2Listener, error) {
|
||||
}
|
||||
cfg.ApplyDefaults()
|
||||
|
||||
// 通过工厂创建具体实现
|
||||
// 通过工厂创建具体实现。必须使用 rec 的副本:HTTP handler 在返回 JSON 前会清空
|
||||
// rec.ImplantToken / EncryptionKey 做脱敏,若 listener 实现持有同一指针会导致 beacon 鉴权永久失败。
|
||||
listenerRec := *rec
|
||||
factory := m.registry.Get(rec.Type)
|
||||
if factory == nil {
|
||||
return nil, ErrUnsupportedType
|
||||
}
|
||||
inst, err := factory(ListenerCreationCtx{
|
||||
Listener: rec,
|
||||
Listener: &listenerRec,
|
||||
Config: cfg,
|
||||
Manager: m,
|
||||
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)
|
||||
}
|
||||
@@ -395,14 +395,27 @@ type MultiAgentAPIUpdate struct {
|
||||
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
||||
}
|
||||
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink 等)
|
||||
type RobotsConfig struct {
|
||||
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
|
||||
Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定)
|
||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
|
||||
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
||||
}
|
||||
|
||||
// RobotWechatConfig 微信 iLink 机器人配置(个人微信 ClawBot / iLink 协议)
|
||||
type RobotWechatConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
BotToken string `yaml:"bot_token,omitempty" json:"bot_token,omitempty"`
|
||||
ILinkBotID string `yaml:"ilink_bot_id,omitempty" json:"ilink_bot_id,omitempty"`
|
||||
ILinkUserID string `yaml:"ilink_user_id,omitempty" json:"ilink_user_id,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://ilinkai.weixin.qq.com
|
||||
BotType string `yaml:"bot_type,omitempty" json:"bot_type,omitempty"` // get_bot_qrcode 参数,默认 3
|
||||
BotAgent string `yaml:"bot_agent,omitempty" json:"bot_agent,omitempty"` // base_info.bot_agent
|
||||
GetUpdatesBuf string `yaml:"get_updates_buf,omitempty" json:"get_updates_buf,omitempty"` // 长轮询游标(运行时)
|
||||
}
|
||||
|
||||
// RobotSessionConfig 机器人会话隔离策略
|
||||
type RobotSessionConfig struct {
|
||||
StrictUserIdentity *bool `yaml:"strict_user_identity,omitempty" json:"strict_user_identity,omitempty"` // true 时只允许真实用户标识,不允许会话/群 ID 兜底
|
||||
@@ -444,8 +457,17 @@ type RobotLarkConfig struct {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
Host string `yaml:"host" json:"host"`
|
||||
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 {
|
||||
|
||||
@@ -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 消息
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoningContent,omitempty"`
|
||||
@@ -117,6 +117,7 @@ func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conve
|
||||
}
|
||||
for i := range conv.Messages {
|
||||
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
||||
details = DedupeConsecutiveProcessDetails(details)
|
||||
detailsJSON := make([]map[string]interface{}, len(details))
|
||||
for j, detail := range details {
|
||||
var data interface{}
|
||||
@@ -235,6 +236,7 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
// 将过程详情附加到对应的消息上
|
||||
for i := range conv.Messages {
|
||||
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
||||
details = DedupeConsecutiveProcessDetails(details)
|
||||
// 将ProcessDetail转换为JSON格式,以便前端使用
|
||||
detailsJSON := make([]map[string]interface{}, len(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)
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/robfig/cron/v3"
|
||||
@@ -1158,7 +1159,16 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
return
|
||||
}
|
||||
if eventType == "response_delta" {
|
||||
respPlan.b.WriteString(message)
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
if acc, okAcc := dataMap[openai.SSEAccumulatedKey].(string); okAcc {
|
||||
respPlan.b.Reset()
|
||||
respPlan.b.WriteString(acc)
|
||||
} else {
|
||||
respPlan.b.WriteString(message)
|
||||
}
|
||||
} else {
|
||||
respPlan.b.WriteString(message)
|
||||
}
|
||||
if dataMap, ok := data.(map[string]interface{}); ok && respPlan.meta == nil {
|
||||
respPlan.meta = make(map[string]interface{}, len(dataMap))
|
||||
for k, v := range dataMap {
|
||||
@@ -1213,8 +1223,12 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
} else if tb.persistAs == "" {
|
||||
tb.persistAs = persistAs
|
||||
}
|
||||
// delta 片段直接拼接
|
||||
tb.b.WriteString(message)
|
||||
if acc, okAcc := dataMap[openai.SSEAccumulatedKey].(string); okAcc {
|
||||
tb.b.Reset()
|
||||
tb.b.WriteString(acc)
|
||||
} else {
|
||||
tb.b.WriteString(message)
|
||||
}
|
||||
// 有时 delta 先到 start 未到,补充元信息
|
||||
for k, v := range dataMap {
|
||||
tb.meta[k] = v
|
||||
|
||||
@@ -206,6 +206,25 @@ func (h *ConfigHandler) SetRobotRestarter(restarter RobotRestarter) {
|
||||
h.robotRestarter = restarter
|
||||
}
|
||||
|
||||
// ApplyWechatRobotBinding 微信 iLink 扫码绑定成功后写入配置并重启机器人连接
|
||||
func (h *ConfigHandler) ApplyWechatRobotBinding(wc config.RobotWechatConfig) error {
|
||||
h.mu.Lock()
|
||||
wc.Enabled = true
|
||||
h.config.Robots.Wechat = wc
|
||||
h.mu.Unlock()
|
||||
if err := h.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.robotRestarter != nil {
|
||||
h.robotRestarter.RestartRobotConnections()
|
||||
}
|
||||
h.logger.Info("微信机器人绑定已保存",
|
||||
zap.String("ilink_bot_id", wc.ILinkBotID),
|
||||
zap.Bool("enabled", wc.Enabled),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfigResponse 获取配置响应
|
||||
type GetConfigResponse struct {
|
||||
OpenAI config.OpenAIConfig `json:"openai"`
|
||||
@@ -735,6 +754,7 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
if req.Robots != nil {
|
||||
h.config.Robots = *req.Robots
|
||||
h.logger.Info("更新机器人配置",
|
||||
zap.Bool("wechat_enabled", h.config.Robots.Wechat.Enabled),
|
||||
zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled),
|
||||
zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled),
|
||||
zap.Bool("lark_enabled", h.config.Robots.Lark.Enabled),
|
||||
@@ -1481,6 +1501,15 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
setBoolInMap(sessionNode, "strict_user_identity", *cfg.Session.StrictUserIdentity)
|
||||
}
|
||||
|
||||
wechatNode := ensureMap(robotsNode, "wechat")
|
||||
setBoolInMap(wechatNode, "enabled", cfg.Wechat.Enabled)
|
||||
setStringInMap(wechatNode, "bot_token", cfg.Wechat.BotToken)
|
||||
setStringInMap(wechatNode, "ilink_bot_id", cfg.Wechat.ILinkBotID)
|
||||
setStringInMap(wechatNode, "ilink_user_id", cfg.Wechat.ILinkUserID)
|
||||
setStringInMap(wechatNode, "base_url", cfg.Wechat.BaseURL)
|
||||
setStringInMap(wechatNode, "bot_type", cfg.Wechat.BotType)
|
||||
setStringInMap(wechatNode, "bot_agent", cfg.Wechat.BotAgent)
|
||||
|
||||
wecomNode := ensureMap(robotsNode, "wecom")
|
||||
setBoolInMap(wecomNode, "enabled", cfg.Wecom.Enabled)
|
||||
setStringInMap(wecomNode, "token", cfg.Wecom.Token)
|
||||
|
||||
@@ -117,6 +117,8 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
details = database.DedupeConsecutiveProcessDetails(details)
|
||||
|
||||
// 转换为前端期望的 JSON 结构(与 GetConversation 中 processDetails 结构一致)
|
||||
out := make([]map[string]interface{}, 0, len(details))
|
||||
for _, d := range details {
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/robot/ilink"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const wechatLoginTTL = 5 * time.Minute
|
||||
|
||||
// WechatConfigSaver 绑定成功后写入配置并重启机器人连接
|
||||
type WechatConfigSaver interface {
|
||||
ApplyWechatRobotBinding(cfg config.RobotWechatConfig) error
|
||||
}
|
||||
|
||||
type wechatLoginSession struct {
|
||||
QRCode string
|
||||
QRCodeImgURL string
|
||||
PendingVerify string
|
||||
CurrentBaseURL string
|
||||
StartedAt time.Time
|
||||
}
|
||||
|
||||
// WechatRobotHandler 微信 iLink 机器人(扫码绑定 + 配置)
|
||||
type WechatRobotHandler struct {
|
||||
config *config.Config
|
||||
configSaver WechatConfigSaver
|
||||
logger *zap.Logger
|
||||
mu sync.Mutex
|
||||
logins map[string]*wechatLoginSession
|
||||
}
|
||||
|
||||
// NewWechatRobotHandler 创建微信机器人处理器
|
||||
func NewWechatRobotHandler(cfg *config.Config, saver WechatConfigSaver, logger *zap.Logger) *WechatRobotHandler {
|
||||
return &WechatRobotHandler{
|
||||
config: cfg,
|
||||
configSaver: saver,
|
||||
logger: logger,
|
||||
logins: make(map[string]*wechatLoginSession),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WechatRobotHandler) purgeExpiredLogins() {
|
||||
now := time.Now()
|
||||
for k, v := range h.logins {
|
||||
if now.Sub(v.StartedAt) > wechatLoginTTL {
|
||||
delete(h.logins, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WechatRobotHandler) ilinkClient(baseURL string) *ilink.Client {
|
||||
ver := h.config.Version
|
||||
if ver == "" {
|
||||
ver = "1.0.0"
|
||||
}
|
||||
ver = strings.TrimPrefix(strings.TrimSpace(ver), "v")
|
||||
ver = strings.TrimPrefix(ver, "V")
|
||||
wc := h.config.Robots.Wechat
|
||||
return ilink.NewClient(baseURL, wc.BotToken, wc.BotAgent, ilink.BuildClientVersion(ver))
|
||||
}
|
||||
|
||||
// HandleWechatQRCode POST /api/robot/wechat/qrcode — 生成绑定二维码
|
||||
func (h *WechatRobotHandler) HandleWechatQRCode(c *gin.Context) {
|
||||
h.mu.Lock()
|
||||
h.purgeExpiredLogins()
|
||||
h.mu.Unlock()
|
||||
|
||||
var req struct {
|
||||
BotType string `json:"bot_type"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
botType := req.BotType
|
||||
if botType == "" {
|
||||
botType = h.config.Robots.Wechat.BotType
|
||||
}
|
||||
if botType == "" {
|
||||
botType = ilink.DefaultBotType
|
||||
}
|
||||
baseURL := h.config.Robots.Wechat.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = ilink.DefaultBaseURL
|
||||
}
|
||||
|
||||
var localTokens []string
|
||||
if t := h.config.Robots.Wechat.BotToken; t != "" {
|
||||
localTokens = []string{t}
|
||||
}
|
||||
|
||||
client := h.ilinkClient(baseURL)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
qr, err := client.GetBotQRCode(ctx, botType, localTokens)
|
||||
if err != nil {
|
||||
h.logger.Warn("获取微信二维码失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "获取二维码失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if qr.QRCode == "" || qr.QRCodeImgContent == "" {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "微信服务器未返回有效二维码"})
|
||||
return
|
||||
}
|
||||
|
||||
sessionKey := uuid.New().String()
|
||||
h.mu.Lock()
|
||||
h.logins[sessionKey] = &wechatLoginSession{
|
||||
QRCode: qr.QRCode,
|
||||
QRCodeImgURL: qr.QRCodeImgContent,
|
||||
CurrentBaseURL: baseURL,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
resp := gin.H{
|
||||
"session_key": sessionKey,
|
||||
"qrcode": qr.QRCode,
|
||||
"qrcode_open_url": qr.QRCodeImgContent,
|
||||
"message": "请使用微信扫描二维码并确认绑定",
|
||||
}
|
||||
if dataURL, err := ilink.QRCodeDataURL(qr.QRCodeImgContent, 256); err != nil {
|
||||
h.logger.Warn("生成二维码图片失败", zap.Error(err))
|
||||
} else {
|
||||
resp["qrcode_image_data_url"] = dataURL
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HandleWechatQRCodeStatus GET /api/robot/wechat/qrcode/status — 轮询扫码状态
|
||||
func (h *WechatRobotHandler) HandleWechatQRCodeStatus(c *gin.Context) {
|
||||
sessionKey := c.Query("session_key")
|
||||
verifyCode := c.Query("verify_code")
|
||||
if sessionKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 session_key"})
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
sess, ok := h.logins[sessionKey]
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "登录会话不存在或已过期,请重新生成二维码"})
|
||||
return
|
||||
}
|
||||
if time.Since(sess.StartedAt) > wechatLoginTTL {
|
||||
h.mu.Lock()
|
||||
delete(h.logins, sessionKey)
|
||||
h.mu.Unlock()
|
||||
c.JSON(http.StatusGone, gin.H{"error": "二维码已过期,请重新生成"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := sess.CurrentBaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = ilink.DefaultBaseURL
|
||||
}
|
||||
vc := verifyCode
|
||||
if vc == "" {
|
||||
vc = sess.PendingVerify
|
||||
}
|
||||
|
||||
client := h.ilinkClient(baseURL)
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 40*time.Second)
|
||||
defer cancel()
|
||||
|
||||
st, err := client.GetQRCodeStatus(ctx, sess.QRCode, vc)
|
||||
if err != nil {
|
||||
h.logger.Warn("轮询微信二维码状态失败", zap.Error(err))
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
switch st.Status {
|
||||
case "wait", "scaned":
|
||||
c.JSON(http.StatusOK, gin.H{"status": st.Status})
|
||||
return
|
||||
case "need_verifycode":
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": st.Status,
|
||||
"message": "请在手机微信查看配对数字,并在下方输入",
|
||||
})
|
||||
return
|
||||
case "scaned_but_redirect":
|
||||
if st.RedirectHost != "" {
|
||||
h.mu.Lock()
|
||||
if s, ok := h.logins[sessionKey]; ok {
|
||||
s.CurrentBaseURL = "https://" + st.RedirectHost
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": st.Status})
|
||||
return
|
||||
case "binded_redirect":
|
||||
h.mu.Lock()
|
||||
delete(h.logins, sessionKey)
|
||||
h.mu.Unlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": st.Status,
|
||||
"already_connected": true,
|
||||
"message": "该微信已绑定过,无需重复绑定",
|
||||
})
|
||||
return
|
||||
case "confirmed":
|
||||
if st.BotToken == "" || st.ILinkBotID == "" {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "绑定确认成功但缺少 bot_token"})
|
||||
return
|
||||
}
|
||||
saveBase := st.BaseURL
|
||||
if saveBase == "" {
|
||||
saveBase = baseURL
|
||||
}
|
||||
wc := h.config.Robots.Wechat
|
||||
wc.Enabled = true
|
||||
wc.BotToken = st.BotToken
|
||||
wc.ILinkBotID = st.ILinkBotID
|
||||
wc.ILinkUserID = st.ILinkUserID
|
||||
wc.BaseURL = saveBase
|
||||
if wc.BotType == "" {
|
||||
wc.BotType = ilink.DefaultBotType
|
||||
}
|
||||
if wc.BotAgent == "" {
|
||||
wc.BotAgent = ilink.DefaultBotAgent
|
||||
}
|
||||
if h.configSaver != nil {
|
||||
if err := h.configSaver.ApplyWechatRobotBinding(wc); err != nil {
|
||||
h.logger.Warn("保存微信机器人配置失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
h.config.Robots.Wechat = wc
|
||||
}
|
||||
h.mu.Lock()
|
||||
delete(h.logins, sessionKey)
|
||||
h.mu.Unlock()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "confirmed",
|
||||
"message": "绑定成功,微信机器人已启用",
|
||||
"ilink_bot_id": st.ILinkBotID,
|
||||
"ilink_user_id": st.ILinkUserID,
|
||||
})
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusOK, gin.H{"status": st.Status})
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWechatVerifyCode POST /api/robot/wechat/qrcode/verify — 提交手机配对数字
|
||||
func (h *WechatRobotHandler) HandleWechatVerifyCode(c *gin.Context) {
|
||||
var req struct {
|
||||
SessionKey string `json:"session_key"`
|
||||
VerifyCode string `json:"verify_code"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.SessionKey == "" || req.VerifyCode == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "需要 session_key 与 verify_code"})
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
sess, ok := h.logins[req.SessionKey]
|
||||
if ok {
|
||||
sess.PendingVerify = req.VerifyCode
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "登录会话不存在或已过期"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已提交配对码,请继续等待绑定"})
|
||||
}
|
||||
|
||||
// HandleWechatStatus GET /api/robot/wechat/status — 当前绑定状态(供前端展示)
|
||||
func (h *WechatRobotHandler) HandleWechatStatus(c *gin.Context) {
|
||||
wc := h.config.Robots.Wechat
|
||||
bound := wc.BotToken != "" && wc.ILinkBotID != ""
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"enabled": wc.Enabled,
|
||||
"bound": bound,
|
||||
"ilink_bot_id": wc.ILinkBotID,
|
||||
"ilink_user_id": wc.ILinkUserID,
|
||||
"base_url": wc.BaseURL,
|
||||
})
|
||||
}
|
||||
@@ -177,6 +177,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
var einoMainRound int
|
||||
var einoLastAgent string
|
||||
subAgentToolStep := make(map[string]int)
|
||||
// mainAgentToolStep:主代理每次工具调用批次递增,供 UI 显示「第 N 轮」(单代理无子代理切换时原先会一直停在第 1 轮)。
|
||||
mainAgentToolStep := make(map[string]int)
|
||||
pendingByID := make(map[string]toolCallPendingInfo)
|
||||
pendingQueueByAgent := make(map[string][]string)
|
||||
markPending := func(tc toolCallPendingInfo) {
|
||||
@@ -529,8 +531,10 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
}
|
||||
if streamsMainAssistant(ev.AgentName) {
|
||||
mainIterKey := einoMainIterationKey(iterEinoAgent, orchestratorName)
|
||||
if einoMainRound == 0 {
|
||||
einoMainRound = 1
|
||||
mainAgentToolStep[mainIterKey] = 1
|
||||
progress("iteration", "", map[string]interface{}{
|
||||
"iteration": 1,
|
||||
"einoScope": "main",
|
||||
@@ -540,17 +544,26 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
} else if einoLastAgent != "" && !streamsMainAssistant(einoLastAgent) {
|
||||
einoMainRound++
|
||||
progress("iteration", "", map[string]interface{}{
|
||||
"iteration": einoMainRound,
|
||||
"einoScope": "main",
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": iterEinoAgent,
|
||||
"orchestration": orchMode,
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
} else if einoLastAgent != "" {
|
||||
needBump := false
|
||||
if !streamsMainAssistant(einoLastAgent) {
|
||||
needBump = true // 子代理 → 主代理
|
||||
} else if einoLastAgent != ev.AgentName {
|
||||
needBump = true // plan_execute:planner ↔ executor 等主代理切换
|
||||
}
|
||||
if needBump {
|
||||
einoMainRound++
|
||||
mainAgentToolStep[mainIterKey] = einoMainRound
|
||||
progress("iteration", "", map[string]interface{}{
|
||||
"iteration": einoMainRound,
|
||||
"einoScope": "main",
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": iterEinoAgent,
|
||||
"orchestration": orchMode,
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
einoLastAgent = ev.AgentName
|
||||
@@ -644,9 +657,9 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
}
|
||||
progress("reasoning_chain_stream_delta", displayDelta, map[string]interface{}{
|
||||
progress("reasoning_chain_stream_delta", displayDelta, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"streamId": reasoningStreamID,
|
||||
})
|
||||
}, fullDisplay))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -676,13 +689,13 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
})
|
||||
streamHeaderSent = true
|
||||
}
|
||||
progress("response_delta", contentDelta, map[string]interface{}{
|
||||
progress("response_delta", contentDelta, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
}, mainAssistantBuf))
|
||||
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
|
||||
}
|
||||
}
|
||||
@@ -701,10 +714,10 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
progress("eino_agent_reply_stream_delta", subDelta, map[string]interface{}{
|
||||
progress("eino_agent_reply_stream_delta", subDelta, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"streamId": subReplyStreamID,
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
}, subAssistantBuf))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -743,13 +756,13 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
}
|
||||
progress("response_delta", eofTail, map[string]interface{}{
|
||||
progress("response_delta", eofTail, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
}, mainAssistantBuf))
|
||||
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
|
||||
}
|
||||
}
|
||||
@@ -791,7 +804,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
||||
lastToolChunk = mergeMessageToolCalls(&schema.Message{ToolCalls: merged})
|
||||
}
|
||||
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
|
||||
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, orchMode, progress, toolEmitSeen, subAgentToolStep, mainAgentToolStep, markPending)
|
||||
// 流式路径此前只把 tool_calls 推给进度 UI,未写入 runAccumulatedMsgs;落库后 loadHistory→RepairOrphan 会删掉全部 tool 结果,表现为「续跑/下轮失忆」。
|
||||
if lastToolChunk != nil && len(lastToolChunk.ToolCalls) > 0 {
|
||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage("", lastToolChunk.ToolCalls))
|
||||
@@ -820,7 +833,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
continue
|
||||
}
|
||||
runAccumulatedMsgs = append(runAccumulatedMsgs, msg)
|
||||
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
|
||||
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, orchMode, progress, toolEmitSeen, subAgentToolStep, mainAgentToolStep, markPending)
|
||||
|
||||
if mv.Role == schema.Assistant {
|
||||
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||
@@ -859,13 +872,13 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
progress("response_delta", body, map[string]interface{}{
|
||||
progress("response_delta", body, openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": snapshotMCPIDs(),
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": ev.AgentName,
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
}, body))
|
||||
}
|
||||
lastAssistant = body
|
||||
if orchMode == "plan_execute" && strings.EqualFold(strings.TrimSpace(ev.AgentName), "executor") {
|
||||
|
||||
@@ -737,12 +737,23 @@ func toolCallsRichSignature(msg *schema.Message) string {
|
||||
return base + "|" + strings.Join(parts, ";")
|
||||
}
|
||||
|
||||
func einoMainIterationKey(agentName, orchestratorName string) string {
|
||||
key := strings.TrimSpace(agentName)
|
||||
if key == "" {
|
||||
key = strings.TrimSpace(orchestratorName)
|
||||
}
|
||||
if key == "" {
|
||||
return "_main"
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func tryEmitToolCallsOnce(
|
||||
msg *schema.Message,
|
||||
agentName, orchestratorName, conversationID string,
|
||||
agentName, orchestratorName, conversationID, orchMode string,
|
||||
progress func(string, string, interface{}),
|
||||
seen map[string]struct{},
|
||||
subAgentToolStep map[string]int,
|
||||
subAgentToolStep, mainAgentToolStep map[string]int,
|
||||
markPending func(toolCallPendingInfo),
|
||||
) {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil || seen == nil {
|
||||
@@ -756,14 +767,14 @@ func tryEmitToolCallsOnce(
|
||||
return
|
||||
}
|
||||
seen[sig] = struct{}{}
|
||||
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, progress, subAgentToolStep, markPending)
|
||||
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, orchMode, progress, subAgentToolStep, mainAgentToolStep, markPending)
|
||||
}
|
||||
|
||||
func emitToolCallsFromMessage(
|
||||
msg *schema.Message,
|
||||
agentName, orchestratorName, conversationID string,
|
||||
agentName, orchestratorName, conversationID, orchMode string,
|
||||
progress func(string, string, interface{}),
|
||||
subAgentToolStep map[string]int,
|
||||
subAgentToolStep, mainAgentToolStep map[string]int,
|
||||
markPending func(toolCallPendingInfo),
|
||||
) {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil {
|
||||
@@ -784,6 +795,22 @@ func emitToolCallsFromMessage(
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
} else if mainAgentToolStep != nil {
|
||||
key := einoMainIterationKey(agentName, orchestratorName)
|
||||
mainAgentToolStep[key]++
|
||||
n := mainAgentToolStep[key]
|
||||
// 第 1 轮已在主代理进入时发出;此后每次工具批次对应新一轮 ReAct(与子代理按工具计步一致)。
|
||||
if n > 1 {
|
||||
progress("iteration", "", map[string]interface{}{
|
||||
"iteration": n,
|
||||
"einoScope": "main",
|
||||
"einoRole": "orchestrator",
|
||||
"einoAgent": agentName,
|
||||
"orchestration": orchMode,
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
}
|
||||
role := "orchestrator"
|
||||
if isSubToolRound {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package openai
|
||||
|
||||
// SSEAccumulatedKey 为 SSE progress 事件 data 中的服务端权威流式全文快照字段。
|
||||
// 前端应优先用该字段更新 buffer,避免对 delta 二次 normalize 导致叠字。
|
||||
const SSEAccumulatedKey = "accumulated"
|
||||
|
||||
// WithSSEAccumulated 在 progress data 中附带当前流式累计全文(权威快照)。
|
||||
func WithSSEAccumulated(data map[string]interface{}, accumulated string) map[string]interface{} {
|
||||
if data == nil {
|
||||
data = make(map[string]interface{}, 1)
|
||||
}
|
||||
data[SSEAccumulatedKey] = accumulated
|
||||
return data
|
||||
}
|
||||
|
||||
// NormalizeStreamingDelta 将可能是“累计片段/重发片段”的内容归一化为“纯增量”。
|
||||
// 与 unexported normalizeStreamingDelta 相同,供 agent / multiagent 等包在发 SSE 前累计正文。
|
||||
func NormalizeStreamingDelta(current, incoming string) (next, delta string) {
|
||||
return normalizeStreamingDelta(current, incoming)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
DefaultBotType = "3"
|
||||
DefaultBotAgent = "CyberStrikeAI/1.0"
|
||||
ILinkAppID = "bot"
|
||||
QRLongPollTimeout = 35 * time.Second
|
||||
APIDefaultTimeout = 15 * time.Second
|
||||
GetUpdatesTimeout = 35 * time.Second
|
||||
)
|
||||
|
||||
// Client 微信 iLink Bot HTTP 客户端(与 @tencent-weixin/openclaw-weixin 协议兼容)
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
BotToken string
|
||||
BotAgent string
|
||||
ClientVersion uint32
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL, botToken, botAgent string, clientVersion uint32) *Client {
|
||||
base := strings.TrimSpace(baseURL)
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
agent := strings.TrimSpace(botAgent)
|
||||
if agent == "" {
|
||||
agent = DefaultBotAgent
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(base, "/"),
|
||||
BotToken: strings.TrimSpace(botToken),
|
||||
BotAgent: sanitizeBotAgent(agent),
|
||||
ClientVersion: clientVersion,
|
||||
HTTP: &http.Client{Timeout: 0},
|
||||
}
|
||||
}
|
||||
|
||||
// BuildClientVersion 将 semver 编码为 iLink-App-ClientVersion(0x00MMNNPP)
|
||||
func BuildClientVersion(version string) uint32 {
|
||||
parts := strings.Split(version, ".")
|
||||
parse := func(i int) int {
|
||||
if i >= len(parts) {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(parts[i]))
|
||||
if n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
major := parse(0) & 0xff
|
||||
minor := parse(1) & 0xff
|
||||
patch := parse(2) & 0xff
|
||||
return uint32((major << 16) | (minor << 8) | patch)
|
||||
}
|
||||
|
||||
type baseInfo struct {
|
||||
ChannelVersion string `json:"channel_version"`
|
||||
BotAgent string `json:"bot_agent"`
|
||||
}
|
||||
|
||||
func (c *Client) buildBaseInfo() baseInfo {
|
||||
return baseInfo{
|
||||
ChannelVersion: "1.0.0",
|
||||
BotAgent: c.BotAgent,
|
||||
}
|
||||
}
|
||||
|
||||
func randomWechatUIN() string {
|
||||
var b [4]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
u := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||
return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(u), 10)))
|
||||
}
|
||||
|
||||
func (c *Client) commonHeaders() http.Header {
|
||||
h := http.Header{}
|
||||
h.Set("iLink-App-Id", ILinkAppID)
|
||||
h.Set("iLink-App-ClientVersion", strconv.FormatUint(uint64(c.ClientVersion), 10))
|
||||
return h
|
||||
}
|
||||
|
||||
func (c *Client) authHeaders() http.Header {
|
||||
h := c.commonHeaders()
|
||||
h.Set("Content-Type", "application/json")
|
||||
h.Set("AuthorizationType", "ilink_bot_token")
|
||||
h.Set("X-WECHAT-UIN", randomWechatUIN())
|
||||
if c.BotToken != "" {
|
||||
h.Set("Authorization", "Bearer "+c.BotToken)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (c *Client) endpointURL(path string) (string, error) {
|
||||
u, err := url.Parse(c.BaseURL + "/")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ref, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.ResolveReference(ref).String(), nil
|
||||
}
|
||||
|
||||
func (c *Client) doRequest(ctx context.Context, method, path string, body []byte, headers http.Header, timeout time.Duration) ([]byte, error) {
|
||||
reqURL, err := c.endpointURL(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bodyReader io.Reader
|
||||
if len(body) > 0 {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, vs := range headers {
|
||||
for _, v := range vs {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
client := c.HTTP
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if timeout > 0 {
|
||||
ctx2, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx2)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("ilink %s %s: %d %s", method, path, resp.StatusCode, string(raw))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// QRCodeResponse 获取二维码响应
|
||||
type QRCodeResponse struct {
|
||||
QRCode string `json:"qrcode"`
|
||||
QRCodeImgContent string `json:"qrcode_img_content"`
|
||||
}
|
||||
|
||||
// GetBotQRCode 获取绑定二维码
|
||||
func (c *Client) GetBotQRCode(ctx context.Context, botType string, localTokenList []string) (*QRCodeResponse, error) {
|
||||
if strings.TrimSpace(botType) == "" {
|
||||
botType = DefaultBotType
|
||||
}
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"local_token_list": localTokenList,
|
||||
})
|
||||
path := "ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(botType)
|
||||
raw, err := c.doRequest(ctx, http.MethodPost, path, body, c.authHeaders(), APIDefaultTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out QRCodeResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// QRStatusResponse 二维码状态轮询响应
|
||||
type QRStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
BotToken string `json:"bot_token"`
|
||||
ILinkBotID string `json:"ilink_bot_id"`
|
||||
ILinkUserID string `json:"ilink_user_id"`
|
||||
BaseURL string `json:"baseurl"`
|
||||
RedirectHost string `json:"redirect_host"`
|
||||
}
|
||||
|
||||
// GetQRCodeStatus 长轮询二维码扫码状态
|
||||
func (c *Client) GetQRCodeStatus(ctx context.Context, qrcode, verifyCode string) (*QRStatusResponse, error) {
|
||||
path := "ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
|
||||
if verifyCode != "" {
|
||||
path += "&verify_code=" + url.QueryEscape(verifyCode)
|
||||
}
|
||||
raw, err := c.doRequest(ctx, http.MethodGet, path, nil, c.commonHeaders(), QRLongPollTimeout)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return &QRStatusResponse{Status: "wait"}, nil
|
||||
}
|
||||
return &QRStatusResponse{Status: "wait"}, nil
|
||||
}
|
||||
var out QRStatusResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// MessageItem 消息内容项
|
||||
type MessageItem struct {
|
||||
Type int `json:"type"`
|
||||
TextItem *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"text_item,omitempty"`
|
||||
}
|
||||
|
||||
// WeixinMessage 入站消息
|
||||
type WeixinMessage struct {
|
||||
FromUserID string `json:"from_user_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
MessageState int `json:"message_state"`
|
||||
ItemList []MessageItem `json:"item_list"`
|
||||
ContextToken string `json:"context_token"`
|
||||
}
|
||||
|
||||
// GetUpdatesResponse 长轮询消息响应
|
||||
type GetUpdatesResponse struct {
|
||||
Ret int `json:"ret"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
Msgs []WeixinMessage `json:"msgs"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
LongPollingTimeoutMs int `json:"longpolling_timeout_ms"`
|
||||
}
|
||||
|
||||
// GetUpdates 长轮询获取新消息
|
||||
func (c *Client) GetUpdates(ctx context.Context, getUpdatesBuf string) (*GetUpdatesResponse, error) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"get_updates_buf": getUpdatesBuf,
|
||||
"base_info": c.buildBaseInfo(),
|
||||
})
|
||||
raw, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/getupdates", body, c.authHeaders(), GetUpdatesTimeout)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil
|
||||
}
|
||||
return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil
|
||||
}
|
||||
var out GetUpdatesResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SendTextMessage 发送文本回复
|
||||
func (c *Client) SendTextMessage(ctx context.Context, toUserID, contextToken, text, clientID string) error {
|
||||
if clientID == "" {
|
||||
clientID = randomClientID()
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"msg": map[string]interface{}{
|
||||
"to_user_id": toUserID,
|
||||
"client_id": clientID,
|
||||
"message_type": 2,
|
||||
"message_state": 2,
|
||||
"context_token": contextToken,
|
||||
"item_list": []map[string]interface{}{
|
||||
{"type": 1, "text_item": map[string]string{"text": text}},
|
||||
},
|
||||
},
|
||||
"base_info": c.buildBaseInfo(),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
_, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/sendmessage", body, c.authHeaders(), APIDefaultTimeout)
|
||||
return err
|
||||
}
|
||||
|
||||
func randomClientID() string {
|
||||
var b [8]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
func sanitizeBotAgent(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return DefaultBotAgent
|
||||
}
|
||||
if len(raw) > 256 {
|
||||
return raw[:256]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// ExtractText 从消息中提取首条文本
|
||||
func ExtractText(msg WeixinMessage) string {
|
||||
for _, item := range msg.ItemList {
|
||||
if item.Type == 1 && item.TextItem != nil {
|
||||
return strings.TrimSpace(item.TextItem.Text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ilink
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// QRCodeDataURL 将扫码内容(一般为 liteapp 链接)编码为 PNG data URL,供 Web 端展示。
|
||||
// qrcode_img_content 不是图片直链,不能用作 <img src>。
|
||||
func QRCodeDataURL(content string, size int) (string, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("empty qr content")
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 256
|
||||
}
|
||||
png, err := qrcode.Encode(content, qrcode.Medium, size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/robot/ilink"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
wechatReconnectInitial = 5 * time.Second
|
||||
wechatReconnectMax = 60 * time.Second
|
||||
wechatPlatform = "wechat"
|
||||
)
|
||||
|
||||
// StartWechat 启动微信 iLink 长轮询(无需公网回调),收到消息后调用 handler 并回复。
|
||||
func StartWechat(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, appVersion string, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Wechat
|
||||
if !cfg.Enabled || cfg.BotToken == "" {
|
||||
return
|
||||
}
|
||||
go runWechatLoop(ctx, cfg, h, appVersion, logger)
|
||||
}
|
||||
|
||||
func runWechatLoop(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) {
|
||||
backoff := wechatReconnectInitial
|
||||
for {
|
||||
err := runWechatPoll(ctx, cfg, h, appVersion, logger)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("微信 iLink 长轮询已按配置关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("微信 iLink 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
if backoff < wechatReconnectMax {
|
||||
backoff *= 2
|
||||
if backoff > wechatReconnectMax {
|
||||
backoff = wechatReconnectMax
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runWechatPoll(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) error {
|
||||
client := ilink.NewClient(cfg.BaseURL, cfg.BotToken, cfg.BotAgent, ilink.BuildClientVersion(appVersion))
|
||||
buf := cfg.GetUpdatesBuf
|
||||
logger.Info("微信 iLink 长轮询已启动", zap.String("ilink_bot_id", cfg.ILinkBotID))
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
resp, err := client.GetUpdates(ctx, buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.ErrCode != 0 && resp.Ret != 0 {
|
||||
logger.Warn("微信 getUpdates 返回错误", zap.Int("errcode", resp.ErrCode), zap.String("errmsg", resp.ErrMsg))
|
||||
}
|
||||
if resp.GetUpdatesBuf != "" {
|
||||
buf = resp.GetUpdatesBuf
|
||||
}
|
||||
for _, msg := range resp.Msgs {
|
||||
if msg.MessageType != 1 {
|
||||
continue
|
||||
}
|
||||
text := ilink.ExtractText(msg)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
userID := strings.TrimSpace(msg.FromUserID)
|
||||
if userID == "" {
|
||||
continue
|
||||
}
|
||||
logger.Info("微信收到消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(wechatPlatform, userID, text)
|
||||
if strings.TrimSpace(reply) == "" {
|
||||
continue
|
||||
}
|
||||
if err := client.SendTextMessage(ctx, userID, msg.ContextToken, reply, ""); err != nil {
|
||||
logger.Warn("微信发送回复失败", zap.String("to", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### What it does
|
||||
|
||||
- Configure **Host / Port / Password** and choose **Single-Agent** or **Multi-Agent**
|
||||
- Configure **Host / Port / HTTPS / Password** and choose an agent mode
|
||||
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`)
|
||||
- Right-click any HTTP message in Burp and send it to CyberStrikeAI for **streaming web pentest**
|
||||
- Keep a **test history sidebar** (searchable) so you can revisit previous runs
|
||||
@@ -63,6 +63,7 @@ If you already have Gradle available, you can still use `build.gradle` to build.
|
||||
|
||||
### Notes
|
||||
|
||||
- This extension connects to your CyberStrikeAI server (default is `http://127.0.0.1:8080`).
|
||||
- Default connection is `https://127.0.0.1:8080` (**HTTPS** checked). Self-signed / local certs are trusted automatically (no import).
|
||||
- Uncheck **HTTPS** only if your server runs plain HTTP.
|
||||
- It uses **Bearer Token** authentication obtained from the configured password.
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||
2) 填写:
|
||||
- **Host**:例如 `127.0.0.1`
|
||||
- **Port**:例如 `8080`
|
||||
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `config.yaml` 的 `auth.password`)
|
||||
- **HTTPS**:默认勾选(对接 `config.yaml` 中 `tls_enabled` / 自签证书);插件会自动信任本地自签证书,无需导入
|
||||
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `auth.password`)
|
||||
- **Agent mode**:选择 `Single Agent` 或 `Multi Agent`
|
||||
3) 点击 **Validate**
|
||||
- 成功:状态显示 `OK (token saved)`
|
||||
@@ -94,8 +95,9 @@ cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||
|
||||
- **Validate 失败 / 401**
|
||||
- 确认密码是否正确(服务端 `auth.password`)
|
||||
- 确认 IP/端口是否能访问(例如浏览器能打开 `http://IP:PORT/`)
|
||||
- 若服务器启用了反向代理/HTTPS,需要把插件里 baseUrl 改成对应协议与端口(当前插件默认使用 `http://`)
|
||||
- 确认 IP/端口是否能访问(例如浏览器能打开 `https://IP:PORT/`)
|
||||
- 服务端启用 TLS 时勾选 **HTTPS**(默认已勾选);自签证书无需手动导入
|
||||
- 若仍为纯 HTTP 部署,取消勾选 **HTTPS**
|
||||
|
||||
- **选择 Multi Agent 后提示“多代理未启用”**
|
||||
- 服务端需要开启:`config.yaml` 中 `multi_agent.enabled: true`
|
||||
|
||||
BIN
Binary file not shown.
+52
-11
@@ -73,15 +73,34 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
public void onEvent(String type, String message, String rawJson) {
|
||||
if (type == null) type = "";
|
||||
switch (type) {
|
||||
case "response_start":
|
||||
tab.appendProgressToRun(runId, "\n\n[主回复]\n");
|
||||
break;
|
||||
case "response_delta":
|
||||
case "eino_agent_reply_stream_delta":
|
||||
tab.appendFinalToRun(runId, message);
|
||||
if (message != null && !message.isEmpty()) {
|
||||
tab.appendFinalToRun(runId, message);
|
||||
}
|
||||
break;
|
||||
case "response":
|
||||
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
|
||||
tab.appendFinalToRun(runId, message);
|
||||
tab.setFinalResponse(runId, message);
|
||||
break;
|
||||
case "eino_agent_reply_stream_start":
|
||||
tab.appendProgressToRun(runId, "\n\n[子代理回复]\n");
|
||||
break;
|
||||
case "eino_agent_reply_stream_delta":
|
||||
if (message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, message);
|
||||
}
|
||||
break;
|
||||
case "eino_agent_reply_stream_end":
|
||||
tab.appendProgressToRun(runId, "\n");
|
||||
break;
|
||||
case "eino_agent_reply":
|
||||
if (message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, "\n\n[子代理回复]\n" + message + "\n");
|
||||
}
|
||||
break;
|
||||
case "progress":
|
||||
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
|
||||
tab.setRunStatus(runId, "running");
|
||||
@@ -94,21 +113,40 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, "error");
|
||||
break;
|
||||
case "reasoning_chain_stream_start":
|
||||
tab.appendProgressToRun(runId, "\n\n[推理过程]\n");
|
||||
break;
|
||||
case "reasoning_chain_stream_delta":
|
||||
if (message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, message);
|
||||
}
|
||||
break;
|
||||
case "reasoning_chain_stream_end":
|
||||
tab.appendProgressToRun(runId, "\n");
|
||||
break;
|
||||
case "reasoning_chain":
|
||||
if (message != null && !message.isEmpty()) {
|
||||
String streamId = rawJson != null ? SimpleJson.extractStringField(rawJson, "streamId") : "";
|
||||
if (streamId == null || streamId.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, "\n\n[推理过程]\n" + message + "\n");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "thinking_stream_start":
|
||||
if (tab.isShowDebugEvents()) {
|
||||
tab.resetThinkingStream(runId);
|
||||
}
|
||||
break;
|
||||
case "thinking_stream_delta":
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, message);
|
||||
}
|
||||
break;
|
||||
case "tool_call":
|
||||
case "tool_result":
|
||||
case "tool_result_delta":
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
if ("thinking_stream_delta".equals(type)) {
|
||||
tab.appendThinkingDelta(runId, message);
|
||||
} else {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
break;
|
||||
case "conversation":
|
||||
@@ -125,7 +163,9 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
case "done":
|
||||
break;
|
||||
default:
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()
|
||||
&& !type.endsWith("_stream_delta") && !type.endsWith("_stream_start")
|
||||
&& !type.endsWith("_stream_end")) {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
break;
|
||||
@@ -134,8 +174,9 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
|
||||
@Override
|
||||
public void onError(String message, Exception e) {
|
||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, "error");
|
||||
boolean cancelled = message != null && message.toLowerCase().contains("cancel");
|
||||
tab.appendProgressToRun(runId, cancelled ? "\n[info] " + message + "\n" : "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, cancelled ? "cancelled" : "error");
|
||||
callbacks.printError("CyberStrikeAI stream error: " + message);
|
||||
if (e != null) {
|
||||
callbacks.printError(e.toString());
|
||||
|
||||
+127
-11
@@ -2,17 +2,29 @@ package burp;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
final class CyberStrikeAIClient {
|
||||
|
||||
private static final int AUTH_CONNECT_TIMEOUT_MS = 4_000;
|
||||
private static final int AUTH_READ_TIMEOUT_MS = 5_000;
|
||||
/** login + validate 整段上限,避免两次读超时叠加拖到半分钟 */
|
||||
private static final int AUTH_OVERALL_TIMEOUT_MS = 10_000;
|
||||
private static final int DEFAULT_READ_TIMEOUT_MS = 15_000;
|
||||
|
||||
private final AtomicReference<HttpURLConnection> activeConnection = new AtomicReference<>();
|
||||
private final AtomicReference<Thread> activeThread = new AtomicReference<>();
|
||||
|
||||
static final class Config {
|
||||
final String baseUrl; // e.g. http://127.0.0.1:8080
|
||||
final String password;
|
||||
@@ -49,15 +61,97 @@ final class CyberStrikeAIClient {
|
||||
void onDone();
|
||||
}
|
||||
|
||||
boolean hasActiveRequest() {
|
||||
return activeConnection.get() != null;
|
||||
}
|
||||
|
||||
void cancelActiveRequest() {
|
||||
HttpURLConnection conn = activeConnection.getAndSet(null);
|
||||
if (conn != null) {
|
||||
try {
|
||||
conn.disconnect();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
Thread t = activeThread.getAndSet(null);
|
||||
if (t != null) {
|
||||
t.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
String loginAndValidate(Config cfg) throws IOException {
|
||||
String token = login(cfg.baseUrl, cfg.password);
|
||||
validate(cfg.baseUrl, token);
|
||||
return token;
|
||||
Thread worker = Thread.currentThread();
|
||||
java.util.Timer deadline = new java.util.Timer("CyberStrikeAI-AuthDeadline", true);
|
||||
deadline.schedule(new java.util.TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
worker.interrupt();
|
||||
cancelActiveRequest();
|
||||
}
|
||||
}, AUTH_OVERALL_TIMEOUT_MS);
|
||||
try {
|
||||
String token = login(cfg.baseUrl, cfg.password);
|
||||
if (Thread.interrupted()) {
|
||||
throw timeoutIOException();
|
||||
}
|
||||
validate(cfg.baseUrl, token);
|
||||
if (Thread.interrupted()) {
|
||||
throw timeoutIOException();
|
||||
}
|
||||
return token;
|
||||
} catch (SocketTimeoutException e) {
|
||||
throw timeoutIOException();
|
||||
} finally {
|
||||
deadline.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException timeoutIOException() {
|
||||
return new IOException("Connection timed out (~" + (AUTH_OVERALL_TIMEOUT_MS / 1000)
|
||||
+ "s). Check host/port and HTTPS checkbox.");
|
||||
}
|
||||
|
||||
private void trackConnection(HttpURLConnection conn) {
|
||||
activeThread.set(Thread.currentThread());
|
||||
activeConnection.set(conn);
|
||||
}
|
||||
|
||||
private void releaseConnection(HttpURLConnection conn) {
|
||||
if (activeConnection.compareAndSet(conn, null)) {
|
||||
activeThread.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCancelled(Throwable e) {
|
||||
if (e == null) {
|
||||
return Thread.currentThread().isInterrupted();
|
||||
}
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
return true;
|
||||
}
|
||||
if (e instanceof InterruptedIOException) {
|
||||
return true;
|
||||
}
|
||||
if (e instanceof SocketTimeoutException) {
|
||||
return false;
|
||||
}
|
||||
Throwable cause = e.getCause();
|
||||
if (cause != null && cause != e) {
|
||||
return isCancelled(cause);
|
||||
}
|
||||
String msg = e.getMessage();
|
||||
return msg != null && (
|
||||
msg.toLowerCase().contains("cancel")
|
||||
|| msg.toLowerCase().contains("abort")
|
||||
|| msg.toLowerCase().contains("closed")
|
||||
);
|
||||
}
|
||||
|
||||
private String login(String baseUrl, String password) throws IOException {
|
||||
URL url = new URL(baseUrl + "/api/auth/login");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||
trackConnection(conn);
|
||||
try {
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
@@ -92,11 +186,16 @@ final class CyberStrikeAIClient {
|
||||
throw new IOException("Login response missing token. Check backend address and credentials.");
|
||||
}
|
||||
return token;
|
||||
} finally {
|
||||
releaseConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(String baseUrl, String token) throws IOException {
|
||||
URL url = new URL(baseUrl + "/api/auth/validate");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||
trackConnection(conn);
|
||||
try {
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||
int code = conn.getResponseCode();
|
||||
@@ -104,6 +203,9 @@ final class CyberStrikeAIClient {
|
||||
if (code < 200 || code >= 300) {
|
||||
throw new IOException("Validate failed (" + code + "): " + resp);
|
||||
}
|
||||
} finally {
|
||||
releaseConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
void streamTest(Config cfg, String token, String message, StreamListener listener) {
|
||||
@@ -117,11 +219,12 @@ final class CyberStrikeAIClient {
|
||||
payload.put("orchestration", cfg.agentMode.orchestration);
|
||||
}
|
||||
|
||||
new Thread(() -> {
|
||||
Thread worker = new Thread(() -> {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, 0);
|
||||
trackConnection(conn);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
@@ -142,6 +245,9 @@ final class CyberStrikeAIClient {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
break;
|
||||
}
|
||||
// SSE format: "data: {json}"
|
||||
if (line.startsWith("data:")) {
|
||||
String json = line.substring("data:".length()).trim();
|
||||
@@ -156,15 +262,25 @@ final class CyberStrikeAIClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
listener.onDone();
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
listener.onError("Cancelled.", null);
|
||||
} else {
|
||||
listener.onDone();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
listener.onError(e.getMessage(), e);
|
||||
if (isCancelled(e)) {
|
||||
listener.onError("Cancelled.", e);
|
||||
} else {
|
||||
listener.onError(e.getMessage(), e);
|
||||
}
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
releaseConnection(conn);
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
}, "CyberStrikeAI-Stream").start();
|
||||
}, "CyberStrikeAI-Stream");
|
||||
worker.start();
|
||||
}
|
||||
|
||||
void cancelByConversationId(String baseUrl, String token, String conversationId) throws IOException {
|
||||
@@ -172,7 +288,7 @@ final class CyberStrikeAIClient {
|
||||
throw new IOException("Missing conversationId.");
|
||||
}
|
||||
URL url = new URL(baseUrl + "/api/agent-loop/cancel");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
|
||||
+130
-34
@@ -14,6 +14,7 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
private final JTextField hostField = new JTextField("127.0.0.1");
|
||||
private final JTextField portField = new JTextField("8080");
|
||||
private final JCheckBox useHttpsBox = new JCheckBox("HTTPS", true);
|
||||
private final JPasswordField passwordField = new JPasswordField();
|
||||
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{
|
||||
"Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor"
|
||||
@@ -29,6 +30,10 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
private final JTextArea progressArea = new JTextArea();
|
||||
private final JTextArea finalRawArea = new JTextArea(); // raw final stream / final response
|
||||
private JScrollPane progressScrollPane;
|
||||
private JScrollPane finalRawScrollPane;
|
||||
/** 距底部在此像素内视为「跟随滚动」,否则用户上拉阅读时不抢滚动条 */
|
||||
private static final int SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
||||
private final JEditorPane markdownPane = new JEditorPane("text/html", "");
|
||||
private final CardLayout outputCardsLayout = new CardLayout();
|
||||
private final JPanel outputCards = new JPanel(outputCardsLayout);
|
||||
@@ -41,6 +46,7 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
||||
private final AtomicReference<String> tokenRef = new AtomicReference<>("");
|
||||
private final AtomicReference<Thread> validateThreadRef = new AtomicReference<>();
|
||||
|
||||
private final DefaultListModel<TestRun> testListModel = new DefaultListModel<>();
|
||||
private final JList<TestRun> testList = new JList<>(testListModel);
|
||||
@@ -107,6 +113,8 @@ final class CyberStrikeAITab implements ITab {
|
||||
row1.add(hostField);
|
||||
row1.add(new JLabel("Port"));
|
||||
row1.add(portField);
|
||||
useHttpsBox.setToolTipText("Use https:// for CyberStrikeAI (self-signed certs are trusted automatically)");
|
||||
row1.add(useHttpsBox);
|
||||
row1.add(new JLabel("Password"));
|
||||
row1.add(passwordField);
|
||||
row1.add(validateButton);
|
||||
@@ -186,15 +194,22 @@ final class CyberStrikeAITab implements ITab {
|
||||
configureTextArea(requestArea, false);
|
||||
configureTextArea(responseArea, false);
|
||||
|
||||
outputCards.add(new JScrollPane(finalRawArea), "raw");
|
||||
finalRawScrollPane = new JScrollPane(finalRawArea);
|
||||
finalRawScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
finalRawScrollPane.getVerticalScrollBar().setUnitIncrement(16);
|
||||
outputCards.add(finalRawScrollPane, "raw");
|
||||
outputCards.add(new JScrollPane(markdownPane), "md");
|
||||
|
||||
outputRoot.add(buildOutputHeader(), BorderLayout.NORTH);
|
||||
outputRoot.add(buildOutputBody(), BorderLayout.CENTER);
|
||||
|
||||
rightTabs.addTab("Output", outputRoot);
|
||||
rightTabs.addTab("Request", new JScrollPane(requestArea));
|
||||
rightTabs.addTab("Response", new JScrollPane(responseArea));
|
||||
JScrollPane requestScroll = new JScrollPane(requestArea);
|
||||
requestScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
rightTabs.addTab("Request", requestScroll);
|
||||
JScrollPane responseScroll = new JScrollPane(responseArea);
|
||||
responseScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
rightTabs.addTab("Response", responseScroll);
|
||||
return rightTabs;
|
||||
}
|
||||
|
||||
@@ -210,12 +225,13 @@ final class CyberStrikeAITab implements ITab {
|
||||
}
|
||||
|
||||
private JComponent buildOutputBody() {
|
||||
JScrollPane progressScroll = new JScrollPane(progressArea);
|
||||
progressScroll.setBorder(BorderFactory.createTitledBorder("Progress"));
|
||||
progressScroll.getVerticalScrollBar().setUnitIncrement(16);
|
||||
progressScrollPane = new JScrollPane(progressArea);
|
||||
progressScrollPane.setBorder(BorderFactory.createTitledBorder("Progress"));
|
||||
progressScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
progressScrollPane.getVerticalScrollBar().setUnitIncrement(16);
|
||||
|
||||
JPanel empty = new JPanel();
|
||||
progressContainer.add(progressScroll, "show");
|
||||
progressContainer.add(progressScrollPane, "show");
|
||||
progressContainer.add(empty, "hide");
|
||||
((CardLayout) progressContainer.getLayout()).show(progressContainer, "show");
|
||||
|
||||
@@ -259,10 +275,27 @@ final class CyberStrikeAITab implements ITab {
|
||||
return split;
|
||||
}
|
||||
|
||||
private static boolean isScrollNearBottom(JScrollPane scrollPane) {
|
||||
if (scrollPane == null) {
|
||||
return true;
|
||||
}
|
||||
JScrollBar bar = scrollPane.getVerticalScrollBar();
|
||||
int max = Math.max(0, bar.getMaximum() - bar.getVisibleAmount());
|
||||
return bar.getValue() >= max - SCROLL_FOLLOW_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
private static void scrollPaneToBottom(JScrollPane scrollPane) {
|
||||
if (scrollPane == null) {
|
||||
return;
|
||||
}
|
||||
JScrollBar bar = scrollPane.getVerticalScrollBar();
|
||||
bar.setValue(bar.getMaximum());
|
||||
}
|
||||
|
||||
private static void configureTextArea(JTextArea area, boolean monospaced) {
|
||||
area.setEditable(false);
|
||||
area.setLineWrap(false);
|
||||
area.setWrapStyleWord(false);
|
||||
area.setLineWrap(true);
|
||||
area.setWrapStyleWord(true);
|
||||
if (monospaced) {
|
||||
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
|
||||
} else {
|
||||
@@ -381,24 +414,44 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
private void wireActions() {
|
||||
validateButton.addActionListener(e -> {
|
||||
validateButton.setEnabled(false);
|
||||
if ("Cancel".equals(validateButton.getText())) {
|
||||
cancelValidateInProgress();
|
||||
return;
|
||||
}
|
||||
validateButton.setText("Cancel");
|
||||
validateButton.setEnabled(true);
|
||||
stopButton.setEnabled(true);
|
||||
statusLabel.setText("Validating...");
|
||||
log("Validating connection...");
|
||||
new Thread(() -> {
|
||||
log("Validating connection... (max ~10s; click Cancel or Stop to abort)");
|
||||
Thread worker = new Thread(() -> {
|
||||
try {
|
||||
CyberStrikeAIClient.Config cfg = currentConfig();
|
||||
String token = client.loginAndValidate(cfg);
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
return;
|
||||
}
|
||||
tokenRef.set(token);
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText("OK (token saved)"));
|
||||
log("Validation OK.");
|
||||
} catch (Exception ex) {
|
||||
tokenRef.set("");
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
|
||||
log("Validation failed: " + ex.getMessage());
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText("Cancelled"));
|
||||
log("Validation cancelled.");
|
||||
} else {
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
|
||||
log("Validation failed: " + ex.getMessage());
|
||||
}
|
||||
} finally {
|
||||
SwingUtilities.invokeLater(() -> validateButton.setEnabled(true));
|
||||
validateThreadRef.set(null);
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
validateButton.setText("Validate");
|
||||
validateButton.setEnabled(true);
|
||||
});
|
||||
}
|
||||
}, "CyberStrikeAI-Validate").start();
|
||||
}, "CyberStrikeAI-Validate");
|
||||
validateThreadRef.set(worker);
|
||||
worker.start();
|
||||
});
|
||||
|
||||
clearButton.addActionListener(e -> {
|
||||
@@ -435,10 +488,23 @@ final class CyberStrikeAITab implements ITab {
|
||||
});
|
||||
|
||||
stopButton.addActionListener(e -> {
|
||||
if ("Cancel".equals(validateButton.getText())) {
|
||||
cancelValidateInProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
String runId = selectedRunId;
|
||||
if (runId != null && client.hasActiveRequest()) {
|
||||
client.cancelActiveRequest();
|
||||
appendProgressToRun(runId, "\n[info] Stream stopped.\n");
|
||||
setRunStatus(runId, "cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (runId == null) return;
|
||||
TestRun run = runs.get(runId);
|
||||
if (run == null) return;
|
||||
|
||||
String token = getToken();
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
appendProgressToRun(runId, "\n[error] Not validated.\n");
|
||||
@@ -483,7 +549,8 @@ final class CyberStrikeAITab implements ITab {
|
||||
String host = hostField.getText().trim();
|
||||
String port = portField.getText().trim();
|
||||
String password = new String(passwordField.getPassword());
|
||||
String baseUrl = "http://" + host + ":" + port;
|
||||
String scheme = useHttpsBox.isSelected() ? "https" : "http";
|
||||
String baseUrl = scheme + "://" + host + ":" + port;
|
||||
int idx = agentModeBox.getSelectedIndex();
|
||||
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
|
||||
? AGENT_MODES[idx]
|
||||
@@ -567,10 +634,31 @@ final class CyberStrikeAITab implements ITab {
|
||||
run.progressBuffer.append(s);
|
||||
}
|
||||
if (runId.equals(selectedRunId)) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
progressArea.append(s);
|
||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||
});
|
||||
SwingUtilities.invokeLater(() -> appendProgressUi(s, false));
|
||||
}
|
||||
}
|
||||
|
||||
private void appendProgressUi(String s, boolean forceFollow) {
|
||||
JScrollBar bar = progressScrollPane != null ? progressScrollPane.getVerticalScrollBar() : null;
|
||||
int scrollBefore = bar != null ? bar.getValue() : 0;
|
||||
boolean follow = forceFollow || isScrollNearBottom(progressScrollPane);
|
||||
progressArea.append(s);
|
||||
if (follow) {
|
||||
scrollPaneToBottom(progressScrollPane);
|
||||
} else if (bar != null) {
|
||||
bar.setValue(scrollBefore);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendFinalUi(String s, boolean forceFollow) {
|
||||
JScrollBar bar = finalRawScrollPane != null ? finalRawScrollPane.getVerticalScrollBar() : null;
|
||||
int scrollBefore = bar != null ? bar.getValue() : 0;
|
||||
boolean follow = forceFollow || isScrollNearBottom(finalRawScrollPane);
|
||||
finalRawArea.append(s);
|
||||
if (follow) {
|
||||
scrollPaneToBottom(finalRawScrollPane);
|
||||
} else if (bar != null) {
|
||||
bar.setValue(scrollBefore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,10 +708,7 @@ final class CyberStrikeAITab implements ITab {
|
||||
run.finalBuffer.append(s);
|
||||
}
|
||||
if (runId.equals(selectedRunId)) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
finalRawArea.append(s);
|
||||
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
|
||||
});
|
||||
SwingUtilities.invokeLater(() -> appendFinalUi(s, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,9 +741,9 @@ final class CyberStrikeAITab implements ITab {
|
||||
}
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
progressArea.setText(progress);
|
||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||
scrollPaneToBottom(progressScrollPane);
|
||||
finalRawArea.setText(fin);
|
||||
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
|
||||
scrollPaneToBottom(finalRawScrollPane);
|
||||
requestArea.setText(run.requestRaw == null ? "" : run.requestRaw);
|
||||
responseArea.setText(run.responseRaw == null ? "" : run.responseRaw);
|
||||
refreshOutputView();
|
||||
@@ -682,25 +767,36 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
void clearAndShowStreamHeader(String title) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
progressArea.setText("");
|
||||
finalRawArea.setText(title + "\n\n");
|
||||
progressArea.setText("[*] " + title + "\n\n");
|
||||
finalRawArea.setText("");
|
||||
markdownPane.setText("");
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy helpers kept for Validate logging
|
||||
void appendStreamLine(String s) {
|
||||
if (s == null) return;
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
progressArea.append(s);
|
||||
progressArea.append("\n");
|
||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||
});
|
||||
SwingUtilities.invokeLater(() -> appendProgressUi(s + "\n", false));
|
||||
}
|
||||
|
||||
private void log(String s) {
|
||||
appendStreamLine("[*] " + s);
|
||||
}
|
||||
|
||||
private void cancelValidateInProgress() {
|
||||
client.cancelActiveRequest();
|
||||
Thread t = validateThreadRef.getAndSet(null);
|
||||
if (t != null) {
|
||||
t.interrupt();
|
||||
}
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
statusLabel.setText("Cancelled");
|
||||
validateButton.setText("Validate");
|
||||
validateButton.setEnabled(true);
|
||||
});
|
||||
log("Validation cancelled.");
|
||||
}
|
||||
|
||||
private void applyFilter() {
|
||||
String q = searchField.getText();
|
||||
if (q == null) q = "";
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package burp;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.URL;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
/**
|
||||
* Opens HTTPS connections without validating server certificates (self-signed / local dev).
|
||||
* Applied per-connection only; does not change JVM-wide defaults for other Burp components.
|
||||
*/
|
||||
final class SslTrustAll {
|
||||
|
||||
private static volatile SSLSocketFactory socketFactory;
|
||||
private static final HostnameVerifier TRUST_ALL_HOSTS = (hostname, session) -> true;
|
||||
|
||||
private SslTrustAll() {
|
||||
}
|
||||
|
||||
static HttpURLConnection open(URL url) throws IOException {
|
||||
return open(url, 5_000, 30_000);
|
||||
}
|
||||
|
||||
static HttpURLConnection open(URL url, int connectTimeoutMs, int readTimeoutMs) throws IOException {
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(connectTimeoutMs);
|
||||
conn.setReadTimeout(readTimeoutMs);
|
||||
if (conn instanceof HttpsURLConnection) {
|
||||
HttpsURLConnection https = (HttpsURLConnection) conn;
|
||||
https.setSSLSocketFactory(new TimeoutSslSocketFactory(socketFactory(), connectTimeoutMs, readTimeoutMs));
|
||||
https.setHostnameVerifier(TRUST_ALL_HOSTS);
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
private static SSLSocketFactory socketFactory() {
|
||||
SSLSocketFactory sf = socketFactory;
|
||||
if (sf != null) {
|
||||
return sf;
|
||||
}
|
||||
synchronized (SslTrustAll.class) {
|
||||
sf = socketFactory;
|
||||
if (sf != null) {
|
||||
return sf;
|
||||
}
|
||||
try {
|
||||
TrustManager[] trustAll = new TrustManager[]{
|
||||
new X509TrustManager() {
|
||||
@Override
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) {
|
||||
}
|
||||
}
|
||||
};
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(null, trustAll, new java.security.SecureRandom());
|
||||
sf = ctx.getSocketFactory();
|
||||
socketFactory = sf;
|
||||
return sf;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to initialize trust-all TLS", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensures TCP connect + socket read respect timeouts (plain HttpURLConnection SSL can hang longer). */
|
||||
private static final class TimeoutSslSocketFactory extends SSLSocketFactory {
|
||||
private final SSLSocketFactory delegate;
|
||||
private final int connectTimeoutMs;
|
||||
private final int readTimeoutMs;
|
||||
|
||||
TimeoutSslSocketFactory(SSLSocketFactory delegate, int connectTimeoutMs, int readTimeoutMs) {
|
||||
this.delegate = delegate;
|
||||
this.connectTimeoutMs = connectTimeoutMs;
|
||||
this.readTimeoutMs = readTimeoutMs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getDefaultCipherSuites() {
|
||||
return delegate.getDefaultCipherSuites();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getSupportedCipherSuites() {
|
||||
return delegate.getSupportedCipherSuites();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket() throws IOException {
|
||||
return tune(delegate.createSocket());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
|
||||
return tune(delegate.createSocket(s, host, port, autoClose));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException {
|
||||
Socket plain = new Socket();
|
||||
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||
return tune(delegate.createSocket(plain, host, port, true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port, java.net.InetAddress localHost, int localPort) throws IOException {
|
||||
Socket plain = new Socket();
|
||||
plain.bind(new InetSocketAddress(localHost, localPort));
|
||||
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||
return tune(delegate.createSocket(plain, host, port, true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(java.net.InetAddress host, int port) throws IOException {
|
||||
Socket plain = new Socket();
|
||||
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||
return tune(delegate.createSocket(plain, host.getHostName(), port, true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(java.net.InetAddress address, int port, java.net.InetAddress localAddress, int localPort) throws IOException {
|
||||
Socket plain = new Socket();
|
||||
plain.bind(new InetSocketAddress(localAddress, localPort));
|
||||
plain.connect(new InetSocketAddress(address, port), connectTimeoutMs);
|
||||
return tune(delegate.createSocket(plain, address.getHostName(), port, true));
|
||||
}
|
||||
|
||||
private Socket tune(Socket socket) throws IOException {
|
||||
socket.setSoTimeout(readTimeoutMs);
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+4
@@ -1,12 +1,16 @@
|
||||
burp/SslTrustAll.class
|
||||
burp/SslTrustAll$TimeoutSslSocketFactory.class
|
||||
burp/CyberStrikeAIClient$StreamListener.class
|
||||
burp/CyberStrikeAIClient$Config.class
|
||||
burp/CyberStrikeAIClient$AgentMode.class
|
||||
burp/MarkdownRenderer.class
|
||||
burp/SimpleJson.class
|
||||
burp/CyberStrikeAIClient.class
|
||||
burp/CyberStrikeAIClient$1.class
|
||||
burp/CyberStrikeAITab$DotIcon.class
|
||||
burp/CyberStrikeAITab.class
|
||||
burp/CyberStrikeAITab$1.class
|
||||
burp/SslTrustAll$1.class
|
||||
burp/BurpExtender$1.class
|
||||
burp/BurpExtender.class
|
||||
burp/CyberStrikeAITab$TestRun.class
|
||||
|
||||
+1
@@ -4,3 +4,4 @@
|
||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/HttpMessageFormatter.java
|
||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/MarkdownRenderer.java
|
||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SimpleJson.java
|
||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SslTrustAll.java
|
||||
|
||||
@@ -64,6 +64,7 @@ show_progress() {
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " CyberStrikeAI 一键部署启动脚本"
|
||||
echo " (默认 HTTPS 自签证书;纯 HTTP 请用: $0 --http)"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
@@ -353,7 +354,18 @@ need_rebuild() {
|
||||
}
|
||||
|
||||
# 主流程
|
||||
# 默认启动主站 HTTPS(--https 传给二进制);传 --http 则走明文 HTTP。
|
||||
main() {
|
||||
USE_HTTPS=1
|
||||
FORWARD_ARGS=()
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--http" ]; then
|
||||
USE_HTTPS=0
|
||||
continue
|
||||
fi
|
||||
FORWARD_ARGS+=("$arg")
|
||||
done
|
||||
|
||||
# 环境检查
|
||||
info "检查运行环境..."
|
||||
check_python
|
||||
@@ -377,13 +389,30 @@ main() {
|
||||
# 启动服务器
|
||||
success "所有准备工作完成!"
|
||||
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 ""
|
||||
|
||||
# 运行服务器
|
||||
exec "./$BINARY_NAME"
|
||||
|
||||
# 始终传入项目根目录下的 config.yaml,避免 cwd 不在项目根时找不到配置;额外参数仍可追加(如再次 -config 覆盖,以 Go flag 后写为准)。
|
||||
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
|
||||
}
|
||||
|
||||
# 执行主流程
|
||||
main
|
||||
# 执行主流程(支持参数,如: ./run.sh --http)
|
||||
main "$@"
|
||||
|
||||
@@ -440,6 +440,230 @@ args:
|
||||
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():
|
||||
parser = argparse.ArgumentParser(description="Pure Python HTTP testing helper powered by httpx")
|
||||
parser.add_argument("--url", required=True)
|
||||
@@ -466,6 +690,16 @@ args:
|
||||
parser.add_argument("--debug", dest="debug", action="store_true")
|
||||
parser.add_argument("--response-encoding", dest="response_encoding", 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(
|
||||
include_headers=False,
|
||||
auto_encode_url=False,
|
||||
@@ -475,9 +709,22 @@ args:
|
||||
show_command=False,
|
||||
show_summary=False,
|
||||
debug=False,
|
||||
response_filter_invert=False,
|
||||
response_filter_ignore_case=False,
|
||||
)
|
||||
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)
|
||||
try:
|
||||
delay_between = float(args.delay or "0")
|
||||
@@ -648,9 +895,37 @@ args:
|
||||
for key, value in response.headers.items():
|
||||
print(f"{key}: {value}")
|
||||
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:
|
||||
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:
|
||||
print("[no body]")
|
||||
|
||||
@@ -729,6 +1004,13 @@ description: |
|
||||
- 连接探针:在无代理场景下额外进行 DNS/TCP/TLS 探测,粗粒度复刻 curl -w 指标
|
||||
- 可重复观测:repeat/delay + TTFB/total/speed_download 统计,便于盲注/时序测试
|
||||
- 扩展开关: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:
|
||||
- name: "url"
|
||||
type: "string"
|
||||
@@ -836,6 +1118,56 @@ parameters:
|
||||
description: "强制响应解码使用的编码(如GBK),覆盖自动探测"
|
||||
required: false
|
||||
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"
|
||||
type: "string"
|
||||
description: "保留字段:标识调用意图(request, spider等),脚本内部不使用"
|
||||
|
||||
+34
-3
@@ -260,8 +260,14 @@
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 420px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.c2-actions > button {
|
||||
flex: 1;
|
||||
min-width: min(100%, 160px);
|
||||
}
|
||||
/* ============================================================================
|
||||
Listener Cards
|
||||
============================================================================ */
|
||||
@@ -851,10 +857,35 @@
|
||||
background: var(--c2-surface);
|
||||
border-radius: var(--c2-radius);
|
||||
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 {
|
||||
text-align: left;
|
||||
@@ -1255,7 +1286,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: 10050;
|
||||
padding: 24px;
|
||||
animation: c2-fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
+2021
-52
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,7 @@
|
||||
"settings": "System settings",
|
||||
"hitl": "Human-in-the-loop",
|
||||
"c2": "C2",
|
||||
"c2Manage": "C2 management",
|
||||
"c2Listeners": "Listeners",
|
||||
"c2Sessions": "Sessions",
|
||||
"c2Tasks": "Tasks",
|
||||
@@ -146,6 +147,7 @@
|
||||
"active": "Active",
|
||||
"highFreq": "High frequency",
|
||||
"noCallData": "No call data",
|
||||
"severityClickHint": "Click to view",
|
||||
"lastUpdated": "Last updated",
|
||||
"viewAll": "View all →",
|
||||
"recentVulns": "Recent vulnerabilities",
|
||||
@@ -834,7 +836,32 @@
|
||||
},
|
||||
"robots": {
|
||||
"title": "Bot settings",
|
||||
"description": "Configure WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
|
||||
"description": "Configure WeChat (iLink), WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
|
||||
"wechat": {
|
||||
"title": "WeChat / iLink",
|
||||
"subtitle": "Bind personal WeChat via QR code and chat with CyberStrikeAI on your phone",
|
||||
"statusIdle": "Not bound",
|
||||
"statusBound": "Connected",
|
||||
"statusScanning": "Binding…",
|
||||
"step1": "Generate QR",
|
||||
"step2": "Scan in WeChat",
|
||||
"step3": "Confirm",
|
||||
"enabled": "Enable WeChat bot",
|
||||
"bindButton": "Generate QR code and bind",
|
||||
"bindHint": "Scan with WeChat to confirm; settings are saved automatically.",
|
||||
"qrLoading": "Generating QR code…",
|
||||
"verifyCodeLabel": "Code on your phone (only if WeChat asks)",
|
||||
"rebindButton": "Re-bind",
|
||||
"boundBotId": "Bound Bot ID: ",
|
||||
"verifyCodeSubmit": "Submit",
|
||||
"advanced": "Advanced settings",
|
||||
"baseUrl": "API Base URL",
|
||||
"botType": "Bot Type",
|
||||
"botAgent": "Bot Agent",
|
||||
"ilinkBotId": "iLink Bot ID (filled after bind)",
|
||||
"boundSuccess": "Binding successful. WeChat bot is enabled.",
|
||||
"openLink": "QR not showing? Open link in WeChat on your phone"
|
||||
},
|
||||
"wecom": {
|
||||
"title": "WeCom",
|
||||
"enabled": "Enable WeCom bot",
|
||||
@@ -1304,6 +1331,35 @@
|
||||
"noCallsYet": "No calls yet",
|
||||
"unknownTool": "Unknown tool",
|
||||
"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",
|
||||
"columnStatus": "Status",
|
||||
"columnStartTime": "Start time",
|
||||
@@ -1484,6 +1540,11 @@
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"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",
|
||||
"clear": "Clear",
|
||||
"vulnId": "Vuln ID",
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"settings": "系统设置",
|
||||
"hitl": "人机协同",
|
||||
"c2": "C2",
|
||||
"c2Manage": "C2 管理",
|
||||
"c2Listeners": "监听器",
|
||||
"c2Sessions": "会话",
|
||||
"c2Tasks": "任务",
|
||||
@@ -146,6 +147,7 @@
|
||||
"active": "活跃",
|
||||
"highFreq": "高频",
|
||||
"noCallData": "暂无调用数据",
|
||||
"severityClickHint": "点击查看",
|
||||
"lastUpdated": "上次更新",
|
||||
"viewAll": "查看全部 →",
|
||||
"recentVulns": "最近漏洞",
|
||||
@@ -823,7 +825,32 @@
|
||||
},
|
||||
"robots": {
|
||||
"title": "机器人设置",
|
||||
"description": "配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
|
||||
"description": "配置微信、企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
|
||||
"wechat": {
|
||||
"title": "微信 / iLink",
|
||||
"subtitle": "扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话",
|
||||
"statusIdle": "未绑定",
|
||||
"statusBound": "已连接",
|
||||
"statusScanning": "绑定中…",
|
||||
"step1": "生成二维码",
|
||||
"step2": "微信扫码",
|
||||
"step3": "确认绑定",
|
||||
"enabled": "启用微信机器人",
|
||||
"bindButton": "生成二维码并绑定",
|
||||
"bindHint": "用微信扫码确认后会自动保存并启用。",
|
||||
"qrLoading": "正在生成二维码…",
|
||||
"verifyCodeLabel": "手机显示的数字(仅部分账号需要)",
|
||||
"rebindButton": "重新绑定",
|
||||
"boundBotId": "已绑定 Bot ID:",
|
||||
"verifyCodeSubmit": "提交",
|
||||
"advanced": "高级设置",
|
||||
"baseUrl": "API Base URL",
|
||||
"botType": "Bot Type",
|
||||
"botAgent": "Bot Agent",
|
||||
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
|
||||
"boundSuccess": "绑定成功,微信机器人已启用。",
|
||||
"openLink": "无法显示二维码?点击用手机微信打开链接"
|
||||
},
|
||||
"wecom": {
|
||||
"title": "企业微信",
|
||||
"enabled": "启用企业微信机器人",
|
||||
@@ -1293,6 +1320,35 @@
|
||||
"noCallsYet": "暂无调用",
|
||||
"unknownTool": "未知工具",
|
||||
"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": "工具",
|
||||
"columnStatus": "状态",
|
||||
"columnStartTime": "开始时间",
|
||||
@@ -1473,6 +1529,11 @@
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"statTotal": "总漏洞数",
|
||||
"statClickAll": "查看全部(清除严重度筛选)",
|
||||
"statClickFilter": "点击按此严重度筛选;再次点击清除",
|
||||
"advancedFilters": "高级筛选",
|
||||
"activeFilters": "已选条件",
|
||||
"chipRemove": "移除",
|
||||
"filter": "筛选",
|
||||
"clear": "清除",
|
||||
"vulnId": "漏洞ID",
|
||||
|
||||
+48
-20
@@ -151,6 +151,25 @@
|
||||
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 / 文本已转义) */
|
||||
function listenerProfileSelectHtml(selectedProfileId) {
|
||||
const sel = selectedProfileId ? String(selectedProfileId) : '';
|
||||
@@ -1293,14 +1312,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = tasks.map(t => `
|
||||
container.innerHTML = tasks.map(t => {
|
||||
const rawId = t.id || '';
|
||||
return `
|
||||
<div class="c2-task-item-compact">
|
||||
<span class="c2-task-status-dot ${t.status}"></span>
|
||||
<span class="c2-task-type">${t.taskType}</span>
|
||||
<span class="c2-task-status-dot ${escapeHtml(t.status || '')}"></span>
|
||||
<span class="c2-task-type">${escapeHtml(t.taskType || '')}</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>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1334,13 +1356,12 @@
|
||||
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
${C2.tasks.map(t => {
|
||||
const rawId = t.id || '';
|
||||
const idJson = JSON.stringify(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' : '-';
|
||||
return `
|
||||
@@ -1356,12 +1377,14 @@
|
||||
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
|
||||
<td>${formatDuration(t.durationMs)}</td>
|
||||
<td>${formatTime(t.createdAt)}</td>
|
||||
<td>
|
||||
<button type="button" class="btn-ghost btn-sm" onclick="C2.viewTask(${idJson})">${escapeHtml(c2t('c2.tasks.view'))}</button>
|
||||
<td class="c2-task-table-col-actions">
|
||||
<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'
|
||||
? `<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>
|
||||
</tr>
|
||||
`;
|
||||
@@ -1387,10 +1410,10 @@
|
||||
</div>
|
||||
<div class="c2-modal-body">
|
||||
<div class="c2-task-detail">
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelId'))}:</strong> ${t.id}</div>
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelSession'))}:</strong> ${t.sessionId}</div>
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelType'))}:</strong> ${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.labelId'))}:</strong> ${escapeHtml(t.id || '')}</div>
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelSession'))}:</strong> ${escapeHtml(t.sessionId || '')}</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 ${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.labelSent'))}:</strong> ${formatTime(t.sentAt)}</div>
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelCompleted'))}:</strong> ${formatTime(t.completedAt)}</div>
|
||||
@@ -1416,19 +1439,24 @@
|
||||
renderTaskModal(local);
|
||||
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);
|
||||
});
|
||||
else showToast(c2t('c2.tasks.emptyAll'), 'warn');
|
||||
}).catch(err => showToast(err.message || String(err), 'error'));
|
||||
};
|
||||
|
||||
C2.cancelTask = function(id) {
|
||||
apiRequest('POST', `${API_BASE}/tasks/${id}/cancel`, {}).then(data => {
|
||||
if (data.error) showToast(data.error, 'error');
|
||||
apiRequest('POST', `${API_BASE}/tasks/${encodeURIComponent(id)}/cancel`, {}).then(data => {
|
||||
if (data.error) showToast(String(data.error), 'error');
|
||||
else {
|
||||
showToast(c2t('c2.tasks.toastCancelled'), 'success');
|
||||
C2.loadTasks(C2.tasksPage || 1);
|
||||
}
|
||||
});
|
||||
}).catch(err => showToast(err.message || String(err), 'error'));
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+50
-3
@@ -2224,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) {
|
||||
const messageElement = document.getElementById(messageId);
|
||||
@@ -2323,6 +2356,10 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
}
|
||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||
detailsContainer.dataset.loaded = '1';
|
||||
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
|
||||
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
|
||||
}
|
||||
// 如果没有processDetails或为空,显示空状态
|
||||
if (!processDetails || processDetails.length === 0) {
|
||||
// 显示空状态提示
|
||||
@@ -2387,7 +2424,13 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
||||
const index = data.index || 0;
|
||||
const total = data.total || 0;
|
||||
itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')');
|
||||
const argsHint = typeof window.toolCallArgHint === 'function'
|
||||
? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(data) : {})
|
||||
: '';
|
||||
const callTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
||||
? window.formatToolCallTimelineTitle(toolName, index, total, argsHint)
|
||||
: (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtml(toolName), index: index, total: total }) : '调用工具: ' + escapeHtml(toolName) + ' (' + index + '/' + total + ')');
|
||||
itemTitle = agPx + '🔧 ' + callTitle;
|
||||
} else if (eventType === 'tool_result') {
|
||||
const toolName = data.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
||||
const success = data.success !== false;
|
||||
@@ -2417,12 +2460,16 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
: '⏸️ 用户中断并继续';
|
||||
}
|
||||
|
||||
addTimelineItem(timeline, eventType, {
|
||||
const timelineOpts = {
|
||||
title: itemTitle,
|
||||
message: detail.message || '',
|
||||
data: data,
|
||||
createdAt: detail.createdAt // 传递实际的事件创建时间
|
||||
});
|
||||
};
|
||||
if (eventType === 'tool_call' && data._mergedResult) {
|
||||
timelineOpts.mergedResult = data._mergedResult;
|
||||
}
|
||||
addTimelineItem(timeline, eventType, timelineOpts);
|
||||
});
|
||||
|
||||
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
|
||||
|
||||
+417
-14
@@ -202,7 +202,6 @@ async function refreshDashboard() {
|
||||
openHighCount = pickOpenCount(openHighRes, highCount);
|
||||
openMediumCount = pickOpenCount(openMediumRes, mediumCount);
|
||||
openLowCount = pickOpenCount(openLowRes, lowCount);
|
||||
if (severityTotalEl) severityTotalEl.textContent = String(total);
|
||||
severityIds.forEach(sev => {
|
||||
const count = bySeverity[sev] || 0;
|
||||
const el = document.getElementById('dashboard-severity-' + sev);
|
||||
@@ -1390,6 +1389,17 @@ function dashboardBarTooltipOnOut(ev) {
|
||||
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)渲染
|
||||
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
|
||||
// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad
|
||||
@@ -1402,9 +1412,31 @@ var SEVERITY_DONUT_CFG = {
|
||||
rOuter: 165,
|
||||
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
|
||||
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 = {
|
||||
critical: '严重',
|
||||
high: '高危',
|
||||
@@ -1422,17 +1454,65 @@ function severityLabel(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) {
|
||||
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||
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 hitsEl = document.getElementById('dashboard-severity-donut-hits');
|
||||
var labelsEl = document.getElementById('dashboard-severity-donut-labels');
|
||||
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()) {
|
||||
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'];
|
||||
@@ -1441,12 +1521,24 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
});
|
||||
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) {
|
||||
segmentsEl.innerHTML = '';
|
||||
if (hitsEl) hitsEl.innerHTML = '';
|
||||
labelsEl.innerHTML = '';
|
||||
if (leadersEl) leadersEl.innerHTML = '';
|
||||
clearSeverityDonutLegendHighlight();
|
||||
resetSeverityDonutCenter(false);
|
||||
_clearSeverityDonutChartWrapHover();
|
||||
if (svgEl) svgEl.classList.remove('donut-ready');
|
||||
return;
|
||||
}
|
||||
|
||||
resetSeverityDonutCenter(true);
|
||||
|
||||
// 弧长按 value/total 计算;若严重度求和 < total(存在未分级),右侧会保留背景轨迹的空白
|
||||
var sumVisible = visible.reduce(function (s, seg) { return s + seg.value; }, 0);
|
||||
var coverage = sumVisible / total; // 半环被实际段覆盖的比例
|
||||
@@ -1456,7 +1548,10 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
var arcsTotalRad = Math.max(0, Math.PI * coverage - totalGapRad);
|
||||
|
||||
var segmentsHtml = '';
|
||||
var hitsHtml = '';
|
||||
var glossHtml = '';
|
||||
var labelsHtml = '';
|
||||
var leadersHtml = '';
|
||||
var cumRad = 0;
|
||||
|
||||
visible.forEach(function (seg, i) {
|
||||
@@ -1466,17 +1561,21 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
var angleEnd = angleStart - segRad;
|
||||
|
||||
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% 时显示外置标签,避免小段标签互相重叠
|
||||
var pctOfTotal = (seg.value / total) * 100;
|
||||
if (pctOfTotal >= 5) {
|
||||
var midAngle = (angleStart + angleEnd) / 2;
|
||||
var labelR = cfg.rOuter + cfg.labelOffset;
|
||||
var labelR = cfg.rOuter + cfg.labelOffset + 6;
|
||||
var sinMid = Math.sin(midAngle);
|
||||
var cosMid = Math.cos(midAngle);
|
||||
var lx = cfg.cx + labelR * cosMid;
|
||||
// 顶部区域标签整体向上抬一些,避免与外弧贴住;侧边标签则不调整
|
||||
var topLift = sinMid > 0.4 ? Math.round((sinMid - 0.3) * 10) : 0;
|
||||
var ly = cfg.cy - labelR * sinMid - topLift;
|
||||
|
||||
@@ -1484,11 +1583,15 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
if (cosMid < -0.15) anchor = 'end';
|
||||
else if (cosMid > 0.15) anchor = 'start';
|
||||
|
||||
var pctText = Math.round(pctOfTotal) + '%';
|
||||
var name = esc(severityLabel(seg.id));
|
||||
var pctText = pctRounded + '%';
|
||||
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 + '" text-anchor="' + anchor + '" x="' + lx.toFixed(1) + '" y="' + ly.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 += '<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 += '</text>';
|
||||
@@ -1498,8 +1601,308 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
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;
|
||||
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 半环(背景轨迹)路径
|
||||
|
||||
+871
-126
File diff suppressed because it is too large
Load Diff
@@ -339,9 +339,23 @@ async function loadConfig(loadTools = true) {
|
||||
|
||||
// 填充机器人配置
|
||||
const robots = currentConfig.robots || {};
|
||||
const wechat = robots.wechat || {};
|
||||
const wecom = robots.wecom || {};
|
||||
const dingtalk = robots.dingtalk || {};
|
||||
const lark = robots.lark || {};
|
||||
const wechatEnabled = document.getElementById('robot-wechat-enabled');
|
||||
if (wechatEnabled) wechatEnabled.checked = wechat.enabled === true;
|
||||
const wechatBase = document.getElementById('robot-wechat-base-url');
|
||||
if (wechatBase) wechatBase.value = wechat.base_url || 'https://ilinkai.weixin.qq.com';
|
||||
const wechatBotType = document.getElementById('robot-wechat-bot-type');
|
||||
if (wechatBotType) wechatBotType.value = wechat.bot_type || '3';
|
||||
const wechatBotAgent = document.getElementById('robot-wechat-bot-agent');
|
||||
if (wechatBotAgent) wechatBotAgent.value = wechat.bot_agent || 'CyberStrikeAI/1.0';
|
||||
const wechatBotId = document.getElementById('robot-wechat-ilink-bot-id');
|
||||
if (wechatBotId) wechatBotId.value = wechat.ilink_bot_id || '';
|
||||
if (typeof refreshWechatRobotBoundUI === 'function') {
|
||||
refreshWechatRobotBoundUI({ ...wechat, bound: !!(wechat.bot_token && wechat.ilink_bot_id) });
|
||||
}
|
||||
const wecomEnabled = document.getElementById('robot-wecom-enabled');
|
||||
if (wecomEnabled) wecomEnabled.checked = wecom.enabled === true;
|
||||
const wecomToken = document.getElementById('robot-wecom-token');
|
||||
@@ -1129,6 +1143,18 @@ async function applySettings() {
|
||||
},
|
||||
robots: {
|
||||
...(prevRobots.session && typeof prevRobots.session === 'object' ? { session: prevRobots.session } : {}),
|
||||
wechat: {
|
||||
enabled: document.getElementById('robot-wechat-enabled')?.checked === true,
|
||||
base_url: document.getElementById('robot-wechat-base-url')?.value.trim() || 'https://ilinkai.weixin.qq.com',
|
||||
bot_type: document.getElementById('robot-wechat-bot-type')?.value.trim() || '3',
|
||||
bot_agent: document.getElementById('robot-wechat-bot-agent')?.value.trim() || 'CyberStrikeAI/1.0',
|
||||
ilink_bot_id: document.getElementById('robot-wechat-ilink-bot-id')?.value.trim() || (prevRobots.wechat && prevRobots.wechat.ilink_bot_id) || '',
|
||||
...(prevRobots.wechat && typeof prevRobots.wechat === 'object' ? {
|
||||
bot_token: prevRobots.wechat.bot_token || '',
|
||||
ilink_user_id: prevRobots.wechat.ilink_user_id || '',
|
||||
get_updates_buf: prevRobots.wechat.get_updates_buf || ''
|
||||
} : {})
|
||||
},
|
||||
wecom: {
|
||||
enabled: document.getElementById('robot-wecom-enabled')?.checked === true,
|
||||
token: document.getElementById('robot-wecom-token')?.value.trim() || '',
|
||||
|
||||
+534
-35
@@ -61,6 +61,24 @@ let vulnerabilityPagination = {
|
||||
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= 同步筛选(通知/对话菜单/任务管理联动)
|
||||
function syncVulnerabilityFiltersFromLocationHash() {
|
||||
const hash = window.location.hash.slice(1);
|
||||
@@ -72,19 +90,35 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
const vid = (params.get('id') || '').trim();
|
||||
const cid = (params.get('conversation_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;
|
||||
}
|
||||
|
||||
vulnerabilityFilters.id = '';
|
||||
vulnerabilityFilters.conversation_id = '';
|
||||
vulnerabilityFilters.task_id = '';
|
||||
vulnerabilityFilters.conversation_tag = '';
|
||||
vulnerabilityFilters.task_tag = '';
|
||||
vulnerabilityFilters.severity = '';
|
||||
vulnerabilityFilters.status = '';
|
||||
const idEl = document.getElementById('vulnerability-id-filter');
|
||||
const convEl = document.getElementById('vulnerability-conversation-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 (convEl) convEl.value = '';
|
||||
if (taskEl) taskEl.value = '';
|
||||
if (convTagEl) convTagEl.value = '';
|
||||
if (taskTagEl) taskTagEl.value = '';
|
||||
if (sevEl) sevEl.value = '';
|
||||
if (stEl) stEl.value = '';
|
||||
|
||||
if (vid) {
|
||||
vulnerabilityFilters.id = vid;
|
||||
@@ -98,18 +132,474 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
vulnerabilityFilters.task_id = 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;
|
||||
if (hasVulnerabilityAdvancedFiltersActive()) {
|
||||
setVulnerabilityAdvancedFiltersOpen(true, false);
|
||||
}
|
||||
syncVulnerabilityStatCardActiveState();
|
||||
updateVulnerabilityFilterPanelState();
|
||||
renderVulnerabilityFilterChips();
|
||||
}
|
||||
|
||||
// 初始化漏洞管理页面
|
||||
function initVulnerabilityPage() {
|
||||
// 从localStorage加载每页条数设置
|
||||
vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
|
||||
initVulnerabilityStatCards();
|
||||
initVulnerabilityFilterPanel();
|
||||
syncVulnerabilityFiltersFromLocationHash();
|
||||
updateVulnerabilityFilterPanelState();
|
||||
renderVulnerabilityFilterChips();
|
||||
loadVulnerabilityFilterOptions();
|
||||
loadVulnerabilityStats();
|
||||
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() {
|
||||
try {
|
||||
@@ -153,15 +643,33 @@ function updateVulnerabilityStats(stats) {
|
||||
by_status: {}
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('stat-total').textContent = stats.total || 0;
|
||||
|
||||
|
||||
const total = stats.total || 0;
|
||||
const bySeverity = stats.by_severity || {};
|
||||
document.getElementById('stat-critical').textContent = bySeverity.critical || 0;
|
||||
document.getElementById('stat-high').textContent = bySeverity.high || 0;
|
||||
document.getElementById('stat-medium').textContent = bySeverity.medium || 0;
|
||||
document.getElementById('stat-low').textContent = bySeverity.low || 0;
|
||||
document.getElementById('stat-info').textContent = bySeverity.info || 0;
|
||||
|
||||
const totalEl = document.getElementById('stat-total');
|
||||
if (totalEl) {
|
||||
totalEl.textContent = String(total);
|
||||
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;
|
||||
}
|
||||
|
||||
// 筛选漏洞
|
||||
// 筛选漏洞(应用当前表单条件)
|
||||
function filterVulnerabilities() {
|
||||
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;
|
||||
|
||||
// 重置到第一页
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
|
||||
loadVulnerabilityStats();
|
||||
loadVulnerabilities();
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
// 清除筛选
|
||||
function clearVulnerabilityFilters() {
|
||||
document.getElementById('vulnerability-id-filter').value = '';
|
||||
document.getElementById('vulnerability-conversation-filter').value = '';
|
||||
document.getElementById('vulnerability-task-filter').value = '';
|
||||
document.getElementById('vulnerability-conversation-tag-filter').value = '';
|
||||
document.getElementById('vulnerability-task-tag-filter').value = '';
|
||||
document.getElementById('vulnerability-severity-filter').value = '';
|
||||
document.getElementById('vulnerability-status-filter').value = '';
|
||||
const fields = [
|
||||
'vulnerability-id-filter',
|
||||
'vulnerability-conversation-filter',
|
||||
'vulnerability-task-filter',
|
||||
'vulnerability-conversation-tag-filter',
|
||||
'vulnerability-task-tag-filter',
|
||||
'vulnerability-severity-filter',
|
||||
'vulnerability-status-filter'
|
||||
];
|
||||
fields.forEach(function (id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
|
||||
vulnerabilityFilters = {
|
||||
id: '',
|
||||
@@ -612,11 +1114,7 @@ function clearVulnerabilityFilters() {
|
||||
status: ''
|
||||
};
|
||||
|
||||
// 重置到第一页
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
|
||||
loadVulnerabilityStats();
|
||||
loadVulnerabilities();
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
// 刷新漏洞
|
||||
@@ -892,6 +1390,7 @@ window.onclick = function(event) {
|
||||
document.addEventListener('languagechange', function () {
|
||||
const page = document.getElementById('page-vulnerabilities');
|
||||
if (page && page.classList.contains('active')) {
|
||||
renderVulnerabilityFilterChips();
|
||||
loadVulnerabilities();
|
||||
}
|
||||
});
|
||||
|
||||
+89
-56
@@ -1666,7 +1666,13 @@ function buildWebshellTimelineItemFromDetail(detail) {
|
||||
var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具');
|
||||
var idx = data.index || 0;
|
||||
var total = data.total || 0;
|
||||
title = ap + '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
|
||||
var wsHint = typeof window.toolCallArgHint === 'function'
|
||||
? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(data) : {})
|
||||
: '';
|
||||
var wsCallTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
||||
? window.formatToolCallTimelineTitle(tn, idx, total, wsHint)
|
||||
: ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
|
||||
title = ap + '🔧 ' + wsCallTitle;
|
||||
} else if (eventType === 'tool_result') {
|
||||
var success = data.success !== false;
|
||||
var tname = data.toolName || '工具';
|
||||
@@ -1695,6 +1701,9 @@ function buildWebshellTimelineItemFromDetail(detail) {
|
||||
html += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' + escapeHtml(paramsLabel) + '</strong><pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre></div></div>';
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (eventType === 'tool_call' && data && data._mergedResult && typeof window.buildToolResultSectionHtml === 'function') {
|
||||
html += '<div class="webshell-ai-timeline-msg tool-result-slot">' + window.buildToolResultSectionHtml(data._mergedResult) + '</div>';
|
||||
} else if (eventType === 'tool_result' && data) {
|
||||
var isError = data.isError || data.success === false;
|
||||
var noResultText = (typeof window.t === 'function') ? window.t('timeline.noResult') : '无结果';
|
||||
@@ -1712,6 +1721,9 @@ function buildWebshellTimelineItemFromDetail(detail) {
|
||||
// 渲染「执行过程及调用工具」折叠块(默认折叠,刷新后加载历史时保留并可展开)
|
||||
function renderWebshellProcessDetailsBlock(processDetails, defaultCollapsed) {
|
||||
if (!processDetails || processDetails.length === 0) return null;
|
||||
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
|
||||
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
|
||||
}
|
||||
var expandLabel = (typeof window.t === 'function') ? window.t('chat.expandDetail') : '展开详情';
|
||||
var collapseLabel = (typeof window.t === 'function') ? window.t('tasks.collapseDetail') : '收起详情';
|
||||
var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具';
|
||||
@@ -2772,7 +2784,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
|
||||
var html = '<span class="webshell-ai-timeline-title">' + escapeHtml(title || message || '') + '</span>';
|
||||
|
||||
// 工具调用入参
|
||||
// 工具调用入参 + 结果同卡
|
||||
if (type === 'tool_call' && data) {
|
||||
try {
|
||||
var args = data.argumentsObj;
|
||||
@@ -2783,14 +2795,20 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
args = { _raw: String(data.arguments) };
|
||||
}
|
||||
}
|
||||
if (args && typeof args === 'object') {
|
||||
var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:';
|
||||
html += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' +
|
||||
escapeHtml(paramsLabel) +
|
||||
'</strong><pre class="tool-args">' +
|
||||
escapeHtml(JSON.stringify(args, null, 2)) +
|
||||
'</pre></div></div>';
|
||||
if (args == null || typeof args !== 'object') {
|
||||
args = {};
|
||||
}
|
||||
var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:';
|
||||
var pendingResult = (typeof window.buildToolResultSectionHtml === 'function')
|
||||
? window.buildToolResultSectionHtml({}, { pending: true })
|
||||
: '';
|
||||
html += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' +
|
||||
escapeHtml(paramsLabel) +
|
||||
'</strong><pre class="tool-args">' +
|
||||
escapeHtml(JSON.stringify(args, null, 2)) +
|
||||
'</pre></div>' +
|
||||
(pendingResult ? '<div class="tool-result-slot">' + pendingResult + '</div>' : '') +
|
||||
'</div>';
|
||||
} catch (e) {
|
||||
// JSON 解析失败时忽略参数详情,避免打断主流程
|
||||
}
|
||||
@@ -2829,6 +2847,7 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
var einoSubReplyStreams = new Map();
|
||||
var wsThinkingStreams = new Map(); // streamId → { el, buf }
|
||||
var wsToolResultStreams = new Map(); // toolCallId → { el, buf }
|
||||
var wsToolCallItems = new Map(); // toolCallId → DOM item(参数+结果同卡)
|
||||
|
||||
if (inputEl) inputEl.value = '';
|
||||
|
||||
@@ -2905,11 +2924,16 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
} else if (_et === 'response_delta') {
|
||||
var deltaText = (_em != null && _em !== '') ? String(_em) : '';
|
||||
if (deltaText) {
|
||||
var normR = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||
? window.normalizeStreamingDeltaJs(streamingTarget, deltaText)
|
||||
: [streamingTarget + deltaText, deltaText];
|
||||
streamingTarget = normR[0];
|
||||
var mergeBuf = (typeof window.mergeStreamBuffer === 'function')
|
||||
? window.mergeStreamBuffer
|
||||
: function (cur, dlt) {
|
||||
var normR = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||
? window.normalizeStreamingDeltaJs(cur, dlt)
|
||||
: [cur + dlt, dlt];
|
||||
return normR[0];
|
||||
};
|
||||
if (deltaText || (_ed && _ed.accumulated != null)) {
|
||||
streamingTarget = mergeBuf(streamingTarget, deltaText, _ed);
|
||||
webshellStreamingTypingId += 1;
|
||||
streamingTypingId = webshellStreamingTypingId;
|
||||
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
||||
@@ -2982,12 +3006,17 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
wsThinkingStreams.set(_ed.streamId, { el: thinkSItem, body: thinkSPre, buf: '' });
|
||||
}
|
||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||
} else if ((_et === 'thinking_stream_delta' || _et === 'reasoning_chain_stream_delta') && _ed.streamId) {
|
||||
} else if ((_et === 'thinking_stream_delta' || _et === 'reasoning_chain_stream_delta') && _ed && _ed.streamId) {
|
||||
var tsD = wsThinkingStreams.get(_ed.streamId);
|
||||
if (tsD) {
|
||||
var normT = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||
? window.normalizeStreamingDeltaJs(tsD.buf, _em || '') : [tsD.buf + (_em || ''), _em || ''];
|
||||
tsD.buf = normT[0];
|
||||
var mergeThink = (typeof window.mergeStreamBuffer === 'function')
|
||||
? window.mergeStreamBuffer
|
||||
: function (cur, dlt) {
|
||||
var normT = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||
? window.normalizeStreamingDeltaJs(cur, dlt) : [cur + dlt, dlt];
|
||||
return normT[0];
|
||||
};
|
||||
tsD.buf = mergeThink(tsD.buf, _em || '', _ed);
|
||||
if (typeof formatMarkdown === 'function') {
|
||||
tsD.body.innerHTML = formatMarkdown(tsD.buf);
|
||||
} else {
|
||||
@@ -3035,11 +3064,16 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
var tn = _ed.toolName || '未知工具';
|
||||
var idx = _ed.index || 0;
|
||||
var total = _ed.total || 0;
|
||||
var callTitle = wsTOr('chat.callTool', '') || ('调用工具: ' + tn + (total ? ' (' + idx + '/' + total + ')' : ''));
|
||||
if (typeof window.t === 'function') {
|
||||
try { callTitle = window.t('chat.callTool', { name: tn, index: idx, total: total }); } catch (e) { /* */ }
|
||||
var wsHintLive = typeof window.toolCallArgHint === 'function'
|
||||
? window.toolCallArgHint(typeof window.parseToolCallArgsFromData === 'function' ? window.parseToolCallArgsFromData(_ed) : {})
|
||||
: '';
|
||||
var callTitle = typeof window.formatToolCallTimelineTitle === 'function'
|
||||
? window.formatToolCallTimelineTitle(tn, idx, total, wsHintLive)
|
||||
: (wsTOr('chat.callTool', '') || ('调用工具: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
|
||||
var callItem = appendTimelineItem('tool_call', webshellAgentPx(_ed) + '🔧 ' + callTitle, _em || '', _ed);
|
||||
if (_ed.toolCallId && callItem) {
|
||||
wsToolCallItems.set(_ed.toolCallId, callItem);
|
||||
}
|
||||
appendTimelineItem('tool_call', webshellAgentPx(_ed) + '🔧 ' + callTitle, _em || '', _ed);
|
||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||
|
||||
// ─── Tool result delta (streaming output) ───
|
||||
@@ -3049,22 +3083,18 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
if (trdDelta) {
|
||||
var trdState = wsToolResultStreams.get(trdKey);
|
||||
if (!trdState) {
|
||||
var trdName = _ed.toolName || '工具';
|
||||
var runLabel = wsTOr('timeline.running', '执行中...');
|
||||
var trdItem = document.createElement('div');
|
||||
trdItem.className = 'webshell-ai-timeline-item webshell-ai-timeline-tool_result';
|
||||
trdItem.innerHTML = '<span class="webshell-ai-timeline-title">' +
|
||||
escapeHtml(webshellAgentPx(_ed) + '⏳ ' + runLabel + ' ' + trdName) +
|
||||
'</span><div class="webshell-ai-timeline-msg"><div class="tool-result-section success">' +
|
||||
'<pre class="tool-result"></pre></div></div>';
|
||||
timelineContainer.appendChild(trdItem);
|
||||
timelineContainer.classList.add('has-items');
|
||||
trdState = { el: trdItem, buf: '' };
|
||||
var callEl = wsToolCallItems.get(trdKey);
|
||||
trdState = { el: callEl || null, buf: '', onCall: !!callEl };
|
||||
wsToolResultStreams.set(trdKey, trdState);
|
||||
}
|
||||
trdState.buf += trdDelta;
|
||||
var trdPre = trdState.el.querySelector('pre.tool-result');
|
||||
if (trdPre) trdPre.textContent = trdState.buf;
|
||||
if (trdState.el) {
|
||||
var trdPre = trdState.el.querySelector('pre.tool-result');
|
||||
if (trdPre) {
|
||||
trdPre.classList.remove('tool-result-pending');
|
||||
trdPre.textContent = trdState.buf;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||
|
||||
@@ -3072,25 +3102,23 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
} else if (_et === 'tool_result' && _ed) {
|
||||
var success = _ed.success !== false;
|
||||
var tname = _ed.toolName || '工具';
|
||||
var titleText = wsTOr(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', '') ||
|
||||
(tname + (success ? ' 执行完成' : ' 执行失败'));
|
||||
if (typeof window.t === 'function') {
|
||||
try { titleText = window.t(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', { name: tname }); } catch (e) { /* */ }
|
||||
var merged = false;
|
||||
if (_ed.toolCallId) {
|
||||
var streamSt = wsToolResultStreams.get(_ed.toolCallId);
|
||||
var callElRes = wsToolCallItems.get(_ed.toolCallId) || (streamSt && streamSt.el);
|
||||
if (callElRes && typeof window.mergeToolResultIntoCallItem === 'function') {
|
||||
window.mergeToolResultIntoCallItem(callElRes, _ed);
|
||||
merged = true;
|
||||
wsToolResultStreams.delete(_ed.toolCallId);
|
||||
wsToolCallItems.delete(_ed.toolCallId);
|
||||
}
|
||||
}
|
||||
// 如果有流式占位条目,更新标题
|
||||
var trdExist = _ed.toolCallId ? wsToolResultStreams.get(_ed.toolCallId) : null;
|
||||
if (trdExist) {
|
||||
var trdTitleEl = trdExist.el.querySelector('.webshell-ai-timeline-title');
|
||||
if (trdTitleEl) trdTitleEl.textContent = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText;
|
||||
// 更新结果内容
|
||||
var resultText = _ed.result ? String(_ed.result) : (_em || '');
|
||||
var trdPreEl = trdExist.el.querySelector('pre.tool-result');
|
||||
if (trdPreEl && resultText) trdPreEl.textContent = resultText;
|
||||
// 更新 section class
|
||||
var trdSection = trdExist.el.querySelector('.tool-result-section');
|
||||
if (trdSection) { trdSection.className = 'tool-result-section ' + (success ? 'success' : 'error'); }
|
||||
wsToolResultStreams.delete(_ed.toolCallId);
|
||||
} else {
|
||||
if (!merged) {
|
||||
var titleText = wsTOr(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', '') ||
|
||||
(tname + (success ? ' 执行完成' : ' 执行失败'));
|
||||
if (typeof window.t === 'function') {
|
||||
try { titleText = window.t(success ? 'chat.toolExecComplete' : 'chat.toolExecFailed', { name: tname }); } catch (e) { /* */ }
|
||||
}
|
||||
var title = webshellAgentPx(_ed) + (success ? '✅ ' : '❌ ') + titleText;
|
||||
var sub = _em || (_ed.result ? String(_ed.result).slice(0, 300) : '');
|
||||
appendTimelineItem('tool_result', title, sub, _ed);
|
||||
@@ -3118,9 +3146,14 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
} else if (_et === 'eino_agent_reply_stream_delta' && _ed.streamId) {
|
||||
var stD = einoSubReplyStreams.get(_ed.streamId);
|
||||
if (stD) {
|
||||
var normS = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||
? window.normalizeStreamingDeltaJs(stD.buf, _em || '') : [stD.buf + (_em || ''), _em || ''];
|
||||
stD.buf = normS[0];
|
||||
var mergeSub = (typeof window.mergeStreamBuffer === 'function')
|
||||
? window.mergeStreamBuffer
|
||||
: function (cur, dlt) {
|
||||
var normS = (typeof window.normalizeStreamingDeltaJs === 'function')
|
||||
? window.normalizeStreamingDeltaJs(cur, dlt) : [cur + dlt, dlt];
|
||||
return normS[0];
|
||||
};
|
||||
stD.buf = mergeSub(stD.buf, _em || '', _ed);
|
||||
var preD = stD.el.querySelector('.webshell-eino-reply-stream-body');
|
||||
if (!preD) {
|
||||
preD = document.createElement('pre');
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
// 微信 iLink 机器人:扫码绑定与状态轮询
|
||||
|
||||
let wechatBindSessionKey = null;
|
||||
let wechatBindPollTimer = null;
|
||||
|
||||
function wechatT(key, fallback) {
|
||||
return typeof t === 'function' ? t(key) : fallback;
|
||||
}
|
||||
|
||||
function getWechatCard() {
|
||||
return document.getElementById('robot-wechat-subsection');
|
||||
}
|
||||
|
||||
function setWechatBadge(mode) {
|
||||
const badge = document.getElementById('robot-wechat-status-badge');
|
||||
if (!badge) return;
|
||||
badge.classList.remove('robot-wechat-badge--idle', 'robot-wechat-badge--bound', 'robot-wechat-badge--scanning');
|
||||
if (mode === 'bound') {
|
||||
badge.classList.add('robot-wechat-badge--bound');
|
||||
badge.textContent = wechatT('settings.robots.wechat.statusBound', '已连接');
|
||||
} else if (mode === 'scanning') {
|
||||
badge.classList.add('robot-wechat-badge--scanning');
|
||||
badge.textContent = wechatT('settings.robots.wechat.statusScanning', '绑定中…');
|
||||
} else {
|
||||
badge.classList.add('robot-wechat-badge--idle');
|
||||
badge.textContent = wechatT('settings.robots.wechat.statusIdle', '未绑定');
|
||||
}
|
||||
}
|
||||
|
||||
function setWechatCardBound(isBound) {
|
||||
const card = getWechatCard();
|
||||
if (card) card.classList.toggle('is-bound', !!isBound);
|
||||
}
|
||||
|
||||
function updateWechatSteps(phase) {
|
||||
const steps = document.querySelectorAll('.robot-wechat-step');
|
||||
if (!steps.length) return;
|
||||
const order = ['generate', 'scan', 'confirm'];
|
||||
const idx = order.indexOf(phase);
|
||||
steps.forEach((el, i) => {
|
||||
el.classList.remove('is-active', 'is-done');
|
||||
if (idx < 0) {
|
||||
if (i === 0) el.classList.add('is-active');
|
||||
} else if (i < idx) {
|
||||
el.classList.add('is-done');
|
||||
} else if (i === idx) {
|
||||
el.classList.add('is-active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureWechatSteps() {
|
||||
const panel = document.getElementById('robot-wechat-scan-panel');
|
||||
if (!panel || panel.querySelector('.robot-wechat-steps')) return;
|
||||
const ol = document.createElement('ol');
|
||||
ol.className = 'robot-wechat-steps';
|
||||
ol.innerHTML = `
|
||||
<li class="robot-wechat-step is-active">${wechatT('settings.robots.wechat.step1', '生成二维码')}</li>
|
||||
<li class="robot-wechat-step">${wechatT('settings.robots.wechat.step2', '微信扫码')}</li>
|
||||
<li class="robot-wechat-step">${wechatT('settings.robots.wechat.step3', '确认绑定')}</li>`;
|
||||
panel.insertBefore(ol, panel.firstChild);
|
||||
}
|
||||
|
||||
function ensureWechatQrFrame() {
|
||||
const img = document.getElementById('robot-wechat-qr-img');
|
||||
if (!img || img.parentElement?.classList.contains('robot-wechat-qr-frame')) return;
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'robot-wechat-qr-frame';
|
||||
img.parentNode.insertBefore(frame, img);
|
||||
frame.appendChild(img);
|
||||
let ph = document.getElementById('robot-wechat-qr-placeholder');
|
||||
if (!ph) {
|
||||
ph = document.createElement('div');
|
||||
ph.id = 'robot-wechat-qr-placeholder';
|
||||
ph.className = 'robot-wechat-qr-placeholder';
|
||||
ph.setAttribute('aria-hidden', 'true');
|
||||
ph.innerHTML = '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3h-3v-3zm4 0h3v3h-3v-3zm-4 4h3v3h-3v-3zm4 0h3v3h-3v-3z"/></svg>';
|
||||
frame.appendChild(ph);
|
||||
} else {
|
||||
frame.appendChild(ph);
|
||||
}
|
||||
}
|
||||
|
||||
function stopWechatBindPoll() {
|
||||
if (wechatBindPollTimer) {
|
||||
clearTimeout(wechatBindPollTimer);
|
||||
wechatBindPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 已绑定:仅展示成功状态,不显示二维码/配对码 */
|
||||
function showWechatBoundUI(wechat) {
|
||||
const wc = wechat || {};
|
||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||
const boundPanel = document.getElementById('robot-wechat-bound-panel');
|
||||
const scanPanel = document.getElementById('robot-wechat-scan-panel');
|
||||
const boundId = document.getElementById('robot-wechat-bound-id');
|
||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||
|
||||
stopWechatBindPoll();
|
||||
wechatBindSessionKey = null;
|
||||
setWechatBadge('bound');
|
||||
setWechatCardBound(true);
|
||||
|
||||
if (wrap) wrap.hidden = false;
|
||||
if (boundPanel) boundPanel.hidden = false;
|
||||
if (scanPanel) scanPanel.hidden = true;
|
||||
|
||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||
if (verifyWrap) verifyWrap.hidden = true;
|
||||
|
||||
const img = document.getElementById('robot-wechat-qr-img');
|
||||
const ph = document.getElementById('robot-wechat-qr-placeholder');
|
||||
if (img) {
|
||||
img.removeAttribute('src');
|
||||
img.hidden = true;
|
||||
}
|
||||
if (ph) ph.hidden = false;
|
||||
|
||||
if (boundId) {
|
||||
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
|
||||
if (id) {
|
||||
boundId.textContent = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID:') + id;
|
||||
boundId.hidden = false;
|
||||
} else {
|
||||
boundId.textContent = '';
|
||||
boundId.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.textContent = wechatT('settings.robots.wechat.rebindButton', '重新绑定');
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫码绑定进行中 */
|
||||
function showWechatScanUI() {
|
||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||
const boundPanel = document.getElementById('robot-wechat-bound-panel');
|
||||
const scanPanel = document.getElementById('robot-wechat-scan-panel');
|
||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||
|
||||
setWechatBadge('scanning');
|
||||
setWechatCardBound(false);
|
||||
ensureWechatSteps();
|
||||
updateWechatSteps('generate');
|
||||
|
||||
if (wrap) wrap.hidden = false;
|
||||
if (boundPanel) boundPanel.hidden = true;
|
||||
if (scanPanel) scanPanel.hidden = false;
|
||||
|
||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||
if (verifyWrap) verifyWrap.hidden = true;
|
||||
|
||||
const verifyInput = document.getElementById('robot-wechat-verify-code');
|
||||
if (verifyInput) verifyInput.value = '';
|
||||
|
||||
if (btn) {
|
||||
btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定');
|
||||
}
|
||||
}
|
||||
|
||||
/** 未绑定且未在扫码:隐藏面板 */
|
||||
function hideWechatQrWrap() {
|
||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||
if (wrap) wrap.hidden = true;
|
||||
setWechatBadge('idle');
|
||||
setWechatCardBound(false);
|
||||
}
|
||||
|
||||
function setWechatQrImage(data) {
|
||||
ensureWechatQrFrame();
|
||||
const img = document.getElementById('robot-wechat-qr-img');
|
||||
const ph = document.getElementById('robot-wechat-qr-placeholder');
|
||||
const linkEl = document.getElementById('robot-wechat-qr-link');
|
||||
const openUrl = data.qrcode_open_url || data.qrcode_img_url || '';
|
||||
|
||||
if (img) {
|
||||
if (data.qrcode_image_data_url) {
|
||||
img.onload = () => {
|
||||
img.hidden = false;
|
||||
if (ph) ph.hidden = true;
|
||||
};
|
||||
img.onerror = () => {
|
||||
img.hidden = true;
|
||||
if (ph) ph.hidden = false;
|
||||
};
|
||||
img.src = data.qrcode_image_data_url;
|
||||
updateWechatSteps('scan');
|
||||
} else {
|
||||
img.removeAttribute('src');
|
||||
img.hidden = true;
|
||||
if (ph) ph.hidden = false;
|
||||
}
|
||||
}
|
||||
if (linkEl) {
|
||||
if (openUrl) {
|
||||
linkEl.href = openUrl;
|
||||
linkEl.hidden = false;
|
||||
} else {
|
||||
linkEl.hidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setWechatQrStatus(text, isError) {
|
||||
const el = document.getElementById('robot-wechat-qr-status');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.classList.toggle('is-error', !!isError);
|
||||
el.classList.toggle('is-success', !isError && !!text);
|
||||
}
|
||||
|
||||
async function startWechatRobotBind() {
|
||||
stopWechatBindPoll();
|
||||
wechatBindSessionKey = null;
|
||||
showWechatScanUI();
|
||||
ensureWechatQrFrame();
|
||||
|
||||
const loading = document.getElementById('robot-wechat-qr-loading');
|
||||
const img = document.getElementById('robot-wechat-qr-img');
|
||||
const ph = document.getElementById('robot-wechat-qr-placeholder');
|
||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||
|
||||
if (loading) loading.hidden = false;
|
||||
if (img) {
|
||||
img.removeAttribute('src');
|
||||
img.hidden = true;
|
||||
}
|
||||
if (ph) ph.hidden = false;
|
||||
setWechatQrStatus('', false);
|
||||
if (btn) btn.disabled = true;
|
||||
|
||||
const botType = document.getElementById('robot-wechat-bot-type')?.value.trim() || '3';
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/robot/wechat/qrcode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bot_type: botType })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || data.message || '获取二维码失败');
|
||||
}
|
||||
wechatBindSessionKey = data.session_key;
|
||||
setWechatQrImage(data);
|
||||
setWechatQrStatus(data.message || '请使用微信扫描二维码', false);
|
||||
pollWechatBindStatus();
|
||||
} catch (e) {
|
||||
setWechatQrStatus(e.message || String(e), true);
|
||||
setWechatBadge('idle');
|
||||
} finally {
|
||||
if (loading) loading.hidden = true;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollWechatBindStatus() {
|
||||
if (!wechatBindSessionKey) return;
|
||||
|
||||
try {
|
||||
const url = `/api/robot/wechat/qrcode/status?session_key=${encodeURIComponent(wechatBindSessionKey)}`;
|
||||
const res = await apiFetch(url, { method: 'GET' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '轮询失败');
|
||||
}
|
||||
|
||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||
|
||||
switch (data.status) {
|
||||
case 'confirmed':
|
||||
stopWechatBindPoll();
|
||||
updateWechatSteps('confirm');
|
||||
document.getElementById('robot-wechat-enabled').checked = true;
|
||||
if (data.ilink_bot_id) {
|
||||
const idEl = document.getElementById('robot-wechat-ilink-bot-id');
|
||||
if (idEl) idEl.value = data.ilink_bot_id;
|
||||
}
|
||||
if (typeof loadConfig === 'function') {
|
||||
await loadConfig(false);
|
||||
} else {
|
||||
showWechatBoundUI({
|
||||
ilink_bot_id: data.ilink_bot_id,
|
||||
bound: true
|
||||
});
|
||||
}
|
||||
return;
|
||||
case 'need_verifycode':
|
||||
updateWechatSteps('scan');
|
||||
if (verifyWrap) verifyWrap.hidden = false;
|
||||
setWechatQrStatus(data.message || '请输入手机微信显示的数字', false);
|
||||
break;
|
||||
case 'scaned':
|
||||
updateWechatSteps('confirm');
|
||||
if (verifyWrap) verifyWrap.hidden = true;
|
||||
setWechatQrStatus('已扫码,请在手机上确认…', false);
|
||||
break;
|
||||
case 'binded_redirect':
|
||||
stopWechatBindPoll();
|
||||
showWechatBoundUI({ bound: true });
|
||||
return;
|
||||
case 'expired':
|
||||
setWechatQrStatus('二维码已过期,请重新点击「生成二维码并绑定」', true);
|
||||
setWechatBadge('scanning');
|
||||
stopWechatBindPoll();
|
||||
return;
|
||||
default:
|
||||
if (verifyWrap) verifyWrap.hidden = true;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
setWechatQrStatus(e.message || String(e), true);
|
||||
}
|
||||
|
||||
wechatBindPollTimer = setTimeout(pollWechatBindStatus, 1500);
|
||||
}
|
||||
|
||||
async function submitWechatVerifyCode() {
|
||||
const code = document.getElementById('robot-wechat-verify-code')?.value.trim();
|
||||
if (!code || !wechatBindSessionKey) return;
|
||||
try {
|
||||
const res = await apiFetch('/api/robot/wechat/qrcode/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_key: wechatBindSessionKey, verify_code: code })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '提交失败');
|
||||
setWechatQrStatus(data.message || '已提交配对码,等待确认…', false);
|
||||
pollWechatBindStatus();
|
||||
} catch (e) {
|
||||
setWechatQrStatus(e.message || String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshWechatRobotBoundUI(wechat) {
|
||||
const wc = wechat || {};
|
||||
const isBound = wc.bound || (wc.bot_token && wc.ilink_bot_id) || !!(wc.ilink_bot_id && wc.enabled);
|
||||
if (isBound) {
|
||||
showWechatBoundUI(wc);
|
||||
} else {
|
||||
hideWechatQrWrap();
|
||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||
if (btn) {
|
||||
btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定');
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-75
@@ -190,10 +190,12 @@
|
||||
<!-- 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">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
<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="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.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>
|
||||
<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">
|
||||
@@ -201,6 +203,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<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-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>
|
||||
@@ -446,42 +449,46 @@
|
||||
</div>
|
||||
</aside>
|
||||
<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-leaders"></g>
|
||||
<g id="dashboard-severity-donut-segments"></g>
|
||||
<g id="dashboard-severity-donut-hits"></g>
|
||||
<g id="dashboard-severity-donut-labels"></g>
|
||||
</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-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 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-label" data-i18n="dashboard.severityCritical">严重</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>
|
||||
</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-label" data-i18n="dashboard.severityHigh">高危</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>
|
||||
</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-label" data-i18n="dashboard.severityMedium">中危</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>
|
||||
</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-label" data-i18n="dashboard.severityLow">低危</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>
|
||||
</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-label" data-i18n="dashboard.severityInfo">信息</span>
|
||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-info">0</span>
|
||||
@@ -1075,10 +1082,13 @@
|
||||
<div class="page-content">
|
||||
<div class="monitor-sections">
|
||||
<section class="monitor-section monitor-overview">
|
||||
<div class="section-header">
|
||||
<h3 data-i18n="mcp.execStats">执行统计</h3>
|
||||
<div class="section-header monitor-stats-section-header">
|
||||
<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 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>
|
||||
</section>
|
||||
@@ -1378,89 +1388,124 @@
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
||||
<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-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<!-- 统计看板 -->
|
||||
<!-- 统计看板:点击卡片筛选严重度,与下方下拉/地址栏 hash 同步 -->
|
||||
<div class="vulnerability-dashboard" id="vulnerability-dashboard">
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat-card">
|
||||
<div class="dashboard-stats" id="vulnerability-stat-cards" role="group" aria-label="漏洞严重度统计">
|
||||
<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-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 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-value" id="stat-critical">-</div>
|
||||
<div class="stat-pct" id="stat-critical-pct" aria-hidden="true">—</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-value" id="stat-high">-</div>
|
||||
<div class="stat-pct" id="stat-high-pct" aria-hidden="true">—</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-value" id="stat-medium">-</div>
|
||||
<div class="stat-pct" id="stat-medium-pct" aria-hidden="true">—</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-value" id="stat-low">-</div>
|
||||
<div class="stat-pct" id="stat-low-pct" aria-hidden="true">—</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-value" id="stat-info">-</div>
|
||||
<div class="stat-pct" id="stat-info-pct" aria-hidden="true">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div class="vulnerability-controls">
|
||||
<div class="vulnerability-filters">
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.vulnId">漏洞ID</span>
|
||||
<input type="text" id="vulnerability-id-filter" data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞ID" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.conversationId">会话ID</span>
|
||||
<input type="text" id="vulnerability-conversation-filter" data-i18n="vulnerabilityPage.filterConversation" data-i18n-attr="placeholder" placeholder="筛选特定会话" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务ID/队列ID</span>
|
||||
<input type="text" id="vulnerability-task-filter" data-i18n="vulnerabilityPage.filterTaskOrQueue" data-i18n-attr="placeholder" placeholder="筛选任务ID或队列ID" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span>
|
||||
<input type="text" id="vulnerability-conversation-tag-filter" data-i18n="vulnerabilityPage.filterConversationTag" data-i18n-attr="placeholder" placeholder="筛选对话标签" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span>
|
||||
<input type="text" id="vulnerability-task-tag-filter" data-i18n="vulnerabilityPage.filterTaskTag" data-i18n-attr="placeholder" placeholder="筛选任务标签" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.severity">严重程度</span>
|
||||
<select id="vulnerability-severity-filter">
|
||||
<option value="" data-i18n="knowledgePage.all">全部</option>
|
||||
<option value="critical" data-i18n="dashboard.severityCritical">严重</option>
|
||||
<option value="high" data-i18n="dashboard.severityHigh">高危</option>
|
||||
<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 class="vulnerability-controls" id="vulnerability-filter-panel">
|
||||
<div class="vulnerability-filter-toolbar">
|
||||
<div class="vulnerability-filter-primary">
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--grow">
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.vulnId">漏洞ID</span>
|
||||
<input type="search" id="vulnerability-id-filter" autocomplete="off"
|
||||
data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞 ID,回车筛选" />
|
||||
</label>
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--status">
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
|
||||
<select id="vulnerability-status-filter" data-i18n-attr="title" title="状态">
|
||||
<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 type="button" class="vulnerability-filter-clear-btn" onclick="clearVulnerabilityFilters()" data-i18n="vulnerabilityPage.clear">清除</button>
|
||||
</div>
|
||||
<select id="vulnerability-severity-filter" class="vulnerability-severity-sync" hidden aria-hidden="true" tabindex="-1">
|
||||
<option value=""></option>
|
||||
<option value="critical">critical</option>
|
||||
<option value="high">high</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="low">low</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<!-- 漏洞列表 -->
|
||||
@@ -1605,11 +1650,13 @@
|
||||
<div id="c2-main" class="c2-main">
|
||||
<div class="c2-welcome">
|
||||
<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>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
||||
<path d="M2 17l10 5 10-5"></path>
|
||||
<path d="M2 12l10 5 10-5"></path>
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.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>
|
||||
</div>
|
||||
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
|
||||
@@ -2332,6 +2379,73 @@
|
||||
<p class="settings-description" data-i18n="settings.robots.description">配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。</p>
|
||||
</div>
|
||||
|
||||
<!-- 微信 / iLink -->
|
||||
<div class="settings-subsection robot-wechat-card" id="robot-wechat-subsection">
|
||||
<div class="robot-wechat-header">
|
||||
<div class="robot-wechat-header-text">
|
||||
<h4 data-i18n="settings.robots.wechat.title">微信 / iLink</h4>
|
||||
<p class="robot-wechat-subtitle" data-i18n="settings.robots.wechat.subtitle">扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话</p>
|
||||
</div>
|
||||
<span id="robot-wechat-status-badge" class="robot-wechat-badge robot-wechat-badge--idle" data-i18n="settings.robots.wechat.statusIdle">未绑定</span>
|
||||
</div>
|
||||
<div class="settings-form robot-wechat-form">
|
||||
<div class="robot-wechat-toolbar">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="robot-wechat-enabled" class="modern-checkbox" />
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-text" data-i18n="settings.robots.wechat.enabled">启用微信机器人</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="robot-wechat-action-row">
|
||||
<button type="button" class="btn-primary" id="robot-wechat-bind-btn" onclick="startWechatRobotBind()" data-i18n="settings.robots.wechat.bindButton">生成二维码并绑定</button>
|
||||
<p class="robot-wechat-hint" id="robot-wechat-bind-hint" data-i18n="settings.robots.wechat.bindHint">用微信扫码确认后会自动保存并启用。</p>
|
||||
</div>
|
||||
<div id="robot-wechat-qr-wrap" class="robot-wechat-panel" hidden>
|
||||
<div id="robot-wechat-bound-panel" class="robot-wechat-bound-panel" hidden>
|
||||
<div class="robot-wechat-bound-icon" aria-hidden="true">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<p class="robot-wechat-bound-msg" data-i18n="settings.robots.wechat.boundSuccess">绑定成功,微信机器人已启用</p>
|
||||
<p id="robot-wechat-bound-id" class="robot-wechat-bound-id" hidden></p>
|
||||
</div>
|
||||
<div id="robot-wechat-scan-panel" class="robot-wechat-scan-panel" hidden>
|
||||
<div id="robot-wechat-qr-loading" class="robot-wechat-qr-loading" hidden data-i18n="settings.robots.wechat.qrLoading">正在生成二维码…</div>
|
||||
<img id="robot-wechat-qr-img" class="robot-wechat-qr-img" alt="" width="220" height="220" hidden />
|
||||
<p class="robot-wechat-qr-fallback">
|
||||
<a id="robot-wechat-qr-link" href="#" target="_blank" rel="noopener noreferrer" hidden data-i18n="settings.robots.wechat.openLink">无法显示二维码?点击用手机微信打开链接</a>
|
||||
</p>
|
||||
<p id="robot-wechat-qr-status" class="robot-wechat-qr-status"></p>
|
||||
<div id="robot-wechat-verify-wrap" class="robot-wechat-verify-wrap" hidden>
|
||||
<label for="robot-wechat-verify-code" data-i18n="settings.robots.wechat.verifyCodeLabel">手机显示的数字(仅部分账号需要)</label>
|
||||
<div class="robot-wechat-verify-row">
|
||||
<input type="text" id="robot-wechat-verify-code" inputmode="numeric" autocomplete="one-time-code" maxlength="8" placeholder="000000" />
|
||||
<button type="button" class="btn-primary btn-sm" onclick="submitWechatVerifyCode()" data-i18n="settings.robots.wechat.verifyCodeSubmit">提交</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="settings-collapsible robot-wechat-advanced" id="robot-wechat-advanced">
|
||||
<summary data-i18n="settings.robots.wechat.advanced">高级设置</summary>
|
||||
<div class="form-group">
|
||||
<label for="robot-wechat-base-url" data-i18n="settings.robots.wechat.baseUrl">API Base URL</label>
|
||||
<input type="text" id="robot-wechat-base-url" placeholder="https://ilinkai.weixin.qq.com" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="robot-wechat-bot-type" data-i18n="settings.robots.wechat.botType">Bot Type</label>
|
||||
<input type="text" id="robot-wechat-bot-type" placeholder="3" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="robot-wechat-bot-agent" data-i18n="settings.robots.wechat.botAgent">Bot Agent</label>
|
||||
<input type="text" id="robot-wechat-bot-agent" placeholder="CyberStrikeAI/1.0" autocomplete="off" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="robot-wechat-ilink-bot-id" data-i18n="settings.robots.wechat.ilinkBotId">iLink Bot ID(绑定后自动填充)</label>
|
||||
<input type="text" id="robot-wechat-ilink-bot-id" readonly autocomplete="off" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业微信 -->
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settings.robots.wecom.title">企业微信</h4>
|
||||
@@ -3501,12 +3615,13 @@
|
||||
<script src="/static/js/chat.js"></script>
|
||||
<script src="/static/js/hitl.js"></script>
|
||||
<script src="/static/js/settings.js"></script>
|
||||
<script src="/static/js/wechat-robot.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.5.0/lib/xterm-addon-fit.js"></script>
|
||||
<script src="/static/js/terminal.js"></script>
|
||||
<script src="/static/js/knowledge.js"></script>
|
||||
<script src="/static/js/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/chat-files.js"></script>
|
||||
<script src="/static/js/tasks.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user