Compare commits

..

39 Commits

Author SHA1 Message Date
公明 ba77e1837e Update config.yaml 2026-05-19 23:05:52 +08:00
公明 eacad60fd6 Add files via upload 2026-05-19 23:03:04 +08:00
公明 70bf5c93bf Update config.yaml 2026-05-19 19:01:31 +08:00
公明 08bd278d8c Update config.yaml 2026-05-19 18:56:24 +08:00
公明 22746d64a3 Add files via upload 2026-05-19 18:53:46 +08:00
公明 199392a5d5 Add files via upload 2026-05-19 18:52:22 +08:00
公明 aafb4cb584 Add files via upload 2026-05-19 18:50:28 +08:00
公明 96e3dd397c Add files via upload 2026-05-19 18:48:17 +08:00
公明 ec0f17145b Add files via upload 2026-05-19 17:50:38 +08:00
公明 ed53da0999 Delete security directory 2026-05-19 17:49:21 +08:00
公明 dc440fc511 Delete robot directory 2026-05-19 17:49:10 +08:00
公明 009ae59033 Delete logger directory 2026-05-19 17:48:59 +08:00
公明 f348b3245a Delete knowledge directory 2026-05-19 17:48:44 +08:00
公明 0018c5219c Delete config directory 2026-05-19 17:48:33 +08:00
公明 01a3e3677a Delete c2 directory 2026-05-19 17:48:22 +08:00
公明 a12ecdb46f Add files via upload 2026-05-19 17:47:56 +08:00
公明 9f59230d74 Add files via upload 2026-05-19 17:46:33 +08:00
公明 085c6a1c72 Add files via upload 2026-05-19 17:43:45 +08:00
公明 7b3860971f Add files via upload 2026-05-19 17:42:12 +08:00
公明 f6f7b7b237 Add files via upload 2026-05-19 17:40:19 +08:00
公明 d5cf4b3b16 Add files via upload 2026-05-19 16:48:07 +08:00
公明 3e58d8355b Add files via upload 2026-05-19 16:32:38 +08:00
公明 eb01ade63b Add files via upload 2026-05-19 16:29:05 +08:00
公明 d1dc15fa44 Add files via upload 2026-05-19 16:27:29 +08:00
公明 73a39ef868 Add files via upload 2026-05-19 16:25:47 +08:00
公明 a022baef03 Add files via upload 2026-05-19 16:23:21 +08:00
公明 59312d428e Add files via upload 2026-05-19 14:53:07 +08:00
公明 951d14ef14 Update config.yaml 2026-05-18 23:51:19 +08:00
公明 0eb22da6e9 Add files via upload 2026-05-18 23:50:55 +08:00
公明 5fd9ef0514 Add files via upload 2026-05-18 23:47:10 +08:00
公明 9a4f3c7d35 Add files via upload 2026-05-18 17:37:29 +08:00
公明 ead2ce3ecc Add files via upload 2026-05-18 17:28:14 +08:00
公明 8733f3a2d2 Update config.yaml 2026-05-18 11:03:29 +08:00
公明 8642f3ba31 Add files via upload 2026-05-17 17:11:16 +08:00
公明 6a262a7367 Add files via upload 2026-05-17 17:09:16 +08:00
公明 eb9192ddb3 Add files via upload 2026-05-17 17:08:42 +08:00
公明 5587e75628 Add files via upload 2026-05-17 17:06:53 +08:00
公明 74bbb453e2 Add files via upload 2026-05-17 17:05:22 +08:00
公明 66842f6206 Add files via upload 2026-05-17 17:01:48 +08:00
58 changed files with 6051 additions and 494 deletions
+10 -2
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.15"
version: "v1.6.18"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -54,7 +54,7 @@ openai:
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinkingextended thinking),mode: off 关闭
reasoning:
mode: off # auto | on | offoff 时不附加任何推理扩展字段
mode: on # auto | on | offoff 时不附加任何推理扩展字段
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
@@ -235,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: ""
+2 -1
View File
@@ -27,12 +27,14 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/pkoukk/tiktoken-go v0.1.8
github.com/robfig/cron/v3 v3.0.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
go.opentelemetry.io/otel v1.34.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0
go.opentelemetry.io/otel/sdk v1.34.0
go.opentelemetry.io/otel/trace v1.34.0
go.uber.org/zap v1.26.0
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 +90,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.35.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
+2 -2
View File
@@ -163,6 +163,8 @@ github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtIS
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
@@ -245,8 +247,6 @@ 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=
+32 -8
View File
@@ -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) != "" {
+167
View File
@@ -0,0 +1,167 @@
package agent
import (
"encoding/json"
"strings"
)
// ParseTraceMessages 解析落库的 last_react_inputOpenAI 风格 messages JSON 数组)。
func ParseTraceMessages(traceInputJSON string) ([]ChatMessage, error) {
traceInputJSON = strings.TrimSpace(traceInputJSON)
if traceInputJSON == "" {
return nil, nil
}
var raw []map[string]interface{}
if err := json.Unmarshal([]byte(traceInputJSON), &raw); err != nil {
return nil, err
}
out := make([]ChatMessage, 0, len(raw))
for _, msgMap := range raw {
msg := ChatMessage{}
role, _ := msgMap["role"].(string)
if role == "" {
continue
}
msg.Role = role
if content, ok := msgMap["content"].(string); ok {
msg.Content = content
}
if rc, ok := msgMap["reasoning_content"].(string); ok && strings.TrimSpace(rc) != "" {
msg.ReasoningContent = rc
}
if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil {
if toolCallsArray, ok := toolCallsRaw.([]interface{}); ok {
for _, tcRaw := range toolCallsArray {
tcMap, ok := tcRaw.(map[string]interface{})
if !ok {
continue
}
toolCall := ToolCall{}
if id, ok := tcMap["id"].(string); ok {
toolCall.ID = id
}
if toolType, ok := tcMap["type"].(string); ok {
toolCall.Type = toolType
}
if funcMap, ok := tcMap["function"].(map[string]interface{}); ok {
toolCall.Function = FunctionCall{}
if name, ok := funcMap["name"].(string); ok {
toolCall.Function.Name = name
}
if argsRaw, ok := funcMap["arguments"]; ok {
if argsStr, ok := argsRaw.(string); ok {
var argsMap map[string]interface{}
if err := json.Unmarshal([]byte(argsStr), &argsMap); err == nil {
toolCall.Function.Arguments = argsMap
}
} else if argsMap, ok := argsRaw.(map[string]interface{}); ok {
toolCall.Function.Arguments = argsMap
}
}
}
if toolCall.ID != "" {
msg.ToolCalls = append(msg.ToolCalls, toolCall)
}
}
}
}
if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
msg.ToolCallID = toolCallID
}
if tn, ok := msgMap["tool_name"].(string); ok && strings.TrimSpace(tn) != "" {
msg.ToolName = strings.TrimSpace(tn)
} else if tn, ok := msgMap["name"].(string); ok && strings.TrimSpace(tn) != "" && strings.EqualFold(msg.Role, "tool") {
msg.ToolName = strings.TrimSpace(tn)
}
out = append(out, msg)
}
return out, nil
}
// ExtractLastUserTurnMessages 仅保留最后一次 user 提问起的消息(不含更早的用户轮次;跳过 system)。
// 与「继续对话」续跑所用轨迹范围一致:当前任务轮次,而非整段多轮对话历史。
func ExtractLastUserTurnMessages(msgs []ChatMessage) []ChatMessage {
if len(msgs) == 0 {
return msgs
}
lastUser := -1
for i, m := range msgs {
if strings.EqualFold(m.Role, "user") {
lastUser = i
}
}
if lastUser < 0 {
return msgs
}
trimmed := msgs[lastUser:]
out := make([]ChatMessage, 0, len(trimmed))
for _, m := range trimmed {
if strings.EqualFold(m.Role, "system") {
continue
}
out = append(out, m)
}
return out
}
// ExtractLastUserTurnTraceJSON 在 JSON 轨迹上裁剪为最后一次 user 起的片段(供落库格式直接处理)。
func ExtractLastUserTurnTraceJSON(traceInputJSON string) string {
traceInputJSON = strings.TrimSpace(traceInputJSON)
if traceInputJSON == "" {
return traceInputJSON
}
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(traceInputJSON), &arr); err != nil {
return traceInputJSON
}
lastUser := -1
for i, m := range arr {
if r, _ := m["role"].(string); strings.EqualFold(r, "user") {
lastUser = i
}
}
if lastUser <= 0 {
return traceInputJSON
}
trimmed := arr[lastUser:]
b, err := json.Marshal(trimmed)
if err != nil {
return traceInputJSON
}
return string(b)
}
// MergeAssistantTraceOutput 将 last_react_output 合并进轨迹最后一条 assistant(与 loadHistoryFromAgentTrace 一致)。
func MergeAssistantTraceOutput(msgs []ChatMessage, assistantOut string) []ChatMessage {
assistantOut = strings.TrimSpace(assistantOut)
if assistantOut == "" || len(msgs) == 0 {
return msgs
}
out := append([]ChatMessage(nil), msgs...)
last := &out[len(out)-1]
if strings.EqualFold(last.Role, "assistant") && len(last.ToolCalls) == 0 {
last.Content = assistantOut
return out
}
out = append(out, ChatMessage{
Role: "assistant",
Content: assistantOut,
})
return out
}
// MessagesToTraceJSON 将消息带序列化为 JSON(跳过 system)。
func MessagesToTraceJSON(msgs []ChatMessage) (string, error) {
filtered := make([]ChatMessage, 0, len(msgs))
for _, m := range msgs {
if strings.EqualFold(m.Role, "system") {
continue
}
filtered = append(filtered, m)
}
b, err := json.Marshal(filtered)
if err != nil {
return "", err
}
return string(b), nil
}
+57
View File
@@ -0,0 +1,57 @@
package agent
import (
"encoding/json"
"testing"
)
func TestExtractLastUserTurnTraceJSON(t *testing.T) {
raw := []map[string]interface{}{
{"role": "user", "content": "old question"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "new target 1.1.1.1"},
{"role": "assistant", "tool_calls": []interface{}{map[string]interface{}{
"id": "c1", "type": "function",
"function": map[string]interface{}{"name": "nmap", "arguments": "{}"},
}}},
{"role": "tool", "tool_call_id": "c1", "content": "open ports"},
}
b, _ := json.Marshal(raw)
out := ExtractLastUserTurnTraceJSON(string(b))
var trimmed []map[string]interface{}
if err := json.Unmarshal([]byte(out), &trimmed); err != nil {
t.Fatal(err)
}
if len(trimmed) != 3 {
t.Fatalf("expected 3 messages, got %d", len(trimmed))
}
if trimmed[0]["content"] != "new target 1.1.1.1" {
t.Fatalf("unexpected first message: %v", trimmed[0])
}
}
func TestExtractLastUserTurnMessagesSkipsSystem(t *testing.T) {
msgs := []ChatMessage{
{Role: "system", Content: "sys"},
{Role: "user", Content: "q"},
{Role: "assistant", Content: "a"},
}
out := ExtractLastUserTurnMessages(msgs)
if len(out) != 2 {
t.Fatalf("expected 2, got %d", len(out))
}
if out[0].Role != "user" {
t.Fatal("expected user first")
}
}
func TestMergeAssistantTraceOutput(t *testing.T) {
msgs := []ChatMessage{
{Role: "user", Content: "q"},
{Role: "assistant", Content: "draft"},
}
out := MergeAssistantTraceOutput(msgs, "final summary")
if out[len(out)-1].Content != "final summary" {
t.Fatalf("expected merged output, got %q", out[len(out)-1].Content)
}
}
+22 -2
View File
@@ -56,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 // 看门狗取消函数
@@ -449,9 +450,11 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*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 {
@@ -469,6 +472,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
notificationHandler,
conversationHandler,
robotHandler,
wechatRobotHandler,
groupHandler,
configHandler,
externalMCPHandler,
@@ -675,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 {
@@ -688,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)
@@ -703,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,
@@ -751,6 +765,12 @@ func setupRoutes(
// 机器人测试(需登录):POST /api/robot/testbody: {"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 流式输出
+85 -66
View File
@@ -82,7 +82,7 @@ func NewBuilder(db *database.DB, openAIConfig *config.OpenAIConfig, logger *zap.
}
}
// BuildChainFromConversation 从对话构建攻击链(简化版本:用户输入+最后一轮ReAct输入+大模型输出)
// BuildChainFromConversation 从对话构建攻击链(单次 LLM 调用;输入为当前任务轮次的 last_react 轨迹,与继续对话续跑范围一致)。
func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID string) (*Chain, error) {
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 {
+248
View File
@@ -0,0 +1,248 @@
package attackchain
import (
"strings"
"unicode/utf8"
"go.uber.org/zap"
)
const (
attackChainTruncationMarker = "\n\n...[攻击链输入已截断 / attack chain input truncated]...\n\n"
attackChainSystemReserve = 256
attackChainSafetyReserve = 2048
)
// attackChainMaxCompletionTokens 为攻击链 JSON 输出预留的 completion token 上限。
func attackChainMaxCompletionTokens(maxTotal int) int {
const capTokens = 16384
if maxTotal <= 0 {
return 8192
}
v := maxTotal / 8
if v < 4096 {
v = 4096
}
if v > capTokens {
v = capTokens
}
return v
}
func (b *Builder) modelName() string {
if b.openAIConfig != nil && b.openAIConfig.Model != "" {
return b.openAIConfig.Model
}
return "gpt-4"
}
func (b *Builder) countTokens(text string) int {
if text == "" {
return 0
}
n, err := b.tokenCounter.Count(b.modelName(), text)
if err != nil {
return utf8.RuneCountInString(text) / 4
}
return n
}
// attackChainPayloadTokenBudget 计算 reactInput + modelOutput 可用的 token 预算。
func (b *Builder) attackChainPayloadTokenBudget() int {
maxTotal := b.maxTokens
if maxTotal <= 0 {
maxTotal = 100000
}
templateTok := b.countTokens(b.buildSimplePrompt("", ""))
completion := attackChainMaxCompletionTokens(maxTotal)
reserve := templateTok + attackChainSystemReserve + completion + attackChainSafetyReserve
budget := maxTotal - reserve
minBudget := maxTotal * 35 / 100
if budget < minBudget {
budget = minBudget
}
if budget < 4096 {
budget = 4096
}
return budget
}
// fitAttackChainPayload 在构建最终 prompt 前压缩 ReAct 轨迹与模型输出,避免超出模型上下文。
func (b *Builder) fitAttackChainPayload(reactInput, modelOutput string) (string, string, bool) {
budget := b.attackChainPayloadTokenBudget()
modelBudget := budget * 15 / 100
if modelBudget < 512 {
modelBudget = 512
}
reactBudget := budget - modelBudget
origReactTok := b.countTokens(reactInput)
origModelTok := b.countTokens(modelOutput)
truncated := false
outModel := modelOutput
if origModelTok > modelBudget {
outModel = truncateTextByTokens(b, modelOutput, modelBudget)
truncated = true
}
outReact := reactInput
perToolLimits := []int{12000, 6000, 3000, 1500, 800}
for _, lim := range perToolLimits {
compact := compactFormattedToolBodies(outReact, lim)
if compact != outReact {
outReact = compact
truncated = true
}
if b.countTokens(outReact) <= reactBudget {
break
}
}
if b.countTokens(outReact) > reactBudget {
outReact = truncateTextByTokens(b, outReact, reactBudget)
truncated = true
}
if truncated {
b.logger.Info("攻击链输入已按 token 预算截断",
zap.Int("maxTotalTokens", b.maxTokens),
zap.Int("payloadBudget", budget),
zap.Int("reactBudget", reactBudget),
zap.Int("modelBudget", modelBudget),
zap.Int("reactInputTokensBefore", origReactTok),
zap.Int("reactInputTokensAfter", b.countTokens(outReact)),
zap.Int("modelOutputTokensBefore", origModelTok),
zap.Int("modelOutputTokensAfter", b.countTokens(outModel)),
zap.Int("maxCompletionTokens", attackChainMaxCompletionTokens(b.maxTokens)),
)
}
return outReact, outModel, truncated
}
// compactFormattedToolBodies 缩短格式化 trace 中 [tool] 消息的正文,保留工具头与调用 ID。
func compactFormattedToolBodies(s string, maxRunesPerBody int) string {
if maxRunesPerBody <= 0 || s == "" {
return s
}
const marker = "[tool]"
var out strings.Builder
remaining := s
changed := false
for {
idx := strings.Index(remaining, marker)
if idx < 0 {
out.WriteString(remaining)
break
}
out.WriteString(remaining[:idx])
remaining = remaining[idx:]
nl := strings.IndexByte(remaining, '\n')
if nl < 0 {
out.WriteString(remaining)
break
}
header := remaining[:nl+1]
remaining = remaining[nl+1:]
bodyEnd := strings.Index(remaining, "\n\n[")
var body, rest string
if bodyEnd < 0 {
body = remaining
rest = ""
} else {
body = remaining[:bodyEnd]
rest = remaining[bodyEnd:]
}
if runeLen(body) > maxRunesPerBody {
body = truncateRunesWithNotice(body, maxRunesPerBody)
changed = true
}
out.WriteString(header)
out.WriteString(body)
remaining = rest
if rest == "" {
break
}
}
if !changed {
return s
}
return out.String()
}
func truncateTextByTokens(b *Builder, text string, maxTokens int) string {
if maxTokens <= 0 || text == "" {
return ""
}
if b.countTokens(text) <= maxTokens {
return text
}
markerTok := b.countTokens(attackChainTruncationMarker)
usable := maxTokens - markerTok
if usable < 256 {
usable = maxTokens / 2
}
headBudget := usable * 60 / 100
tailBudget := usable - headBudget
head := takeTokensFromStart(b, text, headBudget)
tail := takeTokensFromEnd(b, text, tailBudget)
return head + attackChainTruncationMarker + tail
}
func takeTokensFromStart(b *Builder, text string, maxTokens int) string {
rs := []rune(text)
if len(rs) == 0 || maxTokens <= 0 {
return ""
}
lo, hi := 0, len(rs)
for lo < hi {
mid := (lo + hi + 1) / 2
if b.countTokens(string(rs[:mid])) <= maxTokens {
lo = mid
} else {
hi = mid - 1
}
}
return string(rs[:lo])
}
func takeTokensFromEnd(b *Builder, text string, maxTokens int) string {
rs := []rune(text)
if len(rs) == 0 || maxTokens <= 0 {
return ""
}
lo, hi := 0, len(rs)
for lo < hi {
mid := (lo + hi) / 2
if b.countTokens(string(rs[mid:])) <= maxTokens {
hi = mid
} else {
lo = mid + 1
}
}
return string(rs[lo:])
}
func truncateRunesWithNotice(s string, maxRunes int) string {
rs := []rune(s)
if len(rs) <= maxRunes {
return s
}
const notice = "\n...[工具输出已截断 / tool output truncated]...\n"
noticeRunes := []rune(notice)
keep := maxRunes - len(noticeRunes)
if keep < 200 {
keep = maxRunes * 2 / 3
}
if keep < 1 {
return notice
}
head := keep * 70 / 100
tail := keep - head
return string(rs[:head]) + notice + string(rs[len(rs)-tail:])
}
func runeLen(s string) int {
return len([]rune(s))
}
+63
View File
@@ -0,0 +1,63 @@
package attackchain
import (
"strings"
"testing"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"go.uber.org/zap"
)
func testBuilder(maxTotal int) *Builder {
return &Builder{
logger: zap.NewNop(),
openAIConfig: &config.OpenAIConfig{Model: "gpt-4"},
tokenCounter: agent.NewTikTokenCounter(),
maxTokens: maxTotal,
}
}
func TestCompactFormattedToolBodies(t *testing.T) {
long := strings.Repeat("x", 20000)
in := "[user]: hi\n\n[tool] (tool_call_id: abc):\n" + long + "\n\n[assistant]: done\n"
out := compactFormattedToolBodies(in, 500)
if strings.Contains(out, strings.Repeat("x", 10000)) {
t.Fatal("expected tool body to be truncated")
}
if !strings.Contains(out, "[user]: hi") {
t.Fatal("expected user header preserved")
}
if !strings.Contains(out, "[assistant]: done") {
t.Fatal("expected assistant header preserved")
}
}
func TestFitAttackChainPayloadWithinBudget(t *testing.T) {
b := testBuilder(32000)
react := strings.Repeat("scan ", 50000)
model := strings.Repeat("result ", 10000)
r, m, truncated := b.fitAttackChainPayload(react, model)
if !truncated {
t.Fatal("expected truncation for large payload")
}
prompt := b.buildSimplePrompt(r, m)
total := b.countTokens(prompt) + attackChainMaxCompletionTokens(b.maxTokens) + attackChainSystemReserve
if total > b.maxTokens+attackChainSafetyReserve {
t.Fatalf("prompt still too large: estimated %d > max %d", total, b.maxTokens)
}
_ = m
}
func TestAttackChainMaxCompletionTokens(t *testing.T) {
if got := attackChainMaxCompletionTokens(120000); got != 15000 && got != 16384 {
// 120000/8 = 15000
if got < 4096 || got > 16384 {
t.Fatalf("unexpected completion cap: %d", got)
}
}
if got := attackChainMaxCompletionTokens(0); got != 8192 {
t.Fatalf("expected default 8192, got %d", got)
}
}
+4 -2
View File
@@ -239,13 +239,15 @@ func (m *Manager) StartListener(id string) (*database.C2Listener, error) {
}
cfg.ApplyDefaults()
// 通过工厂创建具体实现
// 通过工厂创建具体实现。必须使用 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)),
+74
View File
@@ -0,0 +1,74 @@
package c2
import (
"io"
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/database"
"go.uber.org/zap"
)
// 回归:StartListener 返回的 rec 被 handler 脱敏清空 ImplantToken 后,运行中的 HTTP listener 仍能鉴权。
func TestStartListener_ImplantTokenSurvivesHandlerRedaction(t *testing.T) {
tmp := t.TempDir()
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
lnPick, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := lnPick.Addr().(*net.TCPAddr).Port
_ = lnPick.Close()
mgr := NewManager(db, zap.NewNop(), tmp)
mgr.Registry().Register(string(ListenerTypeHTTPBeacon), NewHTTPBeaconListener)
rec, err := mgr.CreateListener(CreateListenerInput{
Name: "t",
Type: string(ListenerTypeHTTPBeacon),
BindHost: "127.0.0.1",
BindPort: port,
})
if err != nil {
t.Fatal(err)
}
token := rec.ImplantToken
rec, err = mgr.StartListener(rec.ID)
if err != nil {
t.Fatal(err)
}
// 模拟 internal/handler/c2.go StartListener 在 JSON 响应前的脱敏
rec.ImplantToken = ""
rec.EncryptionKey = ""
time.Sleep(50 * time.Millisecond)
body := `{"hostname":"n","username":"u","os":"Linux","arch":"amd64","internal_ip":"10.0.0.1","pid":42}`
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:"+strconv.Itoa(port)+"/check_in", strings.NewReader(body))
req.Header.Set("X-Implant-Token", token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
}
if !strings.Contains(string(b), "session_id") {
t.Fatalf("expected session_id in body: %s", b)
}
_ = mgr.StopListener(rec.ID)
}
+14 -1
View File
@@ -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 兜底
+17 -3
View File
@@ -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
+29
View File
@@ -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)
+293
View File
@@ -0,0 +1,293 @@
package handler
import (
"context"
"net/http"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/robot/ilink"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
const wechatLoginTTL = 5 * time.Minute
// WechatConfigSaver 绑定成功后写入配置并重启机器人连接
type WechatConfigSaver interface {
ApplyWechatRobotBinding(cfg config.RobotWechatConfig) error
}
type wechatLoginSession struct {
QRCode string
QRCodeImgURL string
PendingVerify string
CurrentBaseURL string
StartedAt time.Time
}
// WechatRobotHandler 微信 iLink 机器人(扫码绑定 + 配置)
type WechatRobotHandler struct {
config *config.Config
configSaver WechatConfigSaver
logger *zap.Logger
mu sync.Mutex
logins map[string]*wechatLoginSession
}
// NewWechatRobotHandler 创建微信机器人处理器
func NewWechatRobotHandler(cfg *config.Config, saver WechatConfigSaver, logger *zap.Logger) *WechatRobotHandler {
return &WechatRobotHandler{
config: cfg,
configSaver: saver,
logger: logger,
logins: make(map[string]*wechatLoginSession),
}
}
func (h *WechatRobotHandler) purgeExpiredLogins() {
now := time.Now()
for k, v := range h.logins {
if now.Sub(v.StartedAt) > wechatLoginTTL {
delete(h.logins, k)
}
}
}
func (h *WechatRobotHandler) ilinkClient(baseURL string) *ilink.Client {
ver := h.config.Version
if ver == "" {
ver = "1.0.0"
}
ver = strings.TrimPrefix(strings.TrimSpace(ver), "v")
ver = strings.TrimPrefix(ver, "V")
wc := h.config.Robots.Wechat
return ilink.NewClient(baseURL, wc.BotToken, wc.BotAgent, ilink.BuildClientVersion(ver))
}
// HandleWechatQRCode POST /api/robot/wechat/qrcode — 生成绑定二维码
func (h *WechatRobotHandler) HandleWechatQRCode(c *gin.Context) {
h.mu.Lock()
h.purgeExpiredLogins()
h.mu.Unlock()
var req struct {
BotType string `json:"bot_type"`
}
_ = c.ShouldBindJSON(&req)
botType := req.BotType
if botType == "" {
botType = h.config.Robots.Wechat.BotType
}
if botType == "" {
botType = ilink.DefaultBotType
}
baseURL := h.config.Robots.Wechat.BaseURL
if baseURL == "" {
baseURL = ilink.DefaultBaseURL
}
var localTokens []string
if t := h.config.Robots.Wechat.BotToken; t != "" {
localTokens = []string{t}
}
client := h.ilinkClient(baseURL)
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
qr, err := client.GetBotQRCode(ctx, botType, localTokens)
if err != nil {
h.logger.Warn("获取微信二维码失败", zap.Error(err))
c.JSON(http.StatusBadGateway, gin.H{"error": "获取二维码失败: " + err.Error()})
return
}
if qr.QRCode == "" || qr.QRCodeImgContent == "" {
c.JSON(http.StatusBadGateway, gin.H{"error": "微信服务器未返回有效二维码"})
return
}
sessionKey := uuid.New().String()
h.mu.Lock()
h.logins[sessionKey] = &wechatLoginSession{
QRCode: qr.QRCode,
QRCodeImgURL: qr.QRCodeImgContent,
CurrentBaseURL: baseURL,
StartedAt: time.Now(),
}
h.mu.Unlock()
resp := gin.H{
"session_key": sessionKey,
"qrcode": qr.QRCode,
"qrcode_open_url": qr.QRCodeImgContent,
"message": "请使用微信扫描二维码并确认绑定",
}
if dataURL, err := ilink.QRCodeDataURL(qr.QRCodeImgContent, 256); err != nil {
h.logger.Warn("生成二维码图片失败", zap.Error(err))
} else {
resp["qrcode_image_data_url"] = dataURL
}
c.JSON(http.StatusOK, resp)
}
// HandleWechatQRCodeStatus GET /api/robot/wechat/qrcode/status — 轮询扫码状态
func (h *WechatRobotHandler) HandleWechatQRCodeStatus(c *gin.Context) {
sessionKey := c.Query("session_key")
verifyCode := c.Query("verify_code")
if sessionKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 session_key"})
return
}
h.mu.Lock()
sess, ok := h.logins[sessionKey]
h.mu.Unlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "登录会话不存在或已过期,请重新生成二维码"})
return
}
if time.Since(sess.StartedAt) > wechatLoginTTL {
h.mu.Lock()
delete(h.logins, sessionKey)
h.mu.Unlock()
c.JSON(http.StatusGone, gin.H{"error": "二维码已过期,请重新生成"})
return
}
baseURL := sess.CurrentBaseURL
if baseURL == "" {
baseURL = ilink.DefaultBaseURL
}
vc := verifyCode
if vc == "" {
vc = sess.PendingVerify
}
client := h.ilinkClient(baseURL)
ctx, cancel := context.WithTimeout(c.Request.Context(), 40*time.Second)
defer cancel()
st, err := client.GetQRCodeStatus(ctx, sess.QRCode, vc)
if err != nil {
h.logger.Warn("轮询微信二维码状态失败", zap.Error(err))
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
switch st.Status {
case "wait", "scaned":
c.JSON(http.StatusOK, gin.H{"status": st.Status})
return
case "need_verifycode":
c.JSON(http.StatusOK, gin.H{
"status": st.Status,
"message": "请在手机微信查看配对数字,并在下方输入",
})
return
case "scaned_but_redirect":
if st.RedirectHost != "" {
h.mu.Lock()
if s, ok := h.logins[sessionKey]; ok {
s.CurrentBaseURL = "https://" + st.RedirectHost
}
h.mu.Unlock()
}
c.JSON(http.StatusOK, gin.H{"status": st.Status})
return
case "binded_redirect":
h.mu.Lock()
delete(h.logins, sessionKey)
h.mu.Unlock()
c.JSON(http.StatusOK, gin.H{
"status": st.Status,
"already_connected": true,
"message": "该微信已绑定过,无需重复绑定",
})
return
case "confirmed":
if st.BotToken == "" || st.ILinkBotID == "" {
c.JSON(http.StatusBadGateway, gin.H{"error": "绑定确认成功但缺少 bot_token"})
return
}
saveBase := st.BaseURL
if saveBase == "" {
saveBase = baseURL
}
wc := h.config.Robots.Wechat
wc.Enabled = true
wc.BotToken = st.BotToken
wc.ILinkBotID = st.ILinkBotID
wc.ILinkUserID = st.ILinkUserID
wc.BaseURL = saveBase
if wc.BotType == "" {
wc.BotType = ilink.DefaultBotType
}
if wc.BotAgent == "" {
wc.BotAgent = ilink.DefaultBotAgent
}
if h.configSaver != nil {
if err := h.configSaver.ApplyWechatRobotBinding(wc); err != nil {
h.logger.Warn("保存微信机器人配置失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存配置失败: " + err.Error()})
return
}
} else {
h.config.Robots.Wechat = wc
}
h.mu.Lock()
delete(h.logins, sessionKey)
h.mu.Unlock()
c.JSON(http.StatusOK, gin.H{
"status": "confirmed",
"message": "绑定成功,微信机器人已启用",
"ilink_bot_id": st.ILinkBotID,
"ilink_user_id": st.ILinkUserID,
})
return
default:
c.JSON(http.StatusOK, gin.H{"status": st.Status})
}
}
// HandleWechatVerifyCode POST /api/robot/wechat/qrcode/verify — 提交手机配对数字
func (h *WechatRobotHandler) HandleWechatVerifyCode(c *gin.Context) {
var req struct {
SessionKey string `json:"session_key"`
VerifyCode string `json:"verify_code"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.SessionKey == "" || req.VerifyCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "需要 session_key 与 verify_code"})
return
}
h.mu.Lock()
sess, ok := h.logins[req.SessionKey]
if ok {
sess.PendingVerify = req.VerifyCode
}
h.mu.Unlock()
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "登录会话不存在或已过期"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "已提交配对码,请继续等待绑定"})
}
// HandleWechatStatus GET /api/robot/wechat/status — 当前绑定状态(供前端展示)
func (h *WechatRobotHandler) HandleWechatStatus(c *gin.Context) {
wc := h.config.Robots.Wechat
bound := wc.BotToken != "" && wc.ILinkBotID != ""
c.JSON(http.StatusOK, gin.H{
"enabled": wc.Enabled,
"bound": bound,
"ilink_bot_id": wc.ILinkBotID,
"ilink_user_id": wc.ILinkUserID,
"base_url": wc.BaseURL,
})
}
+36 -23
View File
@@ -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_executeplanner ↔ 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") {
+32 -5
View File
@@ -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 {
+20
View File
@@ -0,0 +1,20 @@
package openai
// SSEAccumulatedKey 为 SSE progress 事件 data 中的服务端权威流式全文快照字段。
// 前端应优先用该字段更新 buffer,避免对 delta 二次 normalize 导致叠字。
const SSEAccumulatedKey = "accumulated"
// WithSSEAccumulated 在 progress data 中附带当前流式累计全文(权威快照)。
func WithSSEAccumulated(data map[string]interface{}, accumulated string) map[string]interface{} {
if data == nil {
data = make(map[string]interface{}, 1)
}
data[SSEAccumulatedKey] = accumulated
return data
}
// NormalizeStreamingDelta 将可能是“累计片段/重发片段”的内容归一化为“纯增量”。
// 与 unexported normalizeStreamingDelta 相同,供 agent / multiagent 等包在发 SSE 前累计正文。
func NormalizeStreamingDelta(current, incoming string) (next, delta string) {
return normalizeStreamingDelta(current, incoming)
}
+316
View File
@@ -0,0 +1,316 @@
package ilink
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
DefaultBaseURL = "https://ilinkai.weixin.qq.com"
DefaultBotType = "3"
DefaultBotAgent = "CyberStrikeAI/1.0"
ILinkAppID = "bot"
QRLongPollTimeout = 35 * time.Second
APIDefaultTimeout = 15 * time.Second
GetUpdatesTimeout = 35 * time.Second
)
// Client 微信 iLink Bot HTTP 客户端(与 @tencent-weixin/openclaw-weixin 协议兼容)
type Client struct {
BaseURL string
BotToken string
BotAgent string
ClientVersion uint32
HTTP *http.Client
}
func NewClient(baseURL, botToken, botAgent string, clientVersion uint32) *Client {
base := strings.TrimSpace(baseURL)
if base == "" {
base = DefaultBaseURL
}
agent := strings.TrimSpace(botAgent)
if agent == "" {
agent = DefaultBotAgent
}
return &Client{
BaseURL: strings.TrimRight(base, "/"),
BotToken: strings.TrimSpace(botToken),
BotAgent: sanitizeBotAgent(agent),
ClientVersion: clientVersion,
HTTP: &http.Client{Timeout: 0},
}
}
// BuildClientVersion 将 semver 编码为 iLink-App-ClientVersion0x00MMNNPP
func BuildClientVersion(version string) uint32 {
parts := strings.Split(version, ".")
parse := func(i int) int {
if i >= len(parts) {
return 0
}
n, _ := strconv.Atoi(strings.TrimSpace(parts[i]))
if n < 0 {
return 0
}
return n
}
major := parse(0) & 0xff
minor := parse(1) & 0xff
patch := parse(2) & 0xff
return uint32((major << 16) | (minor << 8) | patch)
}
type baseInfo struct {
ChannelVersion string `json:"channel_version"`
BotAgent string `json:"bot_agent"`
}
func (c *Client) buildBaseInfo() baseInfo {
return baseInfo{
ChannelVersion: "1.0.0",
BotAgent: c.BotAgent,
}
}
func randomWechatUIN() string {
var b [4]byte
_, _ = rand.Read(b[:])
u := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(u), 10)))
}
func (c *Client) commonHeaders() http.Header {
h := http.Header{}
h.Set("iLink-App-Id", ILinkAppID)
h.Set("iLink-App-ClientVersion", strconv.FormatUint(uint64(c.ClientVersion), 10))
return h
}
func (c *Client) authHeaders() http.Header {
h := c.commonHeaders()
h.Set("Content-Type", "application/json")
h.Set("AuthorizationType", "ilink_bot_token")
h.Set("X-WECHAT-UIN", randomWechatUIN())
if c.BotToken != "" {
h.Set("Authorization", "Bearer "+c.BotToken)
}
return h
}
func (c *Client) endpointURL(path string) (string, error) {
u, err := url.Parse(c.BaseURL + "/")
if err != nil {
return "", err
}
ref, err := url.Parse(path)
if err != nil {
return "", err
}
return u.ResolveReference(ref).String(), nil
}
func (c *Client) doRequest(ctx context.Context, method, path string, body []byte, headers http.Header, timeout time.Duration) ([]byte, error) {
reqURL, err := c.endpointURL(path)
if err != nil {
return nil, err
}
var bodyReader io.Reader
if len(body) > 0 {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
if err != nil {
return nil, err
}
for k, vs := range headers {
for _, v := range vs {
req.Header.Add(k, v)
}
}
client := c.HTTP
if client == nil {
client = http.DefaultClient
}
if timeout > 0 {
ctx2, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req = req.WithContext(ctx2)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("ilink %s %s: %d %s", method, path, resp.StatusCode, string(raw))
}
return raw, nil
}
// QRCodeResponse 获取二维码响应
type QRCodeResponse struct {
QRCode string `json:"qrcode"`
QRCodeImgContent string `json:"qrcode_img_content"`
}
// GetBotQRCode 获取绑定二维码
func (c *Client) GetBotQRCode(ctx context.Context, botType string, localTokenList []string) (*QRCodeResponse, error) {
if strings.TrimSpace(botType) == "" {
botType = DefaultBotType
}
body, _ := json.Marshal(map[string]interface{}{
"local_token_list": localTokenList,
})
path := "ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(botType)
raw, err := c.doRequest(ctx, http.MethodPost, path, body, c.authHeaders(), APIDefaultTimeout)
if err != nil {
return nil, err
}
var out QRCodeResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// QRStatusResponse 二维码状态轮询响应
type QRStatusResponse struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
ILinkBotID string `json:"ilink_bot_id"`
ILinkUserID string `json:"ilink_user_id"`
BaseURL string `json:"baseurl"`
RedirectHost string `json:"redirect_host"`
}
// GetQRCodeStatus 长轮询二维码扫码状态
func (c *Client) GetQRCodeStatus(ctx context.Context, qrcode, verifyCode string) (*QRStatusResponse, error) {
path := "ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
if verifyCode != "" {
path += "&verify_code=" + url.QueryEscape(verifyCode)
}
raw, err := c.doRequest(ctx, http.MethodGet, path, nil, c.commonHeaders(), QRLongPollTimeout)
if err != nil {
if ctx.Err() != nil {
return &QRStatusResponse{Status: "wait"}, nil
}
return &QRStatusResponse{Status: "wait"}, nil
}
var out QRStatusResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// MessageItem 消息内容项
type MessageItem struct {
Type int `json:"type"`
TextItem *struct {
Text string `json:"text"`
} `json:"text_item,omitempty"`
}
// WeixinMessage 入站消息
type WeixinMessage struct {
FromUserID string `json:"from_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ItemList []MessageItem `json:"item_list"`
ContextToken string `json:"context_token"`
}
// GetUpdatesResponse 长轮询消息响应
type GetUpdatesResponse struct {
Ret int `json:"ret"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Msgs []WeixinMessage `json:"msgs"`
GetUpdatesBuf string `json:"get_updates_buf"`
LongPollingTimeoutMs int `json:"longpolling_timeout_ms"`
}
// GetUpdates 长轮询获取新消息
func (c *Client) GetUpdates(ctx context.Context, getUpdatesBuf string) (*GetUpdatesResponse, error) {
body, _ := json.Marshal(map[string]interface{}{
"get_updates_buf": getUpdatesBuf,
"base_info": c.buildBaseInfo(),
})
raw, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/getupdates", body, c.authHeaders(), GetUpdatesTimeout)
if err != nil {
if ctx.Err() != nil {
return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil
}
return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil
}
var out GetUpdatesResponse
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return &out, nil
}
// SendTextMessage 发送文本回复
func (c *Client) SendTextMessage(ctx context.Context, toUserID, contextToken, text, clientID string) error {
if clientID == "" {
clientID = randomClientID()
}
payload := map[string]interface{}{
"msg": map[string]interface{}{
"to_user_id": toUserID,
"client_id": clientID,
"message_type": 2,
"message_state": 2,
"context_token": contextToken,
"item_list": []map[string]interface{}{
{"type": 1, "text_item": map[string]string{"text": text}},
},
},
"base_info": c.buildBaseInfo(),
}
body, _ := json.Marshal(payload)
_, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/sendmessage", body, c.authHeaders(), APIDefaultTimeout)
return err
}
func randomClientID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return fmt.Sprintf("%x", b)
}
func sanitizeBotAgent(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return DefaultBotAgent
}
if len(raw) > 256 {
return raw[:256]
}
return raw
}
// ExtractText 从消息中提取首条文本
func ExtractText(msg WeixinMessage) string {
for _, item := range msg.ItemList {
if item.Type == 1 && item.TextItem != nil {
return strings.TrimSpace(item.TextItem.Text)
}
}
return ""
}
+26
View File
@@ -0,0 +1,26 @@
package ilink
import (
"encoding/base64"
"fmt"
"strings"
"github.com/skip2/go-qrcode"
)
// QRCodeDataURL 将扫码内容(一般为 liteapp 链接)编码为 PNG data URL,供 Web 端展示。
// qrcode_img_content 不是图片直链,不能用作 <img src>。
func QRCodeDataURL(content string, size int) (string, error) {
content = strings.TrimSpace(content)
if content == "" {
return "", fmt.Errorf("empty qr content")
}
if size <= 0 {
size = 256
}
png, err := qrcode.Encode(content, qrcode.Medium, size)
if err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil
}
+96
View File
@@ -0,0 +1,96 @@
package robot
import (
"context"
"strings"
"time"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/robot/ilink"
"go.uber.org/zap"
)
const (
wechatReconnectInitial = 5 * time.Second
wechatReconnectMax = 60 * time.Second
wechatPlatform = "wechat"
)
// StartWechat 启动微信 iLink 长轮询(无需公网回调),收到消息后调用 handler 并回复。
func StartWechat(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, appVersion string, logger *zap.Logger) {
cfg := robotsCfg.Wechat
if !cfg.Enabled || cfg.BotToken == "" {
return
}
go runWechatLoop(ctx, cfg, h, appVersion, logger)
}
func runWechatLoop(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) {
backoff := wechatReconnectInitial
for {
err := runWechatPoll(ctx, cfg, h, appVersion, logger)
if ctx.Err() != nil {
logger.Info("微信 iLink 长轮询已按配置关闭")
return
}
if err != nil {
logger.Warn("微信 iLink 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
if backoff < wechatReconnectMax {
backoff *= 2
if backoff > wechatReconnectMax {
backoff = wechatReconnectMax
}
}
}
}
}
func runWechatPoll(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) error {
client := ilink.NewClient(cfg.BaseURL, cfg.BotToken, cfg.BotAgent, ilink.BuildClientVersion(appVersion))
buf := cfg.GetUpdatesBuf
logger.Info("微信 iLink 长轮询已启动", zap.String("ilink_bot_id", cfg.ILinkBotID))
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
resp, err := client.GetUpdates(ctx, buf)
if err != nil {
return err
}
if resp.ErrCode != 0 && resp.Ret != 0 {
logger.Warn("微信 getUpdates 返回错误", zap.Int("errcode", resp.ErrCode), zap.String("errmsg", resp.ErrMsg))
}
if resp.GetUpdatesBuf != "" {
buf = resp.GetUpdatesBuf
}
for _, msg := range resp.Msgs {
if msg.MessageType != 1 {
continue
}
text := ilink.ExtractText(msg)
if text == "" {
continue
}
userID := strings.TrimSpace(msg.FromUserID)
if userID == "" {
continue
}
logger.Info("微信收到消息", zap.String("from", userID), zap.String("content", text))
reply := h.HandleMessage(wechatPlatform, userID, text)
if strings.TrimSpace(reply) == "" {
continue
}
if err := client.SendTextMessage(ctx, userID, msg.ContextToken, reply, ""); err != nil {
logger.Warn("微信发送回复失败", zap.String("to", userID), zap.Error(err))
}
}
}
}
@@ -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`
@@ -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());
@@ -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");
@@ -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;
}
}
}
@@ -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
@@ -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
+28 -3
View File
@@ -857,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;
@@ -1261,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;
}
+1707 -23
View File
File diff suppressed because it is too large Load Diff
+60 -1
View File
@@ -836,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",
@@ -1306,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",
@@ -1486,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",
+60 -1
View File
@@ -825,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": "启用企业微信机器人",
@@ -1295,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": "开始时间",
@@ -1475,6 +1529,11 @@
},
"vulnerabilityPage": {
"statTotal": "总漏洞数",
"statClickAll": "查看全部(清除严重度筛选)",
"statClickFilter": "点击按此严重度筛选;再次点击清除",
"advancedFilters": "高级筛选",
"activeFilters": "已选条件",
"chipRemove": "移除",
"filter": "筛选",
"clear": "清除",
"vulnId": "漏洞ID",
+48 -20
View File
@@ -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 下拉选项 HTMLvalue / 文本已转义) */
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'));
};
// ============================================================================
+16 -3
View File
@@ -2357,6 +2357,9 @@ 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) {
// 显示空状态提示
@@ -2421,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;
@@ -2451,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 处理)
+868 -117
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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() || '',
+518 -35
View File
@@ -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);
@@ -74,23 +92,31 @@ function syncVulnerabilityFiltersFromLocationHash() {
const tid = (params.get('task_id') || '').trim();
const sev = (params.get('severity') || '').trim();
const st = (params.get('status') || '').trim();
if (!vid && !cid && !tid && !sev && !st) {
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 = '';
@@ -106,6 +132,14 @@ 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;
@@ -115,17 +149,457 @@ function syncVulnerabilityFiltersFromLocationHash() {
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 {
@@ -169,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();
}
// 加载漏洞列表
@@ -591,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: '',
@@ -628,11 +1114,7 @@ function clearVulnerabilityFilters() {
status: ''
};
// 重置到第一页
vulnerabilityPagination.currentPage = 1;
loadVulnerabilityStats();
loadVulnerabilities();
applyVulnerabilityFilters();
}
// 刷新漏洞
@@ -908,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
View File
@@ -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');
+350
View File
@@ -0,0 +1,350 @@
// 微信 iLink 机器人:扫码绑定与状态轮询
let wechatBindSessionKey = null;
let wechatBindPollTimer = null;
function wechatT(key, fallback) {
return typeof t === 'function' ? t(key) : fallback;
}
function getWechatCard() {
return document.getElementById('robot-wechat-subsection');
}
function setWechatBadge(mode) {
const badge = document.getElementById('robot-wechat-status-badge');
if (!badge) return;
badge.classList.remove('robot-wechat-badge--idle', 'robot-wechat-badge--bound', 'robot-wechat-badge--scanning');
if (mode === 'bound') {
badge.classList.add('robot-wechat-badge--bound');
badge.textContent = wechatT('settings.robots.wechat.statusBound', '已连接');
} else if (mode === 'scanning') {
badge.classList.add('robot-wechat-badge--scanning');
badge.textContent = wechatT('settings.robots.wechat.statusScanning', '绑定中…');
} else {
badge.classList.add('robot-wechat-badge--idle');
badge.textContent = wechatT('settings.robots.wechat.statusIdle', '未绑定');
}
}
function setWechatCardBound(isBound) {
const card = getWechatCard();
if (card) card.classList.toggle('is-bound', !!isBound);
}
function updateWechatSteps(phase) {
const steps = document.querySelectorAll('.robot-wechat-step');
if (!steps.length) return;
const order = ['generate', 'scan', 'confirm'];
const idx = order.indexOf(phase);
steps.forEach((el, i) => {
el.classList.remove('is-active', 'is-done');
if (idx < 0) {
if (i === 0) el.classList.add('is-active');
} else if (i < idx) {
el.classList.add('is-done');
} else if (i === idx) {
el.classList.add('is-active');
}
});
}
function ensureWechatSteps() {
const panel = document.getElementById('robot-wechat-scan-panel');
if (!panel || panel.querySelector('.robot-wechat-steps')) return;
const ol = document.createElement('ol');
ol.className = 'robot-wechat-steps';
ol.innerHTML = `
<li class="robot-wechat-step is-active">${wechatT('settings.robots.wechat.step1', '生成二维码')}</li>
<li class="robot-wechat-step">${wechatT('settings.robots.wechat.step2', '微信扫码')}</li>
<li class="robot-wechat-step">${wechatT('settings.robots.wechat.step3', '确认绑定')}</li>`;
panel.insertBefore(ol, panel.firstChild);
}
function ensureWechatQrFrame() {
const img = document.getElementById('robot-wechat-qr-img');
if (!img || img.parentElement?.classList.contains('robot-wechat-qr-frame')) return;
const frame = document.createElement('div');
frame.className = 'robot-wechat-qr-frame';
img.parentNode.insertBefore(frame, img);
frame.appendChild(img);
let ph = document.getElementById('robot-wechat-qr-placeholder');
if (!ph) {
ph = document.createElement('div');
ph.id = 'robot-wechat-qr-placeholder';
ph.className = 'robot-wechat-qr-placeholder';
ph.setAttribute('aria-hidden', 'true');
ph.innerHTML = '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><path d="M14 14h3v3h-3v-3zm4 0h3v3h-3v-3zm-4 4h3v3h-3v-3zm4 0h3v3h-3v-3z"/></svg>';
frame.appendChild(ph);
} else {
frame.appendChild(ph);
}
}
function stopWechatBindPoll() {
if (wechatBindPollTimer) {
clearTimeout(wechatBindPollTimer);
wechatBindPollTimer = null;
}
}
/** 已绑定:仅展示成功状态,不显示二维码/配对码 */
function showWechatBoundUI(wechat) {
const wc = wechat || {};
const wrap = document.getElementById('robot-wechat-qr-wrap');
const boundPanel = document.getElementById('robot-wechat-bound-panel');
const scanPanel = document.getElementById('robot-wechat-scan-panel');
const boundId = document.getElementById('robot-wechat-bound-id');
const btn = document.getElementById('robot-wechat-bind-btn');
stopWechatBindPoll();
wechatBindSessionKey = null;
setWechatBadge('bound');
setWechatCardBound(true);
if (wrap) wrap.hidden = false;
if (boundPanel) boundPanel.hidden = false;
if (scanPanel) scanPanel.hidden = true;
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
if (verifyWrap) verifyWrap.hidden = true;
const img = document.getElementById('robot-wechat-qr-img');
const ph = document.getElementById('robot-wechat-qr-placeholder');
if (img) {
img.removeAttribute('src');
img.hidden = true;
}
if (ph) ph.hidden = false;
if (boundId) {
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
if (id) {
boundId.textContent = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID') + id;
boundId.hidden = false;
} else {
boundId.textContent = '';
boundId.hidden = true;
}
}
if (btn) {
btn.textContent = wechatT('settings.robots.wechat.rebindButton', '重新绑定');
}
}
/** 扫码绑定进行中 */
function showWechatScanUI() {
const wrap = document.getElementById('robot-wechat-qr-wrap');
const boundPanel = document.getElementById('robot-wechat-bound-panel');
const scanPanel = document.getElementById('robot-wechat-scan-panel');
const btn = document.getElementById('robot-wechat-bind-btn');
setWechatBadge('scanning');
setWechatCardBound(false);
ensureWechatSteps();
updateWechatSteps('generate');
if (wrap) wrap.hidden = false;
if (boundPanel) boundPanel.hidden = true;
if (scanPanel) scanPanel.hidden = false;
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
if (verifyWrap) verifyWrap.hidden = true;
const verifyInput = document.getElementById('robot-wechat-verify-code');
if (verifyInput) verifyInput.value = '';
if (btn) {
btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定');
}
}
/** 未绑定且未在扫码:隐藏面板 */
function hideWechatQrWrap() {
const wrap = document.getElementById('robot-wechat-qr-wrap');
if (wrap) wrap.hidden = true;
setWechatBadge('idle');
setWechatCardBound(false);
}
function setWechatQrImage(data) {
ensureWechatQrFrame();
const img = document.getElementById('robot-wechat-qr-img');
const ph = document.getElementById('robot-wechat-qr-placeholder');
const linkEl = document.getElementById('robot-wechat-qr-link');
const openUrl = data.qrcode_open_url || data.qrcode_img_url || '';
if (img) {
if (data.qrcode_image_data_url) {
img.onload = () => {
img.hidden = false;
if (ph) ph.hidden = true;
};
img.onerror = () => {
img.hidden = true;
if (ph) ph.hidden = false;
};
img.src = data.qrcode_image_data_url;
updateWechatSteps('scan');
} else {
img.removeAttribute('src');
img.hidden = true;
if (ph) ph.hidden = false;
}
}
if (linkEl) {
if (openUrl) {
linkEl.href = openUrl;
linkEl.hidden = false;
} else {
linkEl.hidden = true;
}
}
}
function setWechatQrStatus(text, isError) {
const el = document.getElementById('robot-wechat-qr-status');
if (!el) return;
el.textContent = text || '';
el.classList.toggle('is-error', !!isError);
el.classList.toggle('is-success', !isError && !!text);
}
async function startWechatRobotBind() {
stopWechatBindPoll();
wechatBindSessionKey = null;
showWechatScanUI();
ensureWechatQrFrame();
const loading = document.getElementById('robot-wechat-qr-loading');
const img = document.getElementById('robot-wechat-qr-img');
const ph = document.getElementById('robot-wechat-qr-placeholder');
const btn = document.getElementById('robot-wechat-bind-btn');
if (loading) loading.hidden = false;
if (img) {
img.removeAttribute('src');
img.hidden = true;
}
if (ph) ph.hidden = false;
setWechatQrStatus('', false);
if (btn) btn.disabled = true;
const botType = document.getElementById('robot-wechat-bot-type')?.value.trim() || '3';
try {
const res = await apiFetch('/api/robot/wechat/qrcode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bot_type: botType })
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || data.message || '获取二维码失败');
}
wechatBindSessionKey = data.session_key;
setWechatQrImage(data);
setWechatQrStatus(data.message || '请使用微信扫描二维码', false);
pollWechatBindStatus();
} catch (e) {
setWechatQrStatus(e.message || String(e), true);
setWechatBadge('idle');
} finally {
if (loading) loading.hidden = true;
if (btn) btn.disabled = false;
}
}
async function pollWechatBindStatus() {
if (!wechatBindSessionKey) return;
try {
const url = `/api/robot/wechat/qrcode/status?session_key=${encodeURIComponent(wechatBindSessionKey)}`;
const res = await apiFetch(url, { method: 'GET' });
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || '轮询失败');
}
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
switch (data.status) {
case 'confirmed':
stopWechatBindPoll();
updateWechatSteps('confirm');
document.getElementById('robot-wechat-enabled').checked = true;
if (data.ilink_bot_id) {
const idEl = document.getElementById('robot-wechat-ilink-bot-id');
if (idEl) idEl.value = data.ilink_bot_id;
}
if (typeof loadConfig === 'function') {
await loadConfig(false);
} else {
showWechatBoundUI({
ilink_bot_id: data.ilink_bot_id,
bound: true
});
}
return;
case 'need_verifycode':
updateWechatSteps('scan');
if (verifyWrap) verifyWrap.hidden = false;
setWechatQrStatus(data.message || '请输入手机微信显示的数字', false);
break;
case 'scaned':
updateWechatSteps('confirm');
if (verifyWrap) verifyWrap.hidden = true;
setWechatQrStatus('已扫码,请在手机上确认…', false);
break;
case 'binded_redirect':
stopWechatBindPoll();
showWechatBoundUI({ bound: true });
return;
case 'expired':
setWechatQrStatus('二维码已过期,请重新点击「生成二维码并绑定」', true);
setWechatBadge('scanning');
stopWechatBindPoll();
return;
default:
if (verifyWrap) verifyWrap.hidden = true;
break;
}
} catch (e) {
setWechatQrStatus(e.message || String(e), true);
}
wechatBindPollTimer = setTimeout(pollWechatBindStatus, 1500);
}
async function submitWechatVerifyCode() {
const code = document.getElementById('robot-wechat-verify-code')?.value.trim();
if (!code || !wechatBindSessionKey) return;
try {
const res = await apiFetch('/api/robot/wechat/qrcode/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_key: wechatBindSessionKey, verify_code: code })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '提交失败');
setWechatQrStatus(data.message || '已提交配对码,等待确认…', false);
pollWechatBindStatus();
} catch (e) {
setWechatQrStatus(e.message || String(e), true);
}
}
function refreshWechatRobotBoundUI(wechat) {
const wc = wechat || {};
const isBound = wc.bound || (wc.bot_token && wc.ilink_bot_id) || !!(wc.ilink_bot_id && wc.enabled);
if (isBound) {
showWechatBoundUI(wc);
} else {
hideWechatQrWrap();
const btn = document.getElementById('robot-wechat-bind-btn');
if (btn) {
btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定');
}
}
}
+165 -59
View File
@@ -1082,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>
@@ -1385,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>
<!-- 漏洞列表 -->
@@ -2341,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>
@@ -3510,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>