mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-21 05:20:08 +02:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ecf594a25b | |||
| d5759f6d83 | |||
| 81b3f64b15 | |||
| 0e0f1352f0 | |||
| ffba311afd | |||
| d9ed36cfb1 | |||
| b7f80b78ee | |||
| 8f8e5cfff5 | |||
| 120f860640 | |||
| 90cd119a83 | |||
| 56d597e0c5 | |||
| 11ab5cde8f | |||
| 46a7d338a4 | |||
| 46f68cc1d4 | |||
| 7003cdb2e3 | |||
| 4e5e6208bd | |||
| 6a7e78a846 | |||
| 88c6fbfb75 | |||
| 1cd6d0fa90 | |||
| 24390db100 | |||
| c000fe5195 | |||
| 0b4a11d01a | |||
| d433e44a7d | |||
| 7de51fe0ea | |||
| a354cf97e5 | |||
| c180f07c7e | |||
| 15730d3ef4 | |||
| b7fa18b6d4 | |||
| 8d622f63ff | |||
| 20b05146fb | |||
| d8768eae76 | |||
| 9232cee38d | |||
| 6c975e63d2 | |||
| e175523b82 | |||
| ae23427d9e | |||
| 93a2504ce3 | |||
| 09b0479fb3 | |||
| 2bdc9d4fe0 | |||
| 01b3d8056c | |||
| ed479d5e4d | |||
| a49f595231 | |||
| 82cf014a5e | |||
| 508de5fad0 | |||
| 6712344411 | |||
| 7eadccbff6 | |||
| 01b361e4a7 | |||
| f6ce31c961 | |||
| d5a0f93c6c | |||
| 56faefaaf9 | |||
| 16e9c5874a | |||
| 41b5cdde6b | |||
| cf1f8515d9 | |||
| 5e2b30c029 | |||
| 8c7c22369e | |||
| 9b1aba692b | |||
| db730b48c1 | |||
| dfb7dd7390 | |||
| 9f6eb33047 | |||
| 616d87f4cc | |||
| 8d999792b8 | |||
| afae8970d1 | |||
| 4d7330c5c3 | |||
| 8884bfb0b4 | |||
| fb351c80b6 | |||
| 664834e338 | |||
| 95bf62db88 | |||
| 656242614d | |||
| a9d6d8c00e | |||
| 0d6a43c0a8 | |||
| 702f286eb1 |
@@ -112,7 +112,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
- 🔒 Password-protected web UI, audit logs, and SQLite persistence
|
||||
- 📚 Knowledge base (RAG) with embedding-based vector retrieval (cosine similarity), optional **Eino Compose** indexing pipeline, and configurable post-retrieval budgets / reranking hooks
|
||||
- 📁 Conversation grouping with pinning, rename, and batch management
|
||||
- 📂 **Project management**: group conversations and vulnerabilities by project; **shared facts** (project blackboard) persist cross-session context (targets, env, auth notes) with auto-injection for agents and MCP tools (`upsert_project_fact`, `get_project_fact`, …)
|
||||
- 📂 **Project management**: shared facts (blackboard) across sessions, `upsert_project_fact` + `links` to chain paths; attack-chain and project fact graph views
|
||||
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
||||
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
||||
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
||||
@@ -312,7 +312,7 @@ Requirements / tips:
|
||||
### Tool Orchestration & Extensions
|
||||
- **YAML recipes** in `tools/*.yaml` describe commands, arguments, prompts, and metadata.
|
||||
- **Directory hot-reload** – pointing `security.tools_dir` to a folder is usually enough; inline definitions in `config.yaml` remain supported for quick experiments.
|
||||
- **Large-result pagination** – outputs beyond 200 KB are stored as artifacts retrievable through the `query_execution_result` tool with paging, filters, and regex search.
|
||||
- **Large tool outputs** – outputs beyond `reduction_max_length_for_trunc` are summarized via Eino reduction with full content persisted under `tmp/reduction/`; use `read_file` on the path in `<persisted-output>`.
|
||||
- **Result compression** – multi-megabyte logs can be summarized or losslessly compressed before persisting to keep SQLite lean.
|
||||
|
||||
**Creating a custom tool (typical flow)**
|
||||
@@ -551,6 +551,11 @@ multi_agent:
|
||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor optional
|
||||
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
||||
# eino_middleware: plantask_enable, checkpoint_dir, deep_model_retry_max_retries, deep_output_key, ...
|
||||
project:
|
||||
enabled: true # Enable project blackboard & fact MCP tools
|
||||
fact_index_max_runes: 65000
|
||||
fact_summary_max_runes: 24000
|
||||
default_inject_deprecated: false
|
||||
```
|
||||
|
||||
### Tool Definition Example (`tools/nmap.yaml`)
|
||||
|
||||
+7
-2
@@ -111,7 +111,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
||||
- 🔒 Web 登录保护、审计日志、SQLite 持久化
|
||||
- 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项)
|
||||
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
|
||||
- 📂 **项目管理**:按项目归类对话与漏洞;**共享事实**(项目黑板)在多会话间沉淀目标/环境/认证等认知,自动注入 Agent 上下文,支持 MCP 工具读写(`upsert_project_fact`、`get_project_fact` 等)
|
||||
- 📂 **项目管理**:共享事实(黑板)跨会话沉淀认知,`upsert_project_fact` + `links` 串联攻击路径;聊天攻击链与项目事实图可视化
|
||||
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
||||
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
|
||||
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
|
||||
@@ -310,7 +310,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
### 工具编排与扩展
|
||||
- `tools/*.yaml` 定义命令、参数、提示词与元数据,可热加载。
|
||||
- `security.tools_dir` 指向目录即可批量启用;仍支持在主配置里内联定义。
|
||||
- **大结果分页**:超过 200KB 的输出会保存为附件,可通过 `query_execution_result` 工具分页、过滤、正则检索。
|
||||
- **大工具输出**:超过 `reduction_max_length_for_trunc` 时由 Eino reduction 摘要,完整内容落盘至 `tmp/reduction/`;按 `<persisted-output>` 中的路径用 `read_file` 读取。
|
||||
- **结果压缩/摘要**:多兆字节日志可先压缩或生成摘要再写入 SQLite,减小档案体积。
|
||||
|
||||
**自定义工具的一般步骤**
|
||||
@@ -549,6 +549,11 @@ multi_agent:
|
||||
# orchestrator_instruction_plan_execute / orchestrator_instruction_supervisor 可选
|
||||
# eino_skills: { disable: false, filesystem_tools: true, skill_tool_name: skill }
|
||||
# eino_middleware: plantask_enable、checkpoint_dir、deep_model_retry_max_retries、deep_output_key 等
|
||||
project:
|
||||
enabled: true # 启用项目黑板与事实 MCP 工具
|
||||
fact_index_max_runes: 65000
|
||||
fact_summary_max_runes: 24000
|
||||
default_inject_deprecated: false
|
||||
```
|
||||
|
||||
### 工具模版示例(`tools/nmap.yaml`)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"cyberstrike-ai/internal/logger"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -33,23 +32,6 @@ func main() {
|
||||
// 创建安全工具执行器
|
||||
executor := security.NewExecutor(&cfg.Security, mcpServer, log.Logger)
|
||||
|
||||
// 初始化结果存储(与 internal/app/app.go 同样的逻辑)。
|
||||
// stdio 模式下原本不初始化,导致 'exec' 等查询型工具报"结果存储未初始化"。
|
||||
resultStorageDir := "tmp"
|
||||
if cfg.Agent.ResultStorageDir != "" {
|
||||
resultStorageDir = cfg.Agent.ResultStorageDir
|
||||
}
|
||||
if err := os.MkdirAll(resultStorageDir, 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "创建结果存储目录失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
resultStorage, err := storage.NewFileResultStorage(resultStorageDir, log.Logger)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "初始化结果存储失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
executor.SetResultStorage(resultStorage)
|
||||
|
||||
// 注册工具
|
||||
executor.RegisterTools(mcpServer)
|
||||
|
||||
@@ -61,4 +43,3 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.37"
|
||||
version: "v1.6.42"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -58,7 +58,7 @@ openai:
|
||||
api_key: sk-xxxxxxx # API 密钥(必填)
|
||||
model: qwen3-max # 模型名称(必填)
|
||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort;Claude 4.6+ 为 adaptive + output_config.effort(仅显式配置 effort 时下发);3.7 为 enabled+budget_tokens:10000(文档示例),effort 不映射,自定义预算用 extra_request_fields
|
||||
reasoning:
|
||||
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
||||
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
|
||||
@@ -92,8 +92,6 @@ fofa:
|
||||
# 达到最大迭代次数时,AI 会自动总结测试结果
|
||||
agent:
|
||||
max_iterations: 12000 # 全局最大迭代次数(单代理 / Deep / Supervisor / Plan-Execute 主执行器 / 子代理均沿用;agents/*.md 中 max_iterations>0 可单独覆盖)
|
||||
large_result_threshold: 102400 # 大结果阈值(字节),默认50KB,超过此大小会自动保存到存储
|
||||
result_storage_dir: tmp # 结果存储目录,大结果会保存在此目录下
|
||||
tool_timeout_minutes: 60 # 单次工具执行最大时长(分钟),超时自动终止;0 表示不限制(不推荐,易出现长时间挂起)
|
||||
# system_prompt_path: prompts/single-agent.md # 可选:单代理系统提示文件(相对本配置文件所在目录);非空且可读时替换内置提示
|
||||
|
||||
@@ -310,7 +308,9 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
|
||||
project:
|
||||
enabled: true
|
||||
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
|
||||
fact_index_max_runes: 6500
|
||||
fact_summary_max_runes: 2400
|
||||
fact_index_max_runes: 65000
|
||||
# 事实关系速览段预算(从索引总预算中预留)
|
||||
fact_index_path_max_runes: 10000
|
||||
fact_summary_max_runes: 24000
|
||||
default_inject_deprecated: false
|
||||
|
||||
|
||||
|
||||
+17
-135
@@ -18,7 +18,6 @@ import (
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -32,8 +31,6 @@ type Agent struct {
|
||||
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
|
||||
logger *zap.Logger
|
||||
maxIterations int
|
||||
resultStorage ResultStorage // 结果存储
|
||||
largeResultThreshold int // 大结果阈值(字节)
|
||||
mu sync.RWMutex // 添加互斥锁以支持并发更新
|
||||
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
|
||||
currentConversationID string // 当前对话ID(用于自动传递给工具)
|
||||
@@ -41,18 +38,6 @@ type Agent struct {
|
||||
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
|
||||
}
|
||||
|
||||
// ResultStorage 结果存储接口(直接使用 storage 包的类型)
|
||||
type ResultStorage interface {
|
||||
SaveResult(executionID string, toolName string, result string) error
|
||||
GetResult(executionID string) (string, error)
|
||||
GetResultPage(executionID string, page int, limit int) (*storage.ResultPage, error)
|
||||
SearchResult(executionID string, keyword string, useRegex bool) ([]string, error)
|
||||
FilterResult(executionID string, filter string, useRegex bool) ([]string, error)
|
||||
GetResultMetadata(executionID string) (*storage.ResultMetadata, error)
|
||||
GetResultPath(executionID string) string
|
||||
DeleteResult(executionID string) error
|
||||
}
|
||||
|
||||
type agentConversationIDKey struct{}
|
||||
|
||||
func withAgentConversationID(ctx context.Context, id string) context.Context {
|
||||
@@ -83,26 +68,6 @@ func NewAgent(cfg *config.OpenAIConfig, agentCfg *config.AgentConfig, mcpServer
|
||||
maxIterations = 30
|
||||
}
|
||||
|
||||
// 设置大结果阈值,默认50KB
|
||||
largeResultThreshold := 50 * 1024
|
||||
if agentCfg != nil && agentCfg.LargeResultThreshold > 0 {
|
||||
largeResultThreshold = agentCfg.LargeResultThreshold
|
||||
}
|
||||
|
||||
// 设置结果存储目录,默认tmp
|
||||
resultStorageDir := "tmp"
|
||||
if agentCfg != nil && agentCfg.ResultStorageDir != "" {
|
||||
resultStorageDir = agentCfg.ResultStorageDir
|
||||
}
|
||||
|
||||
// 初始化结果存储
|
||||
var resultStorage ResultStorage
|
||||
if resultStorageDir != "" {
|
||||
// 导入storage包(避免循环依赖,使用接口)
|
||||
// 这里需要在实际使用时初始化
|
||||
// 暂时设为nil,在需要时初始化
|
||||
}
|
||||
|
||||
// 配置HTTP Transport,优化连接管理和超时设置
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
@@ -133,20 +98,11 @@ func NewAgent(cfg *config.OpenAIConfig, agentCfg *config.AgentConfig, mcpServer
|
||||
externalMCPMgr: externalMCPMgr,
|
||||
logger: logger,
|
||||
maxIterations: maxIterations,
|
||||
resultStorage: resultStorage,
|
||||
largeResultThreshold: largeResultThreshold,
|
||||
toolNameMapping: make(map[string]string), // 初始化工具名称映射
|
||||
toolDescriptionMode: "short",
|
||||
}
|
||||
}
|
||||
|
||||
// SetResultStorage 设置结果存储(用于避免循环依赖)
|
||||
func (a *Agent) SetResultStorage(storage ResultStorage) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.resultStorage = storage
|
||||
}
|
||||
|
||||
// SetPromptBaseDir 设置单代理 system_prompt_path 相对路径的基准目录(一般为 config.yaml 所在目录)。
|
||||
func (a *Agent) SetPromptBaseDir(dir string) {
|
||||
a.mu.Lock()
|
||||
@@ -663,46 +619,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
}
|
||||
|
||||
resultStr := resultText.String()
|
||||
resultSize := len(resultStr)
|
||||
|
||||
// 检测大结果并保存
|
||||
a.mu.RLock()
|
||||
threshold := a.largeResultThreshold
|
||||
storage := a.resultStorage
|
||||
a.mu.RUnlock()
|
||||
|
||||
if resultSize > threshold && storage != nil {
|
||||
// 异步保存大结果
|
||||
go func() {
|
||||
if err := storage.SaveResult(executionID, toolName, resultStr); err != nil {
|
||||
a.logger.Warn("保存大结果失败",
|
||||
zap.String("executionID", executionID),
|
||||
zap.String("toolName", toolName),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
a.logger.Info("大结果已保存",
|
||||
zap.String("executionID", executionID),
|
||||
zap.String("toolName", toolName),
|
||||
zap.Int("size", resultSize),
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
// 返回最小化通知
|
||||
lines := strings.Split(resultStr, "\n")
|
||||
filePath := ""
|
||||
if storage != nil {
|
||||
filePath = storage.GetResultPath(executionID)
|
||||
}
|
||||
notification := a.formatMinimalNotification(executionID, toolName, resultSize, len(lines), filePath)
|
||||
|
||||
return &ToolExecutionResult{
|
||||
Result: notification,
|
||||
ExecutionID: executionID,
|
||||
IsError: result != nil && result.IsError,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &ToolExecutionResult{
|
||||
Result: resultStr,
|
||||
@@ -711,57 +627,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
}, nil
|
||||
}
|
||||
|
||||
// formatMinimalNotification 格式化最小化通知
|
||||
func (a *Agent) formatMinimalNotification(executionID string, toolName string, size int, lineCount int, filePath string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("工具执行完成。结果已保存(ID: %s)。\n\n", executionID))
|
||||
sb.WriteString("结果信息:\n")
|
||||
sb.WriteString(fmt.Sprintf(" - 工具: %s\n", toolName))
|
||||
sb.WriteString(fmt.Sprintf(" - 大小: %d 字节 (%.2f KB)\n", size, float64(size)/1024))
|
||||
sb.WriteString(fmt.Sprintf(" - 行数: %d 行\n", lineCount))
|
||||
if filePath != "" {
|
||||
sb.WriteString(fmt.Sprintf(" - 文件路径: %s\n", filePath))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("推荐使用 query_execution_result 工具查询完整结果:\n")
|
||||
sb.WriteString(fmt.Sprintf(" - 查询第一页: query_execution_result(execution_id=\"%s\", page=1, limit=100)\n", executionID))
|
||||
sb.WriteString(fmt.Sprintf(" - 搜索关键词: query_execution_result(execution_id=\"%s\", search=\"关键词\")\n", executionID))
|
||||
sb.WriteString(fmt.Sprintf(" - 过滤条件: query_execution_result(execution_id=\"%s\", filter=\"error\")\n", executionID))
|
||||
sb.WriteString(fmt.Sprintf(" - 正则匹配: query_execution_result(execution_id=\"%s\", search=\"\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\", use_regex=true)\n", executionID))
|
||||
sb.WriteString("\n")
|
||||
if filePath != "" {
|
||||
sb.WriteString("如果 query_execution_result 工具不满足需求,也可以使用其他工具处理文件:\n")
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**分段读取示例:**\n")
|
||||
sb.WriteString(fmt.Sprintf(" - 查看前100行: exec(command=\"head\", args=[\"-n\", \"100\", \"%s\"])\n", filePath))
|
||||
sb.WriteString(fmt.Sprintf(" - 查看后100行: exec(command=\"tail\", args=[\"-n\", \"100\", \"%s\"])\n", filePath))
|
||||
sb.WriteString(fmt.Sprintf(" - 查看第50-150行: exec(command=\"sed\", args=[\"-n\", \"50,150p\", \"%s\"])\n", filePath))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**搜索和正则匹配示例:**\n")
|
||||
sb.WriteString(fmt.Sprintf(" - 搜索关键词: exec(command=\"grep\", args=[\"关键词\", \"%s\"])\n", filePath))
|
||||
sb.WriteString(fmt.Sprintf(" - 正则匹配IP地址: exec(command=\"grep\", args=[\"-E\", \"\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\", \"%s\"])\n", filePath))
|
||||
sb.WriteString(fmt.Sprintf(" - 不区分大小写搜索: exec(command=\"grep\", args=[\"-i\", \"关键词\", \"%s\"])\n", filePath))
|
||||
sb.WriteString(fmt.Sprintf(" - 显示匹配行号: exec(command=\"grep\", args=[\"-n\", \"关键词\", \"%s\"])\n", filePath))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**过滤和统计示例:**\n")
|
||||
sb.WriteString(fmt.Sprintf(" - 统计总行数: exec(command=\"wc\", args=[\"-l\", \"%s\"])\n", filePath))
|
||||
sb.WriteString(fmt.Sprintf(" - 过滤包含error的行: exec(command=\"grep\", args=[\"error\", \"%s\"])\n", filePath))
|
||||
sb.WriteString(fmt.Sprintf(" - 排除空行: exec(command=\"grep\", args=[\"-v\", \"^$\", \"%s\"])\n", filePath))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**完整读取(不推荐大文件):**\n")
|
||||
sb.WriteString(fmt.Sprintf(" - 使用 cat 工具: cat(file=\"%s\")\n", filePath))
|
||||
sb.WriteString(fmt.Sprintf(" - 使用 exec 工具: exec(command=\"cat\", args=[\"%s\"])\n", filePath))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("**注意:**\n")
|
||||
sb.WriteString(" - 直接读取大文件可能会再次触发大结果保存机制\n")
|
||||
sb.WriteString(" - 建议优先使用分段读取和搜索功能,避免一次性加载整个文件\n")
|
||||
sb.WriteString(" - 正则表达式语法遵循标准 POSIX 正则表达式规范\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// UpdateConfig 更新OpenAI配置
|
||||
func (a *Agent) UpdateConfig(cfg *config.OpenAIConfig) {
|
||||
a.mu.Lock()
|
||||
@@ -923,6 +788,23 @@ func (a *Agent) RecordLocalToolExecution(toolName string, args map[string]interf
|
||||
return a.mcpServer.RecordCompletedToolInvocation(toolName, args, resultText, invokeErr)
|
||||
}
|
||||
|
||||
// UpdateMCPExecutionDisplayResult 将监控库中的工具结果更新为送入模型的展示正文(reduction 后)。
|
||||
func (a *Agent) UpdateMCPExecutionDisplayResult(executionID, resultText string) {
|
||||
if a == nil || strings.TrimSpace(executionID) == "" {
|
||||
return
|
||||
}
|
||||
text := resultText
|
||||
if strings.TrimSpace(text) == "" {
|
||||
text = "(无输出)"
|
||||
}
|
||||
tr := &mcp.ToolResult{
|
||||
Content: []mcp.Content{{Type: "text", Text: text}},
|
||||
}
|
||||
if a.mcpServer != nil {
|
||||
_ = a.mcpServer.UpdateToolExecutionResult(executionID, tr)
|
||||
}
|
||||
}
|
||||
|
||||
// CancelMCPToolExecutionWithNote 取消一次进行中的 MCP 工具(先内部后外部),与监控页「终止工具」一致;note 非空时合并进返回给模型的文本。
|
||||
func (a *Agent) CancelMCPToolExecutionWithNote(executionID, note string) bool {
|
||||
executionID = strings.TrimSpace(executionID)
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// setupTestAgent 创建测试用的Agent
|
||||
func setupTestAgent(t *testing.T) (*Agent, *storage.FileResultStorage) {
|
||||
func setupTestAgent(t *testing.T) *Agent {
|
||||
logger := zap.NewNop()
|
||||
mcpServer := mcp.NewServer(logger)
|
||||
|
||||
@@ -26,205 +21,10 @@ func setupTestAgent(t *testing.T) (*Agent, *storage.FileResultStorage) {
|
||||
}
|
||||
|
||||
agentCfg := &config.AgentConfig{
|
||||
MaxIterations: 10,
|
||||
LargeResultThreshold: 100, // 设置较小的阈值便于测试
|
||||
ResultStorageDir: "",
|
||||
MaxIterations: 10,
|
||||
}
|
||||
|
||||
agent := NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 10)
|
||||
|
||||
// 创建测试存储
|
||||
tmpDir := filepath.Join(os.TempDir(), "test_agent_storage_"+time.Now().Format("20060102_150405"))
|
||||
testStorage, err := storage.NewFileResultStorage(tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试存储失败: %v", err)
|
||||
}
|
||||
|
||||
agent.SetResultStorage(testStorage)
|
||||
|
||||
return agent, testStorage
|
||||
}
|
||||
|
||||
func TestAgent_FormatMinimalNotification(t *testing.T) {
|
||||
agent, testStorage := setupTestAgent(t)
|
||||
_ = testStorage // 避免未使用变量警告
|
||||
|
||||
executionID := "test_exec_001"
|
||||
toolName := "nmap_scan"
|
||||
size := 50000
|
||||
lineCount := 1000
|
||||
filePath := "tmp/test_exec_001.txt"
|
||||
|
||||
notification := agent.formatMinimalNotification(executionID, toolName, size, lineCount, filePath)
|
||||
|
||||
// 验证通知包含必要信息
|
||||
if !strings.Contains(notification, executionID) {
|
||||
t.Errorf("通知中应该包含执行ID: %s", executionID)
|
||||
}
|
||||
|
||||
if !strings.Contains(notification, toolName) {
|
||||
t.Errorf("通知中应该包含工具名称: %s", toolName)
|
||||
}
|
||||
|
||||
if !strings.Contains(notification, "50000") {
|
||||
t.Errorf("通知中应该包含大小信息")
|
||||
}
|
||||
|
||||
if !strings.Contains(notification, "1000") {
|
||||
t.Errorf("通知中应该包含行数信息")
|
||||
}
|
||||
|
||||
if !strings.Contains(notification, "query_execution_result") {
|
||||
t.Errorf("通知中应该包含查询工具的使用说明")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteToolViaMCP_LargeResult(t *testing.T) {
|
||||
agent, _ := setupTestAgent(t)
|
||||
|
||||
// 创建模拟的MCP工具结果(大结果)
|
||||
largeResult := &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: strings.Repeat("This is a test line with some content.\n", 1000), // 约50KB
|
||||
},
|
||||
},
|
||||
IsError: false,
|
||||
}
|
||||
|
||||
// 模拟MCP服务器返回大结果
|
||||
// 由于我们需要模拟CallTool的行为,这里需要创建一个mock或者使用实际的MCP服务器
|
||||
// 为了简化测试,我们直接测试结果处理逻辑
|
||||
|
||||
// 设置阈值
|
||||
agent.mu.Lock()
|
||||
agent.largeResultThreshold = 1000 // 设置较小的阈值
|
||||
agent.mu.Unlock()
|
||||
|
||||
// 创建执行ID
|
||||
executionID := "test_exec_large_001"
|
||||
toolName := "test_tool"
|
||||
|
||||
// 格式化结果
|
||||
var resultText strings.Builder
|
||||
for _, content := range largeResult.Content {
|
||||
resultText.WriteString(content.Text)
|
||||
resultText.WriteString("\n")
|
||||
}
|
||||
|
||||
resultStr := resultText.String()
|
||||
resultSize := len(resultStr)
|
||||
|
||||
// 检测大结果并保存
|
||||
agent.mu.RLock()
|
||||
threshold := agent.largeResultThreshold
|
||||
storage := agent.resultStorage
|
||||
agent.mu.RUnlock()
|
||||
|
||||
if resultSize > threshold && storage != nil {
|
||||
// 保存大结果
|
||||
err := storage.SaveResult(executionID, toolName, resultStr)
|
||||
if err != nil {
|
||||
t.Fatalf("保存大结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 生成通知
|
||||
lines := strings.Split(resultStr, "\n")
|
||||
filePath := storage.GetResultPath(executionID)
|
||||
notification := agent.formatMinimalNotification(executionID, toolName, resultSize, len(lines), filePath)
|
||||
|
||||
// 验证通知格式
|
||||
if !strings.Contains(notification, executionID) {
|
||||
t.Errorf("通知中应该包含执行ID")
|
||||
}
|
||||
|
||||
// 验证结果已保存
|
||||
savedResult, err := storage.GetResult(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("获取保存的结果失败: %v", err)
|
||||
}
|
||||
|
||||
if savedResult != resultStr {
|
||||
t.Errorf("保存的结果与原始结果不匹配")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("大结果应该被检测到并保存")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_ExecuteToolViaMCP_SmallResult(t *testing.T) {
|
||||
agent, _ := setupTestAgent(t)
|
||||
|
||||
// 创建小结果
|
||||
smallResult := &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: "Small result content",
|
||||
},
|
||||
},
|
||||
IsError: false,
|
||||
}
|
||||
|
||||
// 设置较大的阈值
|
||||
agent.mu.Lock()
|
||||
agent.largeResultThreshold = 100000 // 100KB
|
||||
agent.mu.Unlock()
|
||||
|
||||
// 格式化结果
|
||||
var resultText strings.Builder
|
||||
for _, content := range smallResult.Content {
|
||||
resultText.WriteString(content.Text)
|
||||
resultText.WriteString("\n")
|
||||
}
|
||||
|
||||
resultStr := resultText.String()
|
||||
resultSize := len(resultStr)
|
||||
|
||||
// 检测大结果
|
||||
agent.mu.RLock()
|
||||
threshold := agent.largeResultThreshold
|
||||
storage := agent.resultStorage
|
||||
agent.mu.RUnlock()
|
||||
|
||||
if resultSize > threshold && storage != nil {
|
||||
t.Fatal("小结果不应该被保存")
|
||||
}
|
||||
|
||||
// 小结果应该直接返回
|
||||
if resultSize <= threshold {
|
||||
// 这是预期的行为
|
||||
if resultStr == "" {
|
||||
t.Fatal("小结果应该直接返回,不应该为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_SetResultStorage(t *testing.T) {
|
||||
agent, _ := setupTestAgent(t)
|
||||
|
||||
// 创建新的存储
|
||||
tmpDir := filepath.Join(os.TempDir(), "test_new_storage_"+time.Now().Format("20060102_150405"))
|
||||
newStorage, err := storage.NewFileResultStorage(tmpDir, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("创建新存储失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置新存储
|
||||
agent.SetResultStorage(newStorage)
|
||||
|
||||
// 验证存储已更新
|
||||
agent.mu.RLock()
|
||||
currentStorage := agent.resultStorage
|
||||
agent.mu.RUnlock()
|
||||
|
||||
if currentStorage != newStorage {
|
||||
t.Fatal("存储未正确更新")
|
||||
}
|
||||
|
||||
// 清理
|
||||
os.RemoveAll(tmpDir)
|
||||
return NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 10)
|
||||
}
|
||||
|
||||
func TestAgent_NewAgent_DefaultValues(t *testing.T) {
|
||||
@@ -243,14 +43,6 @@ func TestAgent_NewAgent_DefaultValues(t *testing.T) {
|
||||
if agent.maxIterations != 30 {
|
||||
t.Errorf("默认迭代次数不匹配。期望: 30, 实际: %d", agent.maxIterations)
|
||||
}
|
||||
|
||||
agent.mu.RLock()
|
||||
threshold := agent.largeResultThreshold
|
||||
agent.mu.RUnlock()
|
||||
|
||||
if threshold != 50*1024 {
|
||||
t.Errorf("默认阈值不匹配。期望: %d, 实际: %d", 50*1024, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
||||
@@ -264,9 +56,7 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
agentCfg := &config.AgentConfig{
|
||||
MaxIterations: 20,
|
||||
LargeResultThreshold: 100 * 1024, // 100KB
|
||||
ResultStorageDir: "custom_tmp",
|
||||
MaxIterations: 20,
|
||||
}
|
||||
|
||||
agent := NewAgent(openAICfg, agentCfg, mcpServer, nil, logger, 15)
|
||||
@@ -274,12 +64,4 @@ func TestAgent_NewAgent_CustomConfig(t *testing.T) {
|
||||
if agent.maxIterations != 15 {
|
||||
t.Errorf("迭代次数不匹配。期望: 15, 实际: %d", agent.maxIterations)
|
||||
}
|
||||
|
||||
agent.mu.RLock()
|
||||
threshold := agent.largeResultThreshold
|
||||
agent.mu.RUnlock()
|
||||
|
||||
if threshold != 100*1024 {
|
||||
t.Errorf("阈值不匹配。期望: %d, 实际: %d", 100*1024, threshold)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"cyberstrike-ai/internal/project"
|
||||
"cyberstrike-ai/internal/projectprompt"
|
||||
)
|
||||
|
||||
// DefaultSingleAgentSystemPrompt 单代理(Eino ADK / MCP)内置系统提示;可通过 agent.system_prompt_path 覆盖为文件。
|
||||
@@ -107,7 +107,7 @@ func DefaultSingleAgentSystemPrompt() string {
|
||||
- 若最近一步得到 404/空结果/无效响应,不得直接结束;至少再进行一次“同目标不同策略”的验证(如变更路径、参数、请求方法、上下文来源)。
|
||||
- 避免无效空转:同一工具+同类参数连续失败 3 次后,必须切换策略(改工具、改入口、改假设)并说明切换原因。
|
||||
|
||||
` + project.FactRecordingBlackboardSection(false) + `
|
||||
` + projectprompt.FactRecordingBlackboardSection(false) + `
|
||||
|
||||
## 技能库(Skills)与知识库
|
||||
|
||||
|
||||
+9
-25
@@ -28,7 +28,6 @@ import (
|
||||
"cyberstrike-ai/internal/robot"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/skillpackage"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -130,23 +129,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
externalMCPMgr.StartAllEnabled()
|
||||
}
|
||||
|
||||
// 初始化结果存储
|
||||
resultStorageDir := "tmp"
|
||||
if cfg.Agent.ResultStorageDir != "" {
|
||||
resultStorageDir = cfg.Agent.ResultStorageDir
|
||||
}
|
||||
|
||||
// 确保存储目录存在
|
||||
if err := os.MkdirAll(resultStorageDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建结果存储目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建结果存储实例
|
||||
resultStorage, err := storage.NewFileResultStorage(resultStorageDir, log.Logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("初始化结果存储失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建Agent
|
||||
maxIterations := cfg.Agent.MaxIterations
|
||||
if maxIterations <= 0 {
|
||||
@@ -155,12 +137,6 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
agent := agent.NewAgent(&cfg.OpenAI, &cfg.Agent, mcpServer, externalMCPMgr, log.Logger, maxIterations)
|
||||
agent.UpdateToolDescriptionMode(cfg.Security.ToolDescriptionMode)
|
||||
|
||||
// 设置结果存储到Agent
|
||||
agent.SetResultStorage(resultStorage)
|
||||
|
||||
// 设置结果存储到Executor(用于查询工具)
|
||||
executor.SetResultStorage(resultStorage)
|
||||
|
||||
// 初始化知识库模块(如果启用)
|
||||
var knowledgeManager *knowledge.Manager
|
||||
var knowledgeRetriever *knowledge.Retriever
|
||||
@@ -394,7 +370,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
conversationHandler.SetAudit(auditSvc)
|
||||
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
||||
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
||||
openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, resultStorage, conversationHandler, agentHandler)
|
||||
openAPIHandler := handler.NewOpenAPIHandler(db, log.Logger, conversationHandler, agentHandler)
|
||||
|
||||
// 创建 App 实例(部分字段稍后填充)
|
||||
app := &App{
|
||||
@@ -853,6 +829,7 @@ func setupRoutes(
|
||||
protected.PUT("/batch-tasks/:queueId/schedule-enabled", agentHandler.SetBatchQueueScheduleEnabled)
|
||||
protected.DELETE("/batch-tasks/:queueId", agentHandler.DeleteBatchQueue)
|
||||
protected.PUT("/batch-tasks/:queueId/tasks/:taskId", agentHandler.UpdateBatchTask)
|
||||
protected.POST("/batch-tasks/:queueId/tasks/:taskId/run", agentHandler.RunSingleBatchTask)
|
||||
protected.POST("/batch-tasks/:queueId/tasks", agentHandler.AddBatchTask)
|
||||
protected.DELETE("/batch-tasks/:queueId/tasks/:taskId", agentHandler.DeleteBatchTask)
|
||||
|
||||
@@ -900,6 +877,7 @@ func setupRoutes(
|
||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||
protected.POST("/config/test-vision", configHandler.TestVision)
|
||||
protected.POST("/config/list-models", configHandler.ListModels)
|
||||
|
||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||
@@ -1091,6 +1069,11 @@ func setupRoutes(
|
||||
protected.GET("/projects/:id", projectHandler.GetProject)
|
||||
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
||||
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
||||
protected.GET("/projects/:id/fact-graph", projectHandler.GetFactGraph)
|
||||
protected.GET("/projects/:id/fact-edges", projectHandler.ListFactEdges)
|
||||
protected.POST("/projects/:id/fact-edges", projectHandler.CreateFactEdge)
|
||||
protected.DELETE("/projects/:id/fact-edges/:edgeId", projectHandler.DeleteFactEdge)
|
||||
protected.POST("/projects/:id/promote-attack-chain/:conversationId", projectHandler.PromoteAttackChain)
|
||||
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
|
||||
protected.POST("/projects/:id/facts", projectHandler.CreateFact)
|
||||
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
||||
@@ -1131,6 +1114,7 @@ func setupRoutes(
|
||||
c2Routes.POST("/listeners/:id/start", c2Handler.StartListener)
|
||||
c2Routes.POST("/listeners/:id/stop", c2Handler.StopListener)
|
||||
c2Routes.GET("/sessions", c2Handler.ListSessions)
|
||||
c2Routes.DELETE("/sessions", c2Handler.DeleteSessions)
|
||||
c2Routes.GET("/sessions/:id", c2Handler.GetSession)
|
||||
c2Routes.DELETE("/sessions/:id", c2Handler.DeleteSession)
|
||||
c2Routes.PUT("/sessions/:id/sleep", c2Handler.SetSessionSleep)
|
||||
|
||||
@@ -61,6 +61,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
|
||||
- stop: 停止监听器(需 listener_id)
|
||||
- delete: 删除监听器(需 listener_id)
|
||||
监听器类型: tcp_reverse, http_beacon, https_beacon, websocket
|
||||
tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + ImplantToken)才登记会话;经典 bash/nc 反弹需在 config.allow_legacy_shell=true(公网不推荐)。
|
||||
端口约束:create/update 的 bind_port 禁止与本平台 Web/API 所用端口相同。当前本服务该端口为 %d(配置项 server.port,随进程启动从配置文件加载)。若 bind_port 与此相同会导致本服务或监听器 bind 失败、Beacon/oneliner 误连到 Web 而非 C2。请为监听器另选空闲端口。`, webListenPort),
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -74,7 +75,7 @@ func registerC2ListenerTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webList
|
||||
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port)", webListenPort), "minimum": 1, "maximum": 65535},
|
||||
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
|
||||
"remark": map[string]interface{}{"type": "string", "description": "备注"},
|
||||
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用"},
|
||||
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false)"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
@@ -222,20 +223,23 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
s.RegisterTool(mcp.Tool{
|
||||
Name: builtin.ToolC2Session,
|
||||
Description: `C2 会话管理。通过 action 参数选择操作:
|
||||
- list: 列出会话(可按 listener_id/status/os/search 过滤)
|
||||
- list: 列出会话(可按 listener_id/status/os/search/suspicious 过滤)
|
||||
- get: 获取会话详情及最近任务历史(需 session_id)
|
||||
- set_sleep: 设置心跳间隔(需 session_id)
|
||||
- kill: 下发 exit 任务让 implant 退出(需 session_id)
|
||||
- delete: 删除会话记录(需 session_id)`,
|
||||
- delete: 删除单个会话记录(需 session_id)
|
||||
- delete_batch: 批量删除会话(需 session_ids 数组)`,
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete", "enum": []string{"list", "get", "set_sleep", "kill", "delete"}},
|
||||
"action": map[string]interface{}{"type": "string", "description": "操作: list/get/set_sleep/kill/delete/delete_batch", "enum": []string{"list", "get", "set_sleep", "kill", "delete", "delete_batch"}},
|
||||
"session_id": map[string]interface{}{"type": "string", "description": "会话 ID(get/set_sleep/kill/delete 需要)"},
|
||||
"session_ids": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}, "description": "会话 ID 列表(delete_batch)"},
|
||||
"listener_id": map[string]interface{}{"type": "string", "description": "按监听器过滤(list)"},
|
||||
"status": map[string]interface{}{"type": "string", "description": "按状态过滤: active/sleeping/dead/killed(list)"},
|
||||
"os": map[string]interface{}{"type": "string", "description": "按 OS 过滤: linux/windows/darwin(list)"},
|
||||
"search": map[string]interface{}{"type": "string", "description": "模糊搜索 hostname/username/IP(list)"},
|
||||
"suspicious": map[string]interface{}{"type": "boolean", "description": "仅疑似误报:离线且 tcp_* / unknown / PID 0(list)"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "返回数量上限(list)"},
|
||||
"sleep_seconds": map[string]interface{}{"type": "integer", "description": "心跳间隔秒数(set_sleep)"},
|
||||
"jitter_percent": map[string]interface{}{"type": "integer", "description": "抖动百分比 0-100(set_sleep)"},
|
||||
@@ -257,6 +261,9 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
if limit := int(getFloat64(params, "limit")); limit > 0 {
|
||||
filter.Limit = limit
|
||||
}
|
||||
if v, ok := params["suspicious"].(bool); ok && v {
|
||||
filter.Suspicious = true
|
||||
}
|
||||
sessions, err := m.DB().ListC2Sessions(filter)
|
||||
return makeC2Result(map[string]interface{}{"sessions": sessions, "count": len(sessions)}, err)
|
||||
|
||||
@@ -274,8 +281,16 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
case "set_sleep":
|
||||
sleep := int(getFloat64(params, "sleep_seconds"))
|
||||
jitter := int(getFloat64(params, "jitter_percent"))
|
||||
err := m.DB().SetC2SessionSleep(id, sleep, jitter)
|
||||
return makeC2Result(map[string]interface{}{"updated": err == nil, "sleep_seconds": sleep, "jitter_percent": jitter}, err)
|
||||
task, err := m.SetSessionSleep(id, sleep, jitter)
|
||||
out := map[string]interface{}{
|
||||
"updated": err == nil,
|
||||
"sleep_seconds": sleep,
|
||||
"jitter_percent": jitter,
|
||||
}
|
||||
if task != nil {
|
||||
out["task_id"] = task.ID
|
||||
}
|
||||
return makeC2Result(out, err)
|
||||
|
||||
case "kill":
|
||||
task, err := m.EnqueueTask(c2.EnqueueTaskInput{
|
||||
@@ -292,6 +307,17 @@ func registerC2SessionTool(s *mcp.Server, m *c2.Manager, l *zap.Logger) {
|
||||
err := m.DB().DeleteC2Session(id)
|
||||
return makeC2Result(map[string]interface{}{"deleted": err == nil}, err)
|
||||
|
||||
case "delete_batch":
|
||||
rawIDs, _ := params["session_ids"].([]interface{})
|
||||
ids := make([]string, 0, len(rawIDs))
|
||||
for _, v := range rawIDs {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
ids = append(ids, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
n, err := m.DB().DeleteC2SessionsByIDs(ids)
|
||||
return makeC2Result(map[string]interface{}{"deleted": n}, err)
|
||||
|
||||
default:
|
||||
return makeC2Result(nil, fmt.Errorf("unknown action: %s", action))
|
||||
}
|
||||
@@ -491,11 +517,11 @@ func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListe
|
||||
Name: builtin.ToolC2Payload,
|
||||
Description: fmt.Sprintf(`C2 Payload 生成。通过 action 参数选择操作:
|
||||
- oneliner: 生成单行 payload。kind 必须与监听器协议一致,否则会失败:
|
||||
• tcp_reverse:裸 TCP 反弹,可用 kind: bash, nc, nc_mkfifo, python, perl, powershell(bash 指 /dev/tcp 类,不是 HTTP)。
|
||||
• tcp_reverse:默认仅支持 build 加密 Beacon;若监听器 config.allow_legacy_shell=true,才可用 kind: bash, nc, nc_mkfifo, python, perl, powershell。
|
||||
• http_beacon / https_beacon / websocket:仅 HTTP(S) Beacon 轮询,oneliner 只能用 kind: curl_beacon(脚本内用 bash+curl,与「tcp 的 bash」不同)。curl_beacon 返回串末尾含「 &」用于把整个 bash -c 放后台;若用 exec/execute 同步执行,必须整段原样复制(含末尾 &)。若删掉 &,内部 while 死循环占满前台,调用会一直阻塞到超时/杀进程。
|
||||
• 需要经典 bash 反弹 shell 时:先 c2_listener create type=tcp_reverse,再对该监听器用 kind=bash。
|
||||
• 公网部署 tcp_reverse 请用 build 生成加密 Beacon,勿开启 allow_legacy_shell。
|
||||
• 省略 kind 时,会按监听器类型自动选第一个兼容类型(HTTP 系默认为 curl_beacon)。
|
||||
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reverse(tcp_reverse 下植入端回连后先发魔数 CSB1,再走与 HTTP 相同的 AES-GCM JSON 语义;未发魔数的连接仍按经典交互 shell 处理)。
|
||||
- build: 交叉编译 beacon 二进制。支持 http_beacon / https_beacon / websocket / tcp_reverse(tcp_reverse 植入端回连后先发魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话)。
|
||||
依赖的监听器 bind_port 须避开本服务 Web 端口 %d(配置 server.port,与 c2_listener 描述一致),否则 Beacon 无法正确回连。`, webListenPort),
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
@@ -540,6 +566,9 @@ func registerC2PayloadTool(s *mcp.Server, m *c2.Manager, l *zap.Logger, webListe
|
||||
}
|
||||
return makeC2Result(nil, fmt.Errorf("监听器类型 %s 不支持 %s,兼容类型: %v", listener.Type, kind, names))
|
||||
}
|
||||
if err := c2.ValidateOnelinerForListener(listener, kind); err != nil {
|
||||
return makeC2Result(nil, err)
|
||||
}
|
||||
input := c2.OnelinerInput{
|
||||
Kind: kind,
|
||||
Host: host,
|
||||
|
||||
@@ -89,6 +89,28 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
||||
"type": "string",
|
||||
"description": "可选:关联的漏洞记录 ID",
|
||||
},
|
||||
"links": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "可选:关系边(from → 当前 fact)。finding 至少 1 条 {from:target/*, type:discovered_on};finding 上记录 exploit 用 {from:exploit/*, type:exploits}。省略保留已有边;传 [] 清空全部关系边。",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"from": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "来源 fact_key:存储为 from → 当前 fact",
|
||||
},
|
||||
"type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "depends_on | leads_to | enables | exploits | discovered_on | contains | part_of | supports",
|
||||
},
|
||||
"confidence": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "confirmed | tentative | deprecated",
|
||||
},
|
||||
},
|
||||
"required": []string{"from", "type"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"fact_key", "summary"},
|
||||
},
|
||||
@@ -124,7 +146,26 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
||||
if err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
if _, hasLinks := args["links"]; hasLinks {
|
||||
linkInputs, err := project.ParseFactLinkInputs(args["links"])
|
||||
if err != nil {
|
||||
return textResult("错误: "+err.Error(), true), nil
|
||||
}
|
||||
convID := agent.ConversationIDFromContext(ctx)
|
||||
if err := project.PersistFactLinksFromParsed(db, projectID, created.FactKey, convID, linkInputs, true); err != nil {
|
||||
return textResult("错误: 保存关系边失败: "+err.Error(), true), nil
|
||||
}
|
||||
created, _ = db.GetProjectFactByKey(projectID, created.FactKey)
|
||||
} else if parsed := project.ParseLinksFromBody(created.Body); len(parsed) > 0 {
|
||||
if err := project.PersistFactIncomingLinks(db, projectID, created.FactKey, parsed, true); err != nil {
|
||||
return textResult("错误: 从 body 解析边失败: "+err.Error(), true), nil
|
||||
}
|
||||
created, _ = db.GetProjectFactByKey(projectID, created.FactKey)
|
||||
}
|
||||
msg := fmt.Sprintf("事实已保存。\nfact_key: %s\nid: %s\nconfidence: %s", created.FactKey, created.ID, created.Confidence)
|
||||
if in, _ := db.ListIncomingProjectFactEdges(projectID, created.FactKey); len(in) > 0 {
|
||||
msg += "\n关系边: " + project.FormatFactLinksText(in)
|
||||
}
|
||||
if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" {
|
||||
msg += warn
|
||||
}
|
||||
@@ -164,6 +205,18 @@ func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *confi
|
||||
if f.SourceConversationID != "" {
|
||||
msg += fmt.Sprintf("\nsource_conversation_id: %s", f.SourceConversationID)
|
||||
}
|
||||
if in, _ := db.ListIncomingProjectFactEdges(projectID, f.FactKey); len(in) > 0 {
|
||||
msg += "\n关系边(from → 本 fact):\n"
|
||||
for _, e := range in {
|
||||
msg += fmt.Sprintf("- %s ← %s (%s)\n", e.EdgeType, e.SourceFactKey, e.Confidence)
|
||||
}
|
||||
}
|
||||
if out, _ := db.ListOutgoingProjectFactEdges(projectID, f.FactKey); len(out) > 0 {
|
||||
msg += "指向其他事实:\n"
|
||||
for _, e := range out {
|
||||
msg += fmt.Sprintf("- %s → %s (%s)\n", e.EdgeType, e.TargetFactKey, e.Confidence)
|
||||
}
|
||||
}
|
||||
msg += "\n\n--- body ---\n" + f.Body
|
||||
if warn := project.SparseBodyWarningIfNeeded(f.Category, f.FactKey, f.Body); warn != "" {
|
||||
msg += warn
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package attackchain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/project"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var promoteSlugSanitizer = regexp.MustCompile(`[^a-z0-9._/-]+`)
|
||||
|
||||
// PromoteToProjectResult 攻击链沉淀结果。
|
||||
type PromoteToProjectResult struct {
|
||||
FactsCreated int `json:"facts_created"`
|
||||
FactsUpdated int `json:"facts_updated"`
|
||||
EdgesCreated int `json:"edges_created"`
|
||||
FactKeys []string `json:"fact_keys"`
|
||||
Graph *database.ProjectFactGraph `json:"graph,omitempty"`
|
||||
}
|
||||
|
||||
// PromoteToProject 将对话攻击链沉淀为项目事实与边。
|
||||
func PromoteToProject(db *database.DB, projectID, conversationID string) (*PromoteToProjectResult, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database 未初始化")
|
||||
}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if projectID == "" || conversationID == "" {
|
||||
return nil, fmt.Errorf("project_id 与 conversation_id 必填")
|
||||
}
|
||||
if _, err := db.GetProject(projectID); err != nil {
|
||||
return nil, fmt.Errorf("项目不存在")
|
||||
}
|
||||
conv, err := db.GetConversation(conversationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("对话不存在")
|
||||
}
|
||||
if pid := strings.TrimSpace(conv.ProjectID); pid != "" && pid != projectID {
|
||||
return nil, fmt.Errorf("对话已绑定其他项目")
|
||||
}
|
||||
|
||||
nodes, err := db.LoadAttackChainNodes(conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
edges, err := db.LoadAttackChainEdges(conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return nil, fmt.Errorf("该对话尚无攻击链,请先在对话中生成攻击链")
|
||||
}
|
||||
|
||||
res := &PromoteToProjectResult{}
|
||||
nodeToKey := make(map[string]string, len(nodes))
|
||||
usedKeys := map[string]int{}
|
||||
|
||||
for _, node := range nodes {
|
||||
key := allocatePromoteFactKey(node, usedKeys)
|
||||
nodeToKey[node.ID] = key
|
||||
category := mapPromoteNodeCategory(node.Type)
|
||||
existing, getErr := db.GetProjectFactByKey(projectID, key)
|
||||
f := &database.ProjectFact{
|
||||
ProjectID: projectID,
|
||||
FactKey: key,
|
||||
Category: category,
|
||||
Summary: strings.TrimSpace(node.Label),
|
||||
Body: formatPromotedFactBody(node, conversationID),
|
||||
Confidence: "tentative",
|
||||
SourceConversationID: conversationID,
|
||||
}
|
||||
if getErr == nil && existing != nil {
|
||||
f.ID = existing.ID
|
||||
f.CreatedAt = existing.CreatedAt
|
||||
if strings.TrimSpace(f.Summary) == "" {
|
||||
f.Summary = existing.Summary
|
||||
}
|
||||
if _, err := db.UpsertProjectFact(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.FactsUpdated++
|
||||
} else {
|
||||
if _, err := db.UpsertProjectFact(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.FactsCreated++
|
||||
}
|
||||
res.FactKeys = append(res.FactKeys, key)
|
||||
}
|
||||
|
||||
for _, edge := range edges {
|
||||
srcKey, ok1 := nodeToKey[edge.Source]
|
||||
tgtKey, ok2 := nodeToKey[edge.Target]
|
||||
if !ok1 || !ok2 || srcKey == tgtKey {
|
||||
continue
|
||||
}
|
||||
edgeType := mapPromoteEdgeType(edge.Type)
|
||||
incoming, _ := db.ListIncomingProjectFactEdges(projectID, tgtKey)
|
||||
merged := project.MergeLinkFromInputsUnique(promoteFromEdgeInputsFromDB(incoming), []database.ProjectFactEdgeFromInput{{From: srcKey, Type: edgeType}})
|
||||
if err := db.ReplaceIncomingProjectFactEdges(projectID, tgtKey, merged); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.EdgesCreated++
|
||||
if fact, err := db.GetProjectFactByKey(projectID, tgtKey); err == nil {
|
||||
in, _ := db.ListIncomingProjectFactEdges(projectID, tgtKey)
|
||||
fact.Body = project.SyncBodyLinksSection(fact.Body, in)
|
||||
_, _ = db.UpsertProjectFact(fact)
|
||||
}
|
||||
}
|
||||
|
||||
graph, _ := project.BuildProjectFactGraph(db, projectID, "full", true)
|
||||
res.Graph = graph
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func promoteFromEdgeInputsFromDB(edges []*database.ProjectFactEdge) []database.ProjectFactEdgeFromInput {
|
||||
out := make([]database.ProjectFactEdgeFromInput, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
out = append(out, database.ProjectFactEdgeFromInput{From: e.SourceFactKey, Type: e.EdgeType, Confidence: e.Confidence})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapPromoteNodeCategory(nodeType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(nodeType)) {
|
||||
case "target":
|
||||
return project.FactCategoryTarget
|
||||
case "vulnerability":
|
||||
return project.FactCategoryFinding
|
||||
case "action":
|
||||
return project.FactCategoryChain
|
||||
default:
|
||||
return project.FactCategoryNote
|
||||
}
|
||||
}
|
||||
|
||||
func mapPromoteEdgeType(t string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "discovers", "discovered_on", "targets":
|
||||
return "discovered_on"
|
||||
case "exploits":
|
||||
return "exploits"
|
||||
case "enables":
|
||||
return "enables"
|
||||
case "depends_on":
|
||||
return "depends_on"
|
||||
default:
|
||||
return "leads_to"
|
||||
}
|
||||
}
|
||||
|
||||
func allocatePromoteFactKey(node Node, used map[string]int) string {
|
||||
prefix := "chain/"
|
||||
switch strings.ToLower(strings.TrimSpace(node.Type)) {
|
||||
case "target":
|
||||
prefix = "target/"
|
||||
case "vulnerability":
|
||||
prefix = "finding/"
|
||||
case "action":
|
||||
prefix = "chain/"
|
||||
}
|
||||
base := promoteSlugify(node.Label)
|
||||
if base == "" {
|
||||
base = promoteSlugify(node.ID)
|
||||
}
|
||||
if base == "" {
|
||||
base = uuid.New().String()[:8]
|
||||
}
|
||||
key := prefix + base
|
||||
if n, ok := used[key]; ok {
|
||||
n++
|
||||
used[key] = n
|
||||
key = fmt.Sprintf("%s-%d", key, n)
|
||||
} else {
|
||||
used[key] = 1
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func promoteSlugify(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.NewReplacer(" ", "-", "—", "-", "–", "-", "/", "-").Replace(s)
|
||||
s = promoteSlugSanitizer.ReplaceAllString(s, "-")
|
||||
s = strings.Trim(s, "-")
|
||||
if len(s) > 64 {
|
||||
s = s[:64]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func formatPromotedFactBody(node Node, conversationID string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 来源\n")
|
||||
b.WriteString(fmt.Sprintf("- 对话攻击链沉淀\n- source_conversation_id: %s\n- node_id: %s\n- node_type: %s\n\n", conversationID, node.ID, node.Type))
|
||||
b.WriteString("## 摘要\n")
|
||||
b.WriteString(strings.TrimSpace(node.Label))
|
||||
b.WriteString("\n\n## 关联\n- 结构化关系边(自动同步):\n (见项目攻击路径图)\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -20,10 +20,9 @@ import (
|
||||
)
|
||||
|
||||
// TCPReverseListener 监听 TCP 端口,等待目标机反弹连接。
|
||||
// 经典模式:纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容。
|
||||
// 二进制 Beacon:连接后先发送魔数 CSB1,随后使用与 HTTP Beacon 相同的 AES-GCM JSON 语义(成帧见 tcp_beacon_server.go)。
|
||||
// 每个新连接自动生成一个 implant_uuid(基于远端地址 + 启动时间 hash),登记为 c2_session;
|
||||
// 任务派发:使用同步 exec 模式 —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
|
||||
// 默认仅接受加密 TCP Beacon:连接后先发送魔数 CSB1,再经 AES-GCM 解密且校验 ImplantToken 后才登记会话。
|
||||
// 可选经典模式(config.allow_legacy_shell=true):纯交互式 raw shell,与 nc / bash -i >& /dev/tcp 兼容,无鉴权,仅建议内网实验。
|
||||
// 任务派发(经典模式):同步 exec —— 收到 task 时直接 send 命令字节并读取输出(带结束标记)。
|
||||
type TCPReverseListener struct {
|
||||
rec *database.C2Listener
|
||||
cfg *ListenerConfig
|
||||
@@ -122,12 +121,14 @@ func (l *TCPReverseListener) acceptLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
// handleConn 一个连接=一个会话:先识别二进制 TCP Beacon(魔数 CSB1),否则走经典交互式 shell。
|
||||
// handleConn 先识别加密 TCP Beacon(魔数 CSB1 + AES-GCM + Token);未通过则按配置拒绝或走经典 shell。
|
||||
func (l *TCPReverseListener) handleConn(conn net.Conn) {
|
||||
br := bufio.NewReader(conn)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(20 * time.Second))
|
||||
prefix, err := br.Peek(4)
|
||||
if err == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
|
||||
remote := conn.RemoteAddr().String()
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(tcpBeaconPeekTimeout))
|
||||
prefix, peekErr := br.Peek(4)
|
||||
if peekErr == nil && len(prefix) == 4 && string(prefix) == tcpBeaconMagic {
|
||||
if _, err := br.Discard(4); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
@@ -136,14 +137,22 @@ func (l *TCPReverseListener) handleConn(conn net.Conn) {
|
||||
l.handleTCPBeaconSession(conn, br)
|
||||
return
|
||||
}
|
||||
|
||||
if !l.cfg.AllowLegacyShell {
|
||||
l.logger.Debug("tcp_reverse 拒绝未加密连接", zap.String("remote", remote))
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
l.handleShellConn(conn, br)
|
||||
}
|
||||
|
||||
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容)。
|
||||
// handleShellConn 经典裸 TCP 反弹 shell(与 nc/bash /dev/tcp 兼容);需监听器显式开启 allow_legacy_shell。
|
||||
func (l *TCPReverseListener) handleShellConn(conn net.Conn, br *bufio.Reader) {
|
||||
remote := conn.RemoteAddr().String()
|
||||
host, _, _ := net.SplitHostPort(remote)
|
||||
|
||||
// 用 listener+remote_ip 生成稳定 implant_uuid,使同一来源的重连复用同一会话
|
||||
uuidSeed := fmt.Sprintf("%s|%s", l.rec.ID, host)
|
||||
hash := sha256.Sum256([]byte(uuidSeed))
|
||||
|
||||
+41
-1
@@ -381,8 +381,10 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if existing != nil {
|
||||
// 保留原 ID/FirstSeenAt/Note,避免被覆盖
|
||||
// 保留原 ID/FirstSeenAt/Note 与操作员设置的 sleep/jitter,避免被 beacon 心跳上报覆盖
|
||||
session.FirstSeenAt = existing.FirstSeenAt
|
||||
session.SleepSeconds = existing.SleepSeconds
|
||||
session.JitterPercent = existing.JitterPercent
|
||||
if session.Note == "" {
|
||||
session.Note = existing.Note
|
||||
}
|
||||
@@ -413,6 +415,44 @@ func (m *Manager) IngestCheckIn(listenerID string, req ImplantCheckInRequest) (*
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// SetSessionSleep 更新会话期望的心跳间隔,并向植入体下发 sleep 任务以尽快生效。
|
||||
func (m *Manager) SetSessionSleep(sessionID string, sleepSeconds, jitterPercent int) (*database.C2Task, error) {
|
||||
if strings.TrimSpace(sessionID) == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if sleepSeconds < 1 {
|
||||
sleepSeconds = 1
|
||||
}
|
||||
if jitterPercent < 0 {
|
||||
jitterPercent = 0
|
||||
}
|
||||
if jitterPercent > 100 {
|
||||
jitterPercent = 100
|
||||
}
|
||||
if err := m.db.SetC2SessionSleep(sessionID, sleepSeconds, jitterPercent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, err := m.EnqueueTask(EnqueueTaskInput{
|
||||
SessionID: sessionID,
|
||||
TaskType: TaskTypeSleep,
|
||||
Payload: map[string]interface{}{
|
||||
"seconds": sleepSeconds,
|
||||
"jitter": jitterPercent,
|
||||
},
|
||||
Source: "manual",
|
||||
})
|
||||
if err != nil {
|
||||
m.logger.Warn("sleep 任务入队失败", zap.Error(err), zap.String("session_id", sessionID))
|
||||
}
|
||||
m.publishEvent("info", "session", sessionID, "",
|
||||
fmt.Sprintf("Sleep 已更新: %ds (抖动 %d%%)", sleepSeconds, jitterPercent),
|
||||
map[string]interface{}{
|
||||
"sleep_seconds": sleepSeconds,
|
||||
"jitter_percent": jitterPercent,
|
||||
})
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// MarkSessionDead 心跳超时检测器调用:标记会话为 dead
|
||||
func (m *Manager) MarkSessionDead(sessionID string) error {
|
||||
if err := m.db.SetC2SessionStatus(sessionID, string(SessionDead)); err != nil {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package c2
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestIngestCheckIn_PreservesOperatorSleepOnHeartbeat(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() })
|
||||
|
||||
mgr := NewManager(db, zap.NewNop(), tmp)
|
||||
ln, err := mgr.CreateListener(CreateListenerInput{
|
||||
Name: "t",
|
||||
Type: string(ListenerTypeHTTPBeacon),
|
||||
BindHost: "127.0.0.1",
|
||||
BindPort: 18080,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||
ImplantUUID: "implant-uuid-1",
|
||||
Hostname: "host1",
|
||||
Username: "user",
|
||||
OS: "darwin",
|
||||
Arch: "amd64",
|
||||
SleepSeconds: 5,
|
||||
JitterPercent: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := db.SetC2SessionSleep(first.ID, 30, 20); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
second, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||
ImplantUUID: "implant-uuid-1",
|
||||
Hostname: "host1",
|
||||
Username: "user",
|
||||
OS: "darwin",
|
||||
Arch: "amd64",
|
||||
SleepSeconds: 5,
|
||||
JitterPercent: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.SleepSeconds != 30 || second.JitterPercent != 20 {
|
||||
t.Fatalf("expected sleep=30 jitter=20, got sleep=%d jitter=%d", second.SleepSeconds, second.JitterPercent)
|
||||
}
|
||||
|
||||
stored, err := db.GetC2Session(first.ID)
|
||||
if err != nil || stored == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.SleepSeconds != 30 || stored.JitterPercent != 20 {
|
||||
t.Fatalf("db: expected sleep=30 jitter=20, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSessionSleep_UpdatesDBAndEnqueuesTask(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() })
|
||||
|
||||
mgr := NewManager(db, zap.NewNop(), tmp)
|
||||
ln, err := mgr.CreateListener(CreateListenerInput{
|
||||
Name: "t2",
|
||||
Type: string(ListenerTypeHTTPBeacon),
|
||||
BindHost: "127.0.0.1",
|
||||
BindPort: 18081,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sess, err := mgr.IngestCheckIn(ln.ID, ImplantCheckInRequest{
|
||||
ImplantUUID: "implant-uuid-2",
|
||||
Hostname: "host2",
|
||||
Username: "user",
|
||||
OS: "linux",
|
||||
Arch: "amd64",
|
||||
SleepSeconds: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
task, err := mgr.SetSessionSleep(sess.ID, 15, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task == nil || task.TaskType != string(TaskTypeSleep) {
|
||||
t.Fatalf("expected sleep task, got %#v", task)
|
||||
}
|
||||
|
||||
stored, err := db.GetC2Session(sess.ID)
|
||||
if err != nil || stored == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.SleepSeconds != 15 || stored.JitterPercent != 10 {
|
||||
t.Fatalf("expected sleep=15 jitter=10, got sleep=%d jitter=%d", stored.SleepSeconds, stored.JitterPercent)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
package c2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
)
|
||||
|
||||
// OnelinerKind 单行 payload 的语言/形式
|
||||
@@ -79,6 +82,23 @@ type OnelinerInput struct {
|
||||
ImplantToken string // HTTP Beacon 鉴权 token
|
||||
}
|
||||
|
||||
// ValidateOnelinerForListener 校验 oneliner 与监听器配置是否匹配(如 tcp_reverse 默认要求加密 Beacon)。
|
||||
func ValidateOnelinerForListener(listener *database.C2Listener, kind OnelinerKind) error {
|
||||
if listener == nil {
|
||||
return fmt.Errorf("listener is nil")
|
||||
}
|
||||
if ListenerType(listener.Type) == ListenerTypeTCPReverse && tcpOnelinerKinds[kind] {
|
||||
cfg := &ListenerConfig{}
|
||||
if strings.TrimSpace(listener.ConfigJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(listener.ConfigJSON), cfg)
|
||||
}
|
||||
if !cfg.AllowLegacyShell {
|
||||
return fmt.Errorf("监听器未开启 allow_legacy_shell:tcp_reverse 默认仅接受 CSB1 加密 Beacon(AES-GCM + Token);请用 build 生成 beacon,或显式开启 allow_legacy_shell(公网不推荐)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateOneliner 生成单行 payload。
|
||||
// 设计要点:
|
||||
// - 不依赖目标机预装的可执行(除该 oneliner 关键的 bash/python/perl 等);
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
// tcpBeaconMagic 二进制 Beacon 在反向 TCP 连接建立后首先发送的 4 字节,用于与经典 shell 反弹区分。
|
||||
const tcpBeaconMagic = "CSB1"
|
||||
|
||||
// tcpBeaconPeekTimeout 等待 CSB1 魔数的探测窗口;合法 Beacon 连接后立即发送魔数。
|
||||
const tcpBeaconPeekTimeout = 2 * time.Second
|
||||
|
||||
// tcpBeaconMaxFrame 单帧密文(base64 字符串)最大字节数,防止 OOM。
|
||||
const tcpBeaconMaxFrame = 64 << 20
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ type ListenerConfig struct {
|
||||
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
|
||||
// CallbackHost 植入端/Payload 使用的回连主机名(可选);与 bind_host 分离,便于 NAT/ECS 等场景
|
||||
CallbackHost string `json:"callback_host,omitempty"`
|
||||
// AllowLegacyShell 为 true 时 tcp_reverse 允许未加密的经典 bash/nc 反弹 shell 登记会话(默认 false,公网部署强烈不建议开启)
|
||||
AllowLegacyShell bool `json:"allow_legacy_shell,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyDefaults 对未填字段填默认值;调用方负责持久化时序列化新值
|
||||
|
||||
@@ -45,6 +45,7 @@ type ProjectConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
DefaultProjectID string `yaml:"default_project_id,omitempty" json:"default_project_id,omitempty"` // 机器人/批量等无显式项目时绑定的默认项目
|
||||
FactIndexMaxRunes int `yaml:"fact_index_max_runes,omitempty" json:"fact_index_max_runes,omitempty"`
|
||||
FactIndexPathMaxRunes int `yaml:"fact_index_path_max_runes,omitempty" json:"fact_index_path_max_runes,omitempty"`
|
||||
FactSummaryMaxRunes int `yaml:"fact_summary_max_runes,omitempty" json:"fact_summary_max_runes,omitempty"`
|
||||
DefaultInjectDeprecated bool `yaml:"default_inject_deprecated,omitempty" json:"default_inject_deprecated,omitempty"`
|
||||
}
|
||||
@@ -57,6 +58,14 @@ func (c ProjectConfig) FactIndexMaxRunesEffective() int {
|
||||
return c.FactIndexMaxRunes
|
||||
}
|
||||
|
||||
// FactIndexPathMaxRunesEffective 攻击路径速览段的最大 rune 数(从 fact_index_max_runes 预算中预留)。
|
||||
func (c ProjectConfig) FactIndexPathMaxRunesEffective() int {
|
||||
if c.FactIndexPathMaxRunes <= 0 {
|
||||
return 1000
|
||||
}
|
||||
return c.FactIndexPathMaxRunes
|
||||
}
|
||||
|
||||
// FactSummaryMaxRunesEffective upsert 时 summary 最大 rune 数(索引一行,宜含验证要点)。
|
||||
func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
|
||||
if c.FactSummaryMaxRunes <= 0 {
|
||||
@@ -231,7 +240,7 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
PlantaskRelDir string `yaml:"plantask_rel_dir,omitempty" json:"plantask_rel_dir,omitempty"`
|
||||
// Reduction truncates/offloads large tool outputs (requires eino local backend for Write).
|
||||
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
|
||||
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // default: os temp + conversation id
|
||||
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // 非空:落盘根目录(默认 tmp/reduction);其下按 projects/{id} 或 conversations/{id} 隔离
|
||||
ReductionMaxLengthForTrunc int `yaml:"reduction_max_length_for_trunc,omitempty" json:"reduction_max_length_for_trunc,omitempty"` // default 12000
|
||||
ReductionMaxTokensForClear int `yaml:"reduction_max_tokens_for_clear,omitempty" json:"reduction_max_tokens_for_clear,omitempty"` // default 50000
|
||||
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
|
||||
@@ -593,10 +602,8 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
LargeResultThreshold int `yaml:"large_result_threshold" json:"large_result_threshold"` // 大结果阈值(字节),默认50KB
|
||||
ResultStorageDir string `yaml:"result_storage_dir" json:"result_storage_dir"` // 结果存储目录,默认tmp
|
||||
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
||||
// SystemPromptPath 单代理系统提示 Markdown/文本文件路径(相对 config.yaml 所在目录,或可写绝对路径)。非空且可读时替换内置单代理提示;留空用内置。
|
||||
SystemPromptPath string `yaml:"system_prompt_path,omitempty" json:"system_prompt_path,omitempty"`
|
||||
}
|
||||
|
||||
@@ -69,12 +69,12 @@ func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) {
|
||||
args = append(args, filter.ResourceID)
|
||||
}
|
||||
if filter.Since != nil {
|
||||
conditions = append(conditions, "created_at >= ?")
|
||||
args = append(args, *filter.Since)
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Since))
|
||||
}
|
||||
if filter.Until != nil {
|
||||
conditions = append(conditions, "created_at <= ?")
|
||||
args = append(args, *filter.Until)
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", "<="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Until))
|
||||
}
|
||||
if q := strings.TrimSpace(filter.Query); q != "" {
|
||||
like := "%" + q + "%"
|
||||
@@ -93,7 +93,9 @@ func (db *DB) AppendAuditLog(row *AuditLog) error {
|
||||
return errors.New("audit id is required")
|
||||
}
|
||||
if row.CreatedAt.IsZero() {
|
||||
row.CreatedAt = time.Now()
|
||||
row.CreatedAt = time.Now().UTC()
|
||||
} else {
|
||||
row.CreatedAt = row.CreatedAt.UTC()
|
||||
}
|
||||
if strings.TrimSpace(row.Level) == "" {
|
||||
row.Level = "info"
|
||||
@@ -111,7 +113,7 @@ func (db *DB) AppendAuditLog(row *AuditLog) error {
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query,
|
||||
row.ID, row.CreatedAt, row.Level, row.Category, row.Action, row.Result,
|
||||
row.ID, formatSQLiteUTC(row.CreatedAt), row.Level, row.Category, row.Action, row.Result,
|
||||
row.Actor, row.SessionHint, row.ClientIP, row.UserAgent,
|
||||
row.ResourceType, row.ResourceID, row.Message, detailJSON,
|
||||
)
|
||||
@@ -202,7 +204,7 @@ func (db *DB) ListAuditLogs(filter ListAuditLogsFilter) ([]*AuditLog, error) {
|
||||
|
||||
// DeleteAuditLogsBefore removes rows older than cutoff.
|
||||
func (db *DB) DeleteAuditLogsBefore(cutoff time.Time) (int64, error) {
|
||||
res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < ?`, cutoff)
|
||||
res, err := db.Exec(`DELETE FROM audit_logs WHERE `+sqliteEpochGE("created_at", "<"), formatSQLiteUTC(cutoff))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestBuildAuditLogsWhere_timeFilterSQL(t *testing.T) {
|
||||
since := time.Date(2026, 6, 16, 17, 2, 0, 0, time.UTC)
|
||||
until := time.Date(2026, 6, 17, 3, 3, 0, 0, time.UTC)
|
||||
where, args := buildAuditLogsWhere(ListAuditLogsFilter{Since: &since, Until: &until})
|
||||
if !strings.Contains(where, "strftime('%s', created_at) >=") {
|
||||
t.Fatalf("expected epoch comparison for since, got %q", where)
|
||||
}
|
||||
if !strings.Contains(where, "strftime('%s', created_at) <=") {
|
||||
t.Fatalf("expected epoch comparison for until, got %q", where)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected 2 time args, got %d", len(args))
|
||||
}
|
||||
for i, arg := range args {
|
||||
s, ok := arg.(string)
|
||||
if !ok || s == "" {
|
||||
t.Fatalf("arg %d: want non-empty UTC RFC3339 string, got %v", i, arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditLogs_timeFilterMixedStorageFormats(t *testing.T) {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
dbPath := filepath.Join(root, "..", "..", "data", "conversations.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Skip("conversations.db not found")
|
||||
}
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
since, _ := ParseRFC3339Time("2026-06-16T17:02:00Z")
|
||||
until, _ := ParseRFC3339Time("2026-06-17T03:03:00Z")
|
||||
filter := ListAuditLogsFilter{Since: &since, Until: &until, Limit: 50}
|
||||
logs, err := db.ListAuditLogs(filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range logs {
|
||||
at := row.CreatedAt.UTC()
|
||||
if at.Before(since) || at.After(until) {
|
||||
t.Fatalf("log %s at %s outside [%s, %s]", row.ID, at, since, until)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -507,6 +507,42 @@ func (db *DB) CancelPendingBatchTasks(queueID string, completedAt time.Time) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrepareBatchSingleTaskRun 准备单条执行:可选重置子任务,并更新队列索引与状态
|
||||
func (db *DB) PrepareBatchSingleTaskRun(queueID, taskID string, taskIndex int, resetTask, resumeQueue bool) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if resetTask {
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_tasks SET status = ?, conversation_id = NULL, started_at = NULL, completed_at = NULL, error = NULL, result = NULL WHERE queue_id = ? AND id = ?",
|
||||
"pending", queueID, taskID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重置批量任务状态失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if resumeQueue {
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_task_queues SET status = ?, current_index = ?, completed_at = NULL, last_run_error = NULL WHERE id = ?",
|
||||
"paused", taskIndex, queueID,
|
||||
)
|
||||
} else {
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_task_queues SET current_index = ?, last_run_error = NULL WHERE id = ?",
|
||||
taskIndex, queueID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务队列状态失败: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteBatchTask 删除批量任务
|
||||
func (db *DB) DeleteBatchTask(queueID, taskID string) error {
|
||||
_, err := db.Exec(
|
||||
|
||||
@@ -17,6 +17,9 @@ var ErrNoValidC2EventIDs = errors.New("no valid event ids")
|
||||
// ErrNoValidC2TaskIDs 批量删除任务时未提供任何合法 ID
|
||||
var ErrNoValidC2TaskIDs = errors.New("no valid task ids")
|
||||
|
||||
// ErrNoValidC2SessionIDs 批量删除会话时未提供任何合法 ID
|
||||
var ErrNoValidC2SessionIDs = errors.New("no valid session ids")
|
||||
|
||||
// validC2TextIDForDelete 校验 C2 文本主键(e_/t_/s_/… 等)用于批量删除入参
|
||||
func validC2TextIDForDelete(id string) bool {
|
||||
if len(id) < 2 || len(id) > 80 {
|
||||
@@ -473,6 +476,7 @@ type ListC2SessionsFilter struct {
|
||||
Status string // active|sleeping|dead|killed;空表示全部
|
||||
OS string
|
||||
Search string // 模糊匹配 hostname/username/internal_ip
|
||||
Suspicious bool // 疑似误报:离线且 hostname 为 tcp_* / 用户名为 unknown / PID 为 0
|
||||
Limit int // 0 表示无限制
|
||||
}
|
||||
|
||||
@@ -497,6 +501,11 @@ func (db *DB) ListC2Sessions(filter ListC2SessionsFilter) ([]*C2Session, error)
|
||||
kw := "%" + filter.Search + "%"
|
||||
args = append(args, kw, kw, kw)
|
||||
}
|
||||
if filter.Suspicious {
|
||||
conditions = append(conditions, `status = 'dead' AND (
|
||||
hostname LIKE 'tcp_%' OR LOWER(COALESCE(username,'')) = 'unknown' OR COALESCE(pid, 0) = 0
|
||||
)`)
|
||||
}
|
||||
query := `
|
||||
SELECT id, listener_id, implant_uuid, COALESCE(hostname,''), COALESCE(username,''),
|
||||
COALESCE(os,''), COALESCE(arch,''), COALESCE(pid, 0), COALESCE(process_name,''),
|
||||
@@ -554,6 +563,44 @@ func (db *DB) DeleteC2Session(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteC2SessionsByIDs 按主键批量删除会话
|
||||
func (db *DB) DeleteC2SessionsByIDs(ids []string) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
const maxBatch = 500
|
||||
if len(ids) > maxBatch {
|
||||
ids = ids[:maxBatch]
|
||||
}
|
||||
clean := make([]string, 0, len(ids))
|
||||
seen := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if !validC2TextIDForDelete(id) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
clean = append(clean, id)
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return 0, ErrNoValidC2SessionIDs
|
||||
}
|
||||
placeholders := strings.Repeat("?,", len(clean)-1) + "?"
|
||||
args := make([]interface{}, len(clean))
|
||||
for i := range clean {
|
||||
args[i] = clean[i]
|
||||
}
|
||||
query := `DELETE FROM c2_sessions WHERE id IN (` + placeholders + `)`
|
||||
res, err := db.Exec(query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// CRUD:C2 任务
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -382,26 +382,40 @@ func (db *DB) CountConversations(search string) (int, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func conversationOrderClause(sortBy, tableAlias string) string {
|
||||
col := "updated_at"
|
||||
if strings.TrimSpace(strings.ToLower(sortBy)) == "created_at" {
|
||||
col = "created_at"
|
||||
}
|
||||
prefix := tableAlias
|
||||
if prefix != "" {
|
||||
prefix += "."
|
||||
}
|
||||
return "ORDER BY " + prefix + col + " DESC"
|
||||
}
|
||||
|
||||
// ListConversations 列出所有对话
|
||||
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
|
||||
func (db *DB) ListConversations(limit, offset int, search, sortBy string) ([]*Conversation, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if search != "" {
|
||||
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
|
||||
searchPattern := "%" + search + "%"
|
||||
orderClause := conversationOrderClause(sortBy, "c")
|
||||
rows, err = db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id
|
||||
FROM conversations c
|
||||
WHERE c.title LIKE ?
|
||||
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
|
||||
ORDER BY c.updated_at DESC
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
searchPattern, searchPattern, limit, offset,
|
||||
)
|
||||
} else {
|
||||
orderClause := conversationOrderClause(sortBy, "")
|
||||
rows, err = db.Query(
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?",
|
||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations "+orderClause+" LIMIT ? OFFSET ?",
|
||||
limit, offset,
|
||||
)
|
||||
}
|
||||
@@ -467,11 +481,12 @@ func (db *DB) CountUngroupedConversations() (int, error) {
|
||||
}
|
||||
|
||||
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
|
||||
func (db *DB) ListUngroupedConversations(limit, offset int) ([]*Conversation, error) {
|
||||
func (db *DB) ListUngroupedConversations(limit, offset int, sortBy string) ([]*Conversation, error) {
|
||||
orderClause := conversationOrderClause(sortBy, "c")
|
||||
rows, err := db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
||||
ungroupedConversationsSQL+`
|
||||
ORDER BY c.updated_at DESC
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
limit, offset,
|
||||
)
|
||||
|
||||
@@ -353,6 +353,22 @@ func (db *DB) initTables() error {
|
||||
UNIQUE(project_id, fact_key)
|
||||
);`
|
||||
|
||||
// 项目事实关系边(黑板 DAG)
|
||||
createProjectFactEdgesTable := `
|
||||
CREATE TABLE IF NOT EXISTS project_fact_edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
source_fact_key TEXT NOT NULL,
|
||||
target_fact_key TEXT NOT NULL,
|
||||
edge_type TEXT NOT NULL,
|
||||
confidence TEXT NOT NULL DEFAULT 'tentative',
|
||||
source_conversation_id TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
UNIQUE(project_id, source_fact_key, target_fact_key, edge_type)
|
||||
);`
|
||||
|
||||
// 创建漏洞表
|
||||
createVulnerabilitiesTable := `
|
||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||
@@ -591,6 +607,9 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_fact_edges_project ON project_fact_edges(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_fact_edges_source ON project_fact_edges(project_id, source_fact_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_fact_edges_target ON project_fact_edges(project_id, target_fact_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
||||
@@ -672,6 +691,10 @@ func (db *DB) initTables() error {
|
||||
return fmt.Errorf("创建project_facts表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createProjectFactEdgesTable); err != nil {
|
||||
return fmt.Errorf("创建project_fact_edges表失败: %w", err)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,23 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateToolExecutionResult 仅更新结果字段(用于 reduction 后将监控展示与模型上下文对齐)。
|
||||
func (db *DB) UpdateToolExecutionResult(id string, result *mcp.ToolResult) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || result == nil {
|
||||
return nil
|
||||
}
|
||||
resultBytes, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`UPDATE tool_executions SET result = ? WHERE id = ?`, string(resultBytes), id)
|
||||
if err != nil {
|
||||
db.logger.Warn("更新工具执行结果失败", zap.Error(err), zap.String("executionId", id))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// CountToolExecutions 统计工具执行记录总数
|
||||
func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM tool_executions`
|
||||
|
||||
@@ -389,7 +389,7 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// DeprecateProjectFact 将事实标记为 deprecated。
|
||||
// DeprecateProjectFact 将事实标记为 deprecated(关联边同步 deprecated)。
|
||||
func (db *DB) DeprecateProjectFact(projectID, factKey string) error {
|
||||
res, err := db.Exec(
|
||||
`UPDATE project_facts SET confidence = 'deprecated', updated_at = ? WHERE project_id = ? AND fact_key = ?`,
|
||||
@@ -402,7 +402,7 @@ func (db *DB) DeprecateProjectFact(projectID, factKey string) error {
|
||||
if n == 0 {
|
||||
return fmt.Errorf("事实不存在")
|
||||
}
|
||||
return nil
|
||||
return db.DeprecateProjectFactEdgesForKey(projectID, factKey)
|
||||
}
|
||||
|
||||
// RestoreProjectFact 将已废弃事实恢复为 tentative 或 confirmed(重新参与黑板索引)。
|
||||
@@ -430,9 +430,16 @@ func (db *DB) RestoreProjectFact(projectID, factKey, confidence string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProjectFact 删除事实。
|
||||
// DeleteProjectFact 删除事实(级联删除相关边)。
|
||||
func (db *DB) DeleteProjectFact(id string) error {
|
||||
_, err := db.Exec(`DELETE FROM project_facts WHERE id = ?`, id)
|
||||
f, err := db.GetProjectFact(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DeleteProjectFactEdgesForKey(f.ProjectID, f.FactKey); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`DELETE FROM project_facts WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ValidProjectFactEdgeTypes 项目事实图允许的边类型。
|
||||
var ValidProjectFactEdgeTypes = map[string]struct{}{
|
||||
"depends_on": {},
|
||||
"leads_to": {},
|
||||
"enables": {},
|
||||
"exploits": {},
|
||||
"discovered_on": {},
|
||||
"contains": {},
|
||||
"part_of": {},
|
||||
"supports": {},
|
||||
}
|
||||
|
||||
// ProjectFactEdge 项目事实关系边(source → target)。
|
||||
type ProjectFactEdge struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SourceFactKey string `json:"source_fact_key"`
|
||||
TargetFactKey string `json:"target_fact_key"`
|
||||
EdgeType string `json:"edge_type"`
|
||||
Confidence string `json:"confidence"` // confirmed | tentative | deprecated
|
||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectFactEdgeInput 写入边时的输入(出边:source → To)。
|
||||
type ProjectFactEdgeInput struct {
|
||||
To string `json:"to"`
|
||||
Type string `json:"type"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectFactEdgeFromInput 写入入边时的输入(From → 当前事实)。
|
||||
type ProjectFactEdgeFromInput struct {
|
||||
From string `json:"from"`
|
||||
Type string `json:"type"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectFactGraphNode 图 API 节点。
|
||||
type ProjectFactGraphNode struct {
|
||||
ID string `json:"id"`
|
||||
FactKey string `json:"fact_key"`
|
||||
Category string `json:"category"`
|
||||
Label string `json:"label"` // 图节点短标签(截断)
|
||||
Summary string `json:"summary"` // 完整摘要(侧栏等详情用)
|
||||
Confidence string `json:"confidence"`
|
||||
Type string `json:"type"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
// ProjectFactGraphEdge 图 API 边。
|
||||
type ProjectFactGraphEdge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Type string `json:"type"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
// ProjectFactGraph 项目事实图。
|
||||
type ProjectFactGraph struct {
|
||||
Nodes []ProjectFactGraphNode `json:"nodes"`
|
||||
Edges []ProjectFactGraphEdge `json:"edges"`
|
||||
}
|
||||
|
||||
// ValidateProjectFactEdgeType 校验边类型。
|
||||
func ValidateProjectFactEdgeType(edgeType string) error {
|
||||
edgeType = strings.TrimSpace(strings.ToLower(edgeType))
|
||||
if edgeType == "" {
|
||||
return fmt.Errorf("edge type 不能为空")
|
||||
}
|
||||
if _, ok := ValidProjectFactEdgeTypes[edgeType]; !ok {
|
||||
return fmt.Errorf("无效的 edge type: %s", edgeType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEdgeConfidence(confidence string) string {
|
||||
confidence = strings.TrimSpace(strings.ToLower(confidence))
|
||||
switch confidence {
|
||||
case "confirmed", "deprecated":
|
||||
return confidence
|
||||
default:
|
||||
return "tentative"
|
||||
}
|
||||
}
|
||||
|
||||
// ListProjectFactEdgesByProject 列出项目全部边。
|
||||
func (db *DB) ListProjectFactEdgesByProject(projectID string) ([]*ProjectFactEdge, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at ASC, rowid ASC`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjectFactEdges(rows)
|
||||
}
|
||||
|
||||
// ListOutgoingProjectFactEdges 列出某事实的全部出边。
|
||||
func (db *DB) ListOutgoingProjectFactEdges(projectID, sourceFactKey string) ([]*ProjectFactEdge, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges
|
||||
WHERE project_id = ? AND source_fact_key = ?
|
||||
ORDER BY created_at ASC, rowid ASC`,
|
||||
projectID, sourceFactKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjectFactEdges(rows)
|
||||
}
|
||||
|
||||
// ListIncomingProjectFactEdges 列出某事实的全部入边。
|
||||
func (db *DB) ListIncomingProjectFactEdges(projectID, targetFactKey string) ([]*ProjectFactEdge, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges
|
||||
WHERE project_id = ? AND target_fact_key = ?
|
||||
ORDER BY created_at ASC, rowid ASC`,
|
||||
projectID, targetFactKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjectFactEdges(rows)
|
||||
}
|
||||
|
||||
// ReplaceOutgoingProjectFactEdges 替换某事实的全部出边(links 省略时不调用)。
|
||||
func (db *DB) ReplaceOutgoingProjectFactEdges(projectID, sourceFactKey, sourceConversationID string, inputs []ProjectFactEdgeInput) error {
|
||||
sourceFactKey = strings.TrimSpace(sourceFactKey)
|
||||
if sourceFactKey == "" {
|
||||
return fmt.Errorf("source_fact_key 不能为空")
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`DELETE FROM project_fact_edges WHERE project_id = ? AND source_fact_key = ?`,
|
||||
projectID, sourceFactKey,
|
||||
); err != nil {
|
||||
return fmt.Errorf("清除旧边失败: %w", err)
|
||||
}
|
||||
for _, in := range inputs {
|
||||
target := strings.TrimSpace(in.To)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
if err := ValidateFactKey(target); err != nil {
|
||||
return fmt.Errorf("target fact_key 无效 (%s): %w", target, err)
|
||||
}
|
||||
if target == sourceFactKey {
|
||||
return fmt.Errorf("边不能指向自身: %s", sourceFactKey)
|
||||
}
|
||||
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
edge := &ProjectFactEdge{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
SourceFactKey: sourceFactKey,
|
||||
TargetFactKey: target,
|
||||
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||
SourceConversationID: sourceConversationID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := db.insertProjectFactEdge(edge); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceIncomingProjectFactEdges 替换某事实的全部入边(From 为来源 fact_key)。
|
||||
func (db *DB) ReplaceIncomingProjectFactEdges(projectID, targetFactKey string, inputs []ProjectFactEdgeFromInput) error {
|
||||
targetFactKey = strings.TrimSpace(targetFactKey)
|
||||
if targetFactKey == "" {
|
||||
return fmt.Errorf("target_fact_key 不能为空")
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`DELETE FROM project_fact_edges WHERE project_id = ? AND target_fact_key = ?`,
|
||||
projectID, targetFactKey,
|
||||
); err != nil {
|
||||
return fmt.Errorf("清除旧入边失败: %w", err)
|
||||
}
|
||||
for _, in := range inputs {
|
||||
source := strings.TrimSpace(in.From)
|
||||
if source == "" {
|
||||
continue
|
||||
}
|
||||
if err := ValidateFactKey(source); err != nil {
|
||||
return fmt.Errorf("source fact_key 无效 (%s): %w", source, err)
|
||||
}
|
||||
if source == targetFactKey {
|
||||
return fmt.Errorf("边不能指向自身: %s", targetFactKey)
|
||||
}
|
||||
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceConversationID := ""
|
||||
if srcFact, err := db.GetProjectFactByKey(projectID, source); err == nil && srcFact != nil {
|
||||
sourceConversationID = srcFact.SourceConversationID
|
||||
}
|
||||
edge := &ProjectFactEdge{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
SourceFactKey: source,
|
||||
TargetFactKey: targetFactKey,
|
||||
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||
SourceConversationID: sourceConversationID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := db.insertProjectFactEdge(edge); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProjectFactEdge 按 ID 获取边。
|
||||
func (db *DB) GetProjectFactEdge(edgeID string) (*ProjectFactEdge, error) {
|
||||
var e ProjectFactEdge
|
||||
var createdAt, updatedAt string
|
||||
err := db.QueryRow(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges WHERE id = ?`, edgeID,
|
||||
).Scan(&e.ID, &e.ProjectID, &e.SourceFactKey, &e.TargetFactKey, &e.EdgeType, &e.Confidence,
|
||||
&e.SourceConversationID, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("边不存在")
|
||||
}
|
||||
e.CreatedAt = parseDBTime(createdAt)
|
||||
e.UpdatedAt = parseDBTime(updatedAt)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// AddProjectFactEdge 新增单条边(已存在则更新 confidence)。
|
||||
func (db *DB) AddProjectFactEdge(projectID string, in ProjectFactEdgeInput, sourceFactKey, sourceConversationID string) (*ProjectFactEdge, error) {
|
||||
sourceFactKey = strings.TrimSpace(sourceFactKey)
|
||||
target := strings.TrimSpace(in.To)
|
||||
if sourceFactKey == "" || target == "" {
|
||||
return nil, fmt.Errorf("source 与 target 必填")
|
||||
}
|
||||
if sourceFactKey == target {
|
||||
return nil, fmt.Errorf("边不能指向自身")
|
||||
}
|
||||
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateFactKey(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
e := &ProjectFactEdge{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
SourceFactKey: sourceFactKey,
|
||||
TargetFactKey: target,
|
||||
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||
SourceConversationID: sourceConversationID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO project_fact_edges (
|
||||
id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
source_conversation_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, source_fact_key, target_fact_key, edge_type)
|
||||
DO UPDATE SET confidence = excluded.confidence, updated_at = excluded.updated_at`,
|
||||
e.ID, e.ProjectID, e.SourceFactKey, e.TargetFactKey, e.EdgeType, e.Confidence,
|
||||
nullIfEmpty(e.SourceConversationID), e.CreatedAt, e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("添加边失败: %w", err)
|
||||
}
|
||||
// 返回最新
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges
|
||||
WHERE project_id = ? AND source_fact_key = ? AND target_fact_key = ? AND edge_type = ?`,
|
||||
projectID, sourceFactKey, target, e.EdgeType,
|
||||
)
|
||||
if err != nil {
|
||||
return e, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
list, err := scanProjectFactEdges(rows)
|
||||
if err != nil || len(list) == 0 {
|
||||
return e, nil
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
|
||||
// DeleteProjectFactEdge 删除单条边。
|
||||
func (db *DB) DeleteProjectFactEdge(edgeID string) error {
|
||||
res, err := db.Exec(`DELETE FROM project_fact_edges WHERE id = ?`, edgeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("边不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) insertProjectFactEdge(e *ProjectFactEdge) error {
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO project_fact_edges (
|
||||
id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
source_conversation_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.ID, e.ProjectID, e.SourceFactKey, e.TargetFactKey, e.EdgeType, e.Confidence,
|
||||
nullIfEmpty(e.SourceConversationID), e.CreatedAt, e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入边失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameProjectFactKeyEdges 事实 key 变更时同步边上的引用。
|
||||
func (db *DB) RenameProjectFactKeyEdges(projectID, oldKey, newKey string) error {
|
||||
oldKey = strings.TrimSpace(oldKey)
|
||||
newKey = strings.TrimSpace(newKey)
|
||||
if oldKey == "" || newKey == "" || oldKey == newKey {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := db.Exec(
|
||||
`UPDATE project_fact_edges SET source_fact_key = ?, updated_at = ?
|
||||
WHERE project_id = ? AND source_fact_key = ?`,
|
||||
newKey, now, projectID, oldKey,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`UPDATE project_fact_edges SET target_fact_key = ?, updated_at = ?
|
||||
WHERE project_id = ? AND target_fact_key = ?`,
|
||||
newKey, now, projectID, oldKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProjectFactEdgesForKey 删除与某 fact_key 相关的全部边。
|
||||
func (db *DB) DeleteProjectFactEdgesForKey(projectID, factKey string) error {
|
||||
_, err := db.Exec(
|
||||
`DELETE FROM project_fact_edges
|
||||
WHERE project_id = ? AND (source_fact_key = ? OR target_fact_key = ?)`,
|
||||
projectID, factKey, factKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeprecateProjectFactEdgesForKey 将关联边标记为 deprecated。
|
||||
func (db *DB) DeprecateProjectFactEdgesForKey(projectID, factKey string) error {
|
||||
now := time.Now()
|
||||
_, err := db.Exec(
|
||||
`UPDATE project_fact_edges SET confidence = 'deprecated', updated_at = ?
|
||||
WHERE project_id = ? AND (source_fact_key = ? OR target_fact_key = ?)
|
||||
AND confidence != 'deprecated'`,
|
||||
now, projectID, factKey, factKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanProjectFactEdges(rows *sql.Rows) ([]*ProjectFactEdge, error) {
|
||||
var out []*ProjectFactEdge
|
||||
for rows.Next() {
|
||||
var e ProjectFactEdge
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.ProjectID, &e.SourceFactKey, &e.TargetFactKey, &e.EdgeType, &e.Confidence,
|
||||
&e.SourceConversationID, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt = parseDBTime(createdAt)
|
||||
e.UpdatedAt = parseDBTime(updatedAt)
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// formatSQLiteUTC stores instants as UTC RFC3339 for consistent SQLite reads/writes.
|
||||
func formatSQLiteUTC(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// sqliteEpochGE returns SQL comparing column to param as Unix seconds (timezone-safe).
|
||||
func sqliteEpochGE(column, op string) string {
|
||||
return "strftime('%s', " + column + ") " + op + " strftime('%s', ?)"
|
||||
}
|
||||
|
||||
// ParseRFC3339Time parses API/query timestamps (RFC3339 or RFC3339Nano).
|
||||
func ParseRFC3339Time(value string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errors.New("empty time value")
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
)
|
||||
|
||||
// ExecutionRecorder 可选,在 MCP 工具成功返回且带有 execution id 时回调(用于汇总 mcpExecutionIds)。
|
||||
type ExecutionRecorder func(executionID string)
|
||||
// toolCallID 来自 Eino compose.GetToolCallID,用于与 reduction 后的展示结果关联。
|
||||
type ExecutionRecorder func(executionID, toolCallID string)
|
||||
|
||||
// ToolErrorPrefix 用于把内部 MCP 执行结果中的 IsError 标记传递到多代理上层。
|
||||
// Eino 工具通道目前只支持返回字符串,因此通过前缀标识,随后在多代理 runner 中解析为 success/isError。
|
||||
@@ -178,7 +179,7 @@ func runMCPToolInvocation(
|
||||
return "", nil
|
||||
}
|
||||
if res.ExecutionID != "" && record != nil {
|
||||
record(res.ExecutionID)
|
||||
record(res.ExecutionID, compose.GetToolCallID(ctx))
|
||||
}
|
||||
if res.IsError {
|
||||
return ToolErrorPrefix + res.Result, nil
|
||||
|
||||
@@ -2,8 +2,8 @@ package einomcp
|
||||
|
||||
import "sync"
|
||||
|
||||
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP 桥在每次 InvokableRun 结束时 Fire,
|
||||
// 用于在 ADK 未透出 schema.Tool 事件时仍推送 tool_result、清 pending,避免 UI 卡在「执行中」或迭代末 force-close。
|
||||
// ToolInvokeNotifyHolder 由 Eino run loop 在迭代开始前 Set 回调;MCP/execute 桥在工具调用结束时 Fire,
|
||||
// 用于清除 pending tool_call(tool_result 由 ADK schema.Tool 事件推送,含流式工具与 reduction 后正文)。
|
||||
type ToolInvokeNotifyHolder struct {
|
||||
mu sync.RWMutex
|
||||
fn func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error)
|
||||
|
||||
@@ -640,8 +640,8 @@ func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||
var emptyResponseAttempts int
|
||||
for {
|
||||
resultMA, errMA = multiagent.RunEinoSingleChatModelAgent(
|
||||
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
||||
conversationID, curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
|
||||
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger,
|
||||
conversationID, h.conversationProjectID(conversationID), curMsg, curHist, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID),
|
||||
)
|
||||
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||
taskCtx, conversationID, resultMA, errMA, &emptyResponseAttempts,
|
||||
@@ -689,8 +689,8 @@ func (h *AgentHandler) runRobotMultiAgentWithRetry(
|
||||
var emptyResponseAttempts int
|
||||
for {
|
||||
resultMA, errMA = multiagent.RunDeepAgent(
|
||||
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
|
||||
conversationID, curMsg, curHist, roleTools, progressCallback,
|
||||
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger,
|
||||
conversationID, h.conversationProjectID(conversationID), curMsg, curHist, roleTools, progressCallback,
|
||||
h.agentsMarkdownDir, orchestration, nil, h.projectBlackboardBlock(conversationID),
|
||||
)
|
||||
handledEmpty, exhaustedEmpty := h.handleEinoEmptyResponseContinue(
|
||||
@@ -1185,6 +1185,8 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
}
|
||||
flushResponsePlan()
|
||||
// 助手正文开始前,推理流通常已结束;落库以便刷新后「渗透测试详情」可回放
|
||||
flushThinkingStreams()
|
||||
respPlan.meta = nil
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
respPlan.meta = make(map[string]interface{}, len(dataMap))
|
||||
@@ -1220,6 +1222,19 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
if eventType == "response" {
|
||||
flushResponsePlan()
|
||||
flushThinkingStreams()
|
||||
return
|
||||
}
|
||||
if eventType == "done" {
|
||||
flushResponsePlan()
|
||||
flushThinkingStreams()
|
||||
return
|
||||
}
|
||||
|
||||
// 流式思考/推理结束:聚合落库(与 eino_agent_reply_stream_end 同理)
|
||||
if eventType == "thinking_stream_end" || eventType == "reasoning_chain_stream_end" {
|
||||
flushResponsePlan()
|
||||
flushThinkingStreams()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1663,6 +1678,7 @@ func (h *AgentHandler) ListBatchQueues(c *gin.Context) {
|
||||
// StartBatchQueue 开始执行批量任务队列
|
||||
func (h *AgentHandler) StartBatchQueue(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
h.batchTaskManager.ClearSingleRunTask(queueID)
|
||||
ok, err := h.startBatchQueueExecution(queueID, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -1694,6 +1710,7 @@ func (h *AgentHandler) RerunBatchQueue(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "重置队列失败"})
|
||||
return
|
||||
}
|
||||
h.batchTaskManager.ClearSingleRunTask(queueID)
|
||||
ok, err := h.startBatchQueueExecution(queueID, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -1893,6 +1910,53 @@ func (h *AgentHandler) AddBatchTask(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "任务已添加", "task": task, "queue": queue})
|
||||
}
|
||||
|
||||
// RunSingleBatchTask 单条执行指定子任务(可覆盖已成功项),完成后暂停队列
|
||||
func (h *AgentHandler) RunSingleBatchTask(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
taskID := c.Param("taskId")
|
||||
|
||||
if err := h.batchTaskManager.PrepareSingleTaskRun(queueID, taskID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.batchTaskManager.SetSingleRunTask(queueID, taskID)
|
||||
|
||||
// 暂停态单条执行:旧批量协程可能仍占用执行槽,先回收以便重新启动
|
||||
if queue, ok := h.batchTaskManager.GetBatchQueue(queueID); ok && queue.Status == BatchQueueStatusPaused {
|
||||
h.forceUnmarkBatchQueueRunning(queueID)
|
||||
}
|
||||
|
||||
autoStarted := true
|
||||
autoStartMsg := "已开始单条执行"
|
||||
ok, startErr := h.startBatchQueueExecution(queueID, false)
|
||||
if startErr != nil {
|
||||
h.batchTaskManager.ClearSingleRunTask(queueID)
|
||||
autoStarted = false
|
||||
autoStartMsg = "任务已准备就绪,但自动启动失败: " + startErr.Error()
|
||||
} else if !ok {
|
||||
h.batchTaskManager.ClearSingleRunTask(queueID)
|
||||
autoStarted = false
|
||||
autoStartMsg = "任务已准备就绪,但队列不存在"
|
||||
}
|
||||
|
||||
queue, exists := h.batchTaskManager.GetBatchQueue(queueID)
|
||||
if !exists {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "队列不存在"})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "task", "run_single_batch_task", "单条执行批量子任务", "batch_task", taskID, map[string]interface{}{
|
||||
"batch_queue_id": queueID,
|
||||
"auto_started": autoStarted,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": autoStartMsg,
|
||||
"queue": queue,
|
||||
"autoStarted": autoStarted,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteBatchTask 删除批量任务
|
||||
func (h *AgentHandler) DeleteBatchTask(c *gin.Context) {
|
||||
queueID := c.Param("queueId")
|
||||
@@ -1934,6 +1998,10 @@ func (h *AgentHandler) unmarkBatchQueueRunning(queueID string) {
|
||||
delete(h.batchRunning, queueID)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) forceUnmarkBatchQueueRunning(queueID string) {
|
||||
h.unmarkBatchQueueRunning(queueID)
|
||||
}
|
||||
|
||||
func (h *AgentHandler) nextBatchQueueRunAt(cronExpr string, from time.Time) (*time.Time, error) {
|
||||
expr := strings.TrimSpace(cronExpr)
|
||||
if expr == "" {
|
||||
@@ -2081,6 +2149,10 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||
h.batchTaskManager.UpdateTaskStatus(queueID, task.ID, "failed", "", "创建对话失败: "+err.Error())
|
||||
h.batchTaskManager.MoveToNextTask(queueID)
|
||||
if h.batchTaskManager.TakeSingleRunTaskIfMatch(queueID, task.ID) {
|
||||
h.batchTaskManager.UpdateQueueStatus(queueID, "paused")
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
conversationID = conv.ID
|
||||
@@ -2218,12 +2290,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
var runErr error
|
||||
switch {
|
||||
case useBatchMulti:
|
||||
resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.projectBlackboardBlock(conversationID))
|
||||
resultMA, runErr = multiagent.RunDeepAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, h.agentsMarkdownDir, batchOrch, nil, h.projectBlackboardBlock(conversationID))
|
||||
default:
|
||||
if h.config == nil {
|
||||
runErr = fmt.Errorf("服务器配置未加载")
|
||||
} else {
|
||||
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
|
||||
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.db, h.logger, conversationID, h.conversationProjectID(conversationID), finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2337,6 +2409,12 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
|
||||
// 移动到下一个任务
|
||||
h.batchTaskManager.MoveToNextTask(queueID)
|
||||
|
||||
if h.batchTaskManager.TakeSingleRunTaskIfMatch(queueID, task.ID) {
|
||||
h.batchTaskManager.UpdateQueueStatus(queueID, "paused")
|
||||
h.logger.Info("单条执行完成,队列已暂停", zap.String("queueId", queueID), zap.String("taskId", task.ID))
|
||||
break
|
||||
}
|
||||
|
||||
// 检查是否被取消或暂停
|
||||
queue, _ = h.batchTaskManager.GetBatchQueue(queueID)
|
||||
if queue.Status == "cancelled" || queue.Status == "paused" {
|
||||
|
||||
@@ -3,10 +3,14 @@ package handler
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -46,3 +50,50 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
|
||||
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmp)
|
||||
|
||||
conv, err := db.CreateConversation("test", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
|
||||
h := &AgentHandler{logger: zap.NewNop(), db: db}
|
||||
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
|
||||
|
||||
streamID := "eino-reasoning-test-1"
|
||||
cb("reasoning_chain_stream_start", " ", map[string]interface{}{
|
||||
"streamId": streamID,
|
||||
"source": "eino",
|
||||
})
|
||||
cb("reasoning_chain_stream_delta", "step one", openai.WithSSEAccumulated(map[string]interface{}{
|
||||
"streamId": streamID,
|
||||
}, "step one"))
|
||||
cb("done", "", map[string]interface{}{"conversationId": conv.ID})
|
||||
|
||||
details, err := db.GetProcessDetails(asst.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetails: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range details {
|
||||
if d.EventType == "reasoning_chain" && d.Message == "step one" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected reasoning_chain persisted on done, got %+v", details)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
@@ -20,12 +19,12 @@ func auditFilterFromQuery(c *gin.Context) database.ListAuditLogsFilter {
|
||||
ResourceID: c.Query("resource_id"),
|
||||
}
|
||||
if since := c.Query("since"); since != "" {
|
||||
if t, err := time.Parse(time.RFC3339, since); err == nil {
|
||||
if t, err := database.ParseRFC3339Time(since); err == nil {
|
||||
filter.Since = &t
|
||||
}
|
||||
}
|
||||
if until := c.Query("until"); until != "" {
|
||||
if t, err := time.Parse(time.RFC3339, until); err == nil {
|
||||
if t, err := database.ParseRFC3339Time(until); err == nil {
|
||||
filter.Until = &t
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +77,12 @@ type BatchTaskQueue struct {
|
||||
|
||||
// BatchTaskManager 批量任务管理器
|
||||
type BatchTaskManager struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
queues map[string]*BatchTaskQueue
|
||||
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
||||
mu sync.RWMutex
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
queues map[string]*BatchTaskQueue
|
||||
taskCancels map[string]context.CancelFunc // 存储每个队列当前任务的取消函数
|
||||
singleRunTasks map[string]string // queueID -> taskID,单条执行完成后暂停队列
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewBatchTaskManager 创建批量任务管理器
|
||||
@@ -90,9 +91,10 @@ func NewBatchTaskManager(logger *zap.Logger) *BatchTaskManager {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &BatchTaskManager{
|
||||
logger: logger,
|
||||
queues: make(map[string]*BatchTaskQueue),
|
||||
taskCancels: make(map[string]context.CancelFunc),
|
||||
logger: logger,
|
||||
queues: make(map[string]*BatchTaskQueue),
|
||||
taskCancels: make(map[string]context.CancelFunc),
|
||||
singleRunTasks: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,6 +866,138 @@ func (m *BatchTaskManager) AddTaskToQueue(queueID, message string) (*BatchTask,
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// PrepareSingleTaskRun 准备单条执行:重置目标任务(若已有结果)并定位队列索引
|
||||
func (m *BatchTaskManager) PrepareSingleTaskRun(queueID, taskID string) error {
|
||||
var cancelFunc context.CancelFunc
|
||||
var siblingRunningIDs []string
|
||||
|
||||
m.mu.Lock()
|
||||
queue, exists := m.queues[queueID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("队列不存在")
|
||||
}
|
||||
|
||||
var task *BatchTask
|
||||
taskIndex := -1
|
||||
for i, t := range queue.Tasks {
|
||||
if t.ID == taskID {
|
||||
taskIndex = i
|
||||
task = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if task == nil {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("任务不存在")
|
||||
}
|
||||
|
||||
if !queueAllowsSingleTaskRunLocked(queue, task) {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("队列正在执行或未就绪,无法单条执行")
|
||||
}
|
||||
|
||||
// 暂停态:中止在途子任务并收口仍标记 running 的其它子任务,以便单条执行非冲突项
|
||||
if queue.Status == BatchQueueStatusPaused {
|
||||
if c, ok := m.taskCancels[queueID]; ok {
|
||||
cancelFunc = c
|
||||
delete(m.taskCancels, queueID)
|
||||
}
|
||||
for _, t := range queue.Tasks {
|
||||
if t != nil && t.ID != taskID && t.Status == BatchTaskStatusRunning {
|
||||
siblingRunningIDs = append(siblingRunningIDs, t.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
needsReset := task.Status != BatchTaskStatusPending
|
||||
resumeQueue := queue.Status == BatchQueueStatusCompleted || queue.Status == BatchQueueStatusCancelled
|
||||
m.mu.Unlock()
|
||||
|
||||
if cancelFunc != nil {
|
||||
cancelFunc()
|
||||
}
|
||||
const staleRunMsg = "为单条执行其它任务,已中止"
|
||||
for _, sid := range siblingRunningIDs {
|
||||
m.UpdateTaskStatus(queueID, sid, BatchTaskStatusCancelled, "", staleRunMsg)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
queue, exists = m.queues[queueID]
|
||||
if !exists {
|
||||
return fmt.Errorf("队列不存在")
|
||||
}
|
||||
|
||||
task = nil
|
||||
taskIndex = -1
|
||||
for i, t := range queue.Tasks {
|
||||
if t.ID == taskID {
|
||||
taskIndex = i
|
||||
task = t
|
||||
break
|
||||
}
|
||||
}
|
||||
if task == nil {
|
||||
return fmt.Errorf("任务不存在")
|
||||
}
|
||||
|
||||
if m.db != nil {
|
||||
if err := m.db.PrepareBatchSingleTaskRun(queueID, taskID, taskIndex, needsReset, resumeQueue); err != nil {
|
||||
return fmt.Errorf("准备单条执行失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if needsReset {
|
||||
task.Status = BatchTaskStatusPending
|
||||
task.ConversationID = ""
|
||||
task.StartedAt = nil
|
||||
task.CompletedAt = nil
|
||||
task.Error = ""
|
||||
task.Result = ""
|
||||
}
|
||||
queue.CurrentIndex = taskIndex
|
||||
queue.LastRunError = ""
|
||||
if resumeQueue {
|
||||
queue.Status = BatchQueueStatusPaused
|
||||
queue.CompletedAt = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSingleRunTask 标记队列仅执行指定子任务,完成后自动暂停
|
||||
func (m *BatchTaskManager) SetSingleRunTask(queueID, taskID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.singleRunTasks == nil {
|
||||
m.singleRunTasks = make(map[string]string)
|
||||
}
|
||||
m.singleRunTasks[queueID] = taskID
|
||||
}
|
||||
|
||||
// ClearSingleRunTask 清除单条执行标记
|
||||
func (m *BatchTaskManager) ClearSingleRunTask(queueID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.singleRunTasks, queueID)
|
||||
}
|
||||
|
||||
// TakeSingleRunTaskIfMatch 若刚完成的子任务为单条执行目标,则清除标记并返回 true
|
||||
func (m *BatchTaskManager) TakeSingleRunTaskIfMatch(queueID, taskID string) bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.singleRunTasks == nil {
|
||||
return false
|
||||
}
|
||||
if m.singleRunTasks[queueID] != taskID {
|
||||
return false
|
||||
}
|
||||
delete(m.singleRunTasks, queueID)
|
||||
return true
|
||||
}
|
||||
|
||||
// DeleteTask 删除任务(队列空闲时可删;执行中任务不可删)
|
||||
func (m *BatchTaskManager) DeleteTask(queueID, taskID string) error {
|
||||
m.mu.Lock()
|
||||
@@ -936,6 +1070,25 @@ func queueAllowsTaskListMutationLocked(queue *BatchTaskQueue) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// queueAllowsSingleTaskRunLocked 是否允许对指定子任务发起单条执行(必须在持有 BatchTaskManager.mu 下调用)
|
||||
func queueAllowsSingleTaskRunLocked(queue *BatchTaskQueue, task *BatchTask) bool {
|
||||
if queue == nil || task == nil {
|
||||
return false
|
||||
}
|
||||
if task.Status == BatchTaskStatusRunning {
|
||||
return false
|
||||
}
|
||||
if queue.Status == BatchQueueStatusRunning {
|
||||
return false
|
||||
}
|
||||
switch queue.Status {
|
||||
case BatchQueueStatusPending, BatchQueueStatusPaused, BatchQueueStatusCompleted, BatchQueueStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// GetNextTask 获取下一个待执行的任务
|
||||
func (m *BatchTaskManager) GetNextTask(queueID string) (*BatchTask, bool) {
|
||||
m.mu.Lock()
|
||||
|
||||
+58
-3
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -277,6 +278,9 @@ func (h *C2Handler) ListSessions(c *gin.Context) {
|
||||
filter.Limit = n
|
||||
}
|
||||
}
|
||||
if c.Query("suspicious") == "1" || strings.EqualFold(c.Query("suspicious"), "true") {
|
||||
filter.Suspicious = true
|
||||
}
|
||||
|
||||
sessions, err := h.mgr().DB().ListC2Sessions(filter)
|
||||
if err != nil {
|
||||
@@ -324,7 +328,37 @@ func (h *C2Handler) DeleteSession(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// SetSessionSleep 设置会话的 sleep/jitter
|
||||
// DeleteSessions 批量删除会话(请求体 JSON: {"ids":["s_xxx",...]})
|
||||
func (h *C2Handler) DeleteSessions(c *gin.Context) {
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ids is required"})
|
||||
return
|
||||
}
|
||||
n, err := h.mgr().DB().DeleteC2SessionsByIDs(req.IDs)
|
||||
if err != nil {
|
||||
if errors.Is(err, database.ErrNoValidC2SessionIDs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "c2", "session_delete", "批量删除 C2 会话", "c2_session", "", map[string]interface{}{
|
||||
"count": n, "ids": req.IDs,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": n})
|
||||
}
|
||||
|
||||
// SetSessionSleep 设置会话的 sleep/jitter,并下发 sleep 任务到植入体
|
||||
func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req struct {
|
||||
@@ -335,12 +369,33 @@ func (h *C2Handler) SetSessionSleep(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.SleepSeconds < 1 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "sleep_seconds must be >= 1"})
|
||||
return
|
||||
}
|
||||
if req.JitterPercent < 0 || req.JitterPercent > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "jitter_percent must be 0-100"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.mgr().DB().SetC2SessionSleep(id, req.SleepSeconds, req.JitterPercent); err != nil {
|
||||
task, err := h.mgr().SetSessionSleep(id, req.SleepSeconds, req.JitterPercent)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"updated": true})
|
||||
out := gin.H{
|
||||
"updated": true,
|
||||
"sleep_seconds": req.SleepSeconds,
|
||||
"jitter_percent": req.JitterPercent,
|
||||
}
|
||||
if task != nil {
|
||||
out["task_id"] = task.ID
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+77
-13
@@ -688,11 +688,9 @@ type UpdateConfigRequest struct {
|
||||
// AgentConfigUpdate 用于 PATCH /api/config 的 agent 段:仅 JSON 中出现的字段(指针非 nil)覆盖内存配置。
|
||||
// 避免旧版「整包替换 *AgentConfig」时,未传的整型字段被反序列化为 0 误覆盖(例如 tool_timeout_minutes 变成 0)。
|
||||
type AgentConfigUpdate struct {
|
||||
MaxIterations *int `json:"max_iterations,omitempty"`
|
||||
LargeResultThreshold *int `json:"large_result_threshold,omitempty"`
|
||||
ResultStorageDir *string `json:"result_storage_dir,omitempty"`
|
||||
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
|
||||
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
|
||||
MaxIterations *int `json:"max_iterations,omitempty"`
|
||||
ToolTimeoutMinutes *int `json:"tool_timeout_minutes,omitempty"`
|
||||
SystemPromptPath *string `json:"system_prompt_path,omitempty"`
|
||||
}
|
||||
|
||||
func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
|
||||
@@ -702,12 +700,6 @@ func applyAgentConfigUpdate(dst *config.AgentConfig, src *AgentConfigUpdate) {
|
||||
if src.MaxIterations != nil {
|
||||
dst.MaxIterations = *src.MaxIterations
|
||||
}
|
||||
if src.LargeResultThreshold != nil {
|
||||
dst.LargeResultThreshold = *src.LargeResultThreshold
|
||||
}
|
||||
if src.ResultStorageDir != nil {
|
||||
dst.ResultStorageDir = *src.ResultStorageDir
|
||||
}
|
||||
if src.ToolTimeoutMinutes != nil {
|
||||
dst.ToolTimeoutMinutes = *src.ToolTimeoutMinutes
|
||||
}
|
||||
@@ -1076,6 +1068,80 @@ func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ListModelsRequest 获取模型列表请求(OpenAI 兼容 GET /models)。
|
||||
type ListModelsRequest struct {
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
}
|
||||
|
||||
// ListModels 代理调用上游 GET /models,返回可用模型 id 列表。
|
||||
func (h *ConfigHandler) ListModels(c *gin.Context) {
|
||||
var req ListModelsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
provider := strings.TrimSpace(req.Provider)
|
||||
if provider == "" {
|
||||
provider = "openai"
|
||||
}
|
||||
if strings.EqualFold(provider, "claude") {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"supported": false,
|
||||
"error": "Claude (Anthropic Messages API) 不支持自动获取模型列表,请手动填写",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.APIKey) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
tmpCfg := &config.OpenAIConfig{
|
||||
Provider: provider,
|
||||
BaseURL: baseURL,
|
||||
APIKey: strings.TrimSpace(req.APIKey),
|
||||
}
|
||||
client := openai.NewClient(tmpCfg, nil, h.logger)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
models, err := client.ListModels(ctx)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*openai.APIError); ok {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"supported": true,
|
||||
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", apiErr.StatusCode, apiErr.Body),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"supported": true,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"supported": true,
|
||||
"models": models,
|
||||
"count": len(models),
|
||||
})
|
||||
}
|
||||
|
||||
// TestVisionRequest 测试 Vision 模型连接;vision.api_key/base_url 留空时可传 openai 段作回退。
|
||||
type TestVisionRequest struct {
|
||||
Vision config.VisionConfig `json:"vision"`
|
||||
@@ -1532,8 +1598,6 @@ func updateAgentConfig(doc *yaml.Node, agent config.AgentConfig) {
|
||||
agentNode := ensureMap(root, "agent")
|
||||
setIntInMap(agentNode, "max_iterations", agent.MaxIterations)
|
||||
setIntInMap(agentNode, "tool_timeout_minutes", agent.ToolTimeoutMinutes)
|
||||
setIntInMap(agentNode, "large_result_threshold", agent.LargeResultThreshold)
|
||||
setStringInMap(agentNode, "result_storage_dir", agent.ResultStorageDir)
|
||||
setStringInMap(agentNode, "system_prompt_path", agent.SystemPromptPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -105,17 +105,18 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
||||
|
||||
excludeGrouped := strings.TrimSpace(search) == "" &&
|
||||
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
|
||||
sortBy := strings.TrimSpace(c.Query("sort_by"))
|
||||
|
||||
var conversations []*database.Conversation
|
||||
var total int
|
||||
var err error
|
||||
if excludeGrouped {
|
||||
conversations, err = h.db.ListUngroupedConversations(limit, offset)
|
||||
conversations, err = h.db.ListUngroupedConversations(limit, offset, sortBy)
|
||||
if err == nil {
|
||||
total, err = h.db.CountUngroupedConversations()
|
||||
}
|
||||
} else {
|
||||
conversations, err = h.db.ListConversations(limit, offset, search)
|
||||
conversations, err = h.db.ListConversations(limit, offset, search, sortBy)
|
||||
if err == nil {
|
||||
total, err = h.db.CountConversations(search)
|
||||
}
|
||||
|
||||
@@ -224,8 +224,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
conversationID,
|
||||
h.conversationProjectID(conversationID),
|
||||
curFinalMessage,
|
||||
curHistory,
|
||||
roleTools,
|
||||
@@ -454,8 +456,10 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
prep.ConversationID,
|
||||
h.conversationProjectID(prep.ConversationID),
|
||||
curMsg,
|
||||
curHist,
|
||||
prep.RoleTools,
|
||||
|
||||
@@ -234,8 +234,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
conversationID,
|
||||
h.conversationProjectID(conversationID),
|
||||
curFinalMessage,
|
||||
curHistory,
|
||||
roleTools,
|
||||
@@ -466,8 +468,10 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
h.config,
|
||||
&h.config.MultiAgent,
|
||||
h.agent,
|
||||
h.db,
|
||||
h.logger,
|
||||
prep.ConversationID,
|
||||
h.conversationProjectID(prep.ConversationID),
|
||||
curMsg,
|
||||
curHist,
|
||||
prep.RoleTools,
|
||||
|
||||
+139
-34
@@ -2,10 +2,8 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
@@ -15,17 +13,15 @@ import (
|
||||
type OpenAPIHandler struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
resultStorage storage.ResultStorage
|
||||
conversationHdlr *ConversationHandler
|
||||
agentHdlr *AgentHandler
|
||||
}
|
||||
|
||||
// NewOpenAPIHandler 创建新的OpenAPI处理器
|
||||
func NewOpenAPIHandler(db *database.DB, logger *zap.Logger, resultStorage storage.ResultStorage, conversationHdlr *ConversationHandler, agentHdlr *AgentHandler) *OpenAPIHandler {
|
||||
func NewOpenAPIHandler(db *database.DB, logger *zap.Logger, conversationHdlr *ConversationHandler, agentHdlr *AgentHandler) *OpenAPIHandler {
|
||||
return &OpenAPIHandler{
|
||||
db: db,
|
||||
logger: logger,
|
||||
resultStorage: resultStorage,
|
||||
conversationHdlr: conversationHdlr,
|
||||
agentHdlr: agentHdlr,
|
||||
}
|
||||
@@ -2468,17 +2464,108 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "fact_key", "in": "query", "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "include_links", "in": "query", "schema": map[string]interface{}{"type": "boolean"}},
|
||||
{"name": "include_link_counts", "in": "query", "schema": map[string]interface{}{"type": "boolean"}},
|
||||
},
|
||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "事实列表或单条"}},
|
||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "事实列表或单条(可含 link_counts / outgoing_links)"}},
|
||||
},
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"项目管理"}, "summary": "创建/更新事实", "operationId": "upsertProjectFactREST",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"fact_key": map[string]interface{}{"type": "string"},
|
||||
"summary": map[string]interface{}{"type": "string"},
|
||||
"links": map[string]interface{}{
|
||||
"type": "array",
|
||||
"items": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"to": map[string]interface{}{"type": "string"},
|
||||
"type": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"links_text": map[string]interface{}{"type": "string", "description": "type: fact_key 每行一条"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "成功"}},
|
||||
},
|
||||
},
|
||||
"/api/projects/{id}/fact-graph": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"项目管理"}, "summary": "获取项目事实攻击路径图", "operationId": "getProjectFactGraph",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "view", "in": "query", "schema": map[string]interface{}{"type": "string", "enum": []string{"path", "full"}, "default": "path"}},
|
||||
{"name": "exclude_deprecated", "in": "query", "schema": map[string]interface{}{"type": "boolean", "default": true}},
|
||||
},
|
||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "nodes + edges"}},
|
||||
},
|
||||
},
|
||||
"/api/projects/{id}/fact-edges": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"项目管理"}, "summary": "列出项目全部事实边", "operationId": "listProjectFactEdges",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "边列表"}},
|
||||
},
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"项目管理"}, "summary": "添加事实边", "operationId": "createProjectFactEdge",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"required": []string{"source_fact_key", "target_fact_key", "edge_type"},
|
||||
"properties": map[string]interface{}{
|
||||
"source_fact_key": map[string]interface{}{"type": "string"},
|
||||
"target_fact_key": map[string]interface{}{"type": "string"},
|
||||
"edge_type": map[string]interface{}{"type": "string"},
|
||||
"confidence": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "边已创建"}},
|
||||
},
|
||||
},
|
||||
"/api/projects/{id}/fact-edges/{edgeId}": map[string]interface{}{
|
||||
"delete": map[string]interface{}{
|
||||
"tags": []string{"项目管理"}, "summary": "删除事实边", "operationId": "deleteProjectFactEdge",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "edgeId", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "删除成功"}},
|
||||
},
|
||||
},
|
||||
"/api/projects/{id}/promote-attack-chain/{conversationId}": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"项目管理"}, "summary": "将对话攻击链沉淀到项目事实图", "operationId": "promoteAttackChainToProject",
|
||||
"parameters": []map[string]interface{}{
|
||||
{"name": "id", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
{"name": "conversationId", "in": "path", "required": true, "schema": map[string]interface{}{"type": "string"}},
|
||||
},
|
||||
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "沉淀结果(facts/edges/graph)"}},
|
||||
},
|
||||
},
|
||||
"/api/vulnerabilities": map[string]interface{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"漏洞管理"},
|
||||
@@ -5034,6 +5121,51 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/config/list-models": map[string]interface{}{
|
||||
"post": map[string]interface{}{
|
||||
"tags": []string{"配置管理"},
|
||||
"summary": "获取模型列表",
|
||||
"description": "代理调用 OpenAI 兼容 GET /models,返回可用模型 id 列表。Claude 不支持。",
|
||||
"operationId": "listModels",
|
||||
"requestBody": map[string]interface{}{
|
||||
"required": true,
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"required": []string{"api_key"},
|
||||
"properties": map[string]interface{}{
|
||||
"provider": map[string]interface{}{"type": "string", "description": "LLM提供商(openai/claude)", "example": "openai"},
|
||||
"base_url": map[string]interface{}{"type": "string", "description": "API基地址(可选)"},
|
||||
"api_key": map[string]interface{}{"type": "string", "description": "API密钥"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"responses": map[string]interface{}{
|
||||
"200": map[string]interface{}{
|
||||
"description": "获取结果",
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"success": map[string]interface{}{"type": "boolean"},
|
||||
"supported": map[string]interface{}{"type": "boolean"},
|
||||
"error": map[string]interface{}{"type": "string"},
|
||||
"models": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
|
||||
"count": map[string]interface{}{"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": map[string]interface{}{"description": "参数错误"},
|
||||
"401": map[string]interface{}{"description": "未授权"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==================== 终端 ====================
|
||||
"/api/terminal/run": map[string]interface{}{
|
||||
@@ -6354,35 +6486,8 @@ func (h *OpenAPIHandler) GetConversationResults(c *gin.Context) {
|
||||
vulnerabilities[i] = *v
|
||||
}
|
||||
|
||||
// 获取执行结果(从MCP执行记录中获取)
|
||||
// 获取执行结果(历史大结果由 Eino reduction 落盘,此处不再聚合文件存储)
|
||||
executionResults := []map[string]interface{}{}
|
||||
for _, msg := range messages {
|
||||
if len(msg.MCPExecutionIDs) > 0 {
|
||||
for _, execID := range msg.MCPExecutionIDs {
|
||||
// 尝试从结果存储中获取执行结果
|
||||
if h.resultStorage != nil {
|
||||
result, err := h.resultStorage.GetResult(execID)
|
||||
if err == nil && result != "" {
|
||||
// 获取元数据以获取工具名称和创建时间
|
||||
metadata, err := h.resultStorage.GetResultMetadata(execID)
|
||||
toolName := "unknown"
|
||||
createdAt := time.Now()
|
||||
if err == nil && metadata != nil {
|
||||
toolName = metadata.ToolName
|
||||
createdAt = metadata.CreatedAt
|
||||
}
|
||||
executionResults = append(executionResults, map[string]interface{}{
|
||||
"id": execID,
|
||||
"toolName": toolName,
|
||||
"status": "success",
|
||||
"result": result,
|
||||
"createdAt": createdAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"conversationId": conv.ID,
|
||||
|
||||
+255
-21
@@ -1,10 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/attackchain"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/project"
|
||||
|
||||
@@ -223,26 +225,102 @@ func (h *ProjectHandler) DeleteProject(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
type factLinkRequest struct {
|
||||
From string `json:"from"`
|
||||
Type string `json:"type"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
type upsertFactRequest struct {
|
||||
FactKey string `json:"fact_key" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary" binding:"required"`
|
||||
Body string `json:"body"`
|
||||
Confidence string `json:"confidence"`
|
||||
Pinned bool `json:"pinned"`
|
||||
RelatedVulnerabilityID string `json:"related_vulnerability_id"`
|
||||
FactKey string `json:"fact_key" binding:"required"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary" binding:"required"`
|
||||
Body string `json:"body"`
|
||||
Confidence string `json:"confidence"`
|
||||
Pinned bool `json:"pinned"`
|
||||
RelatedVulnerabilityID string `json:"related_vulnerability_id"`
|
||||
Links []factLinkRequest `json:"links"`
|
||||
LinksText *string `json:"links_text"`
|
||||
}
|
||||
|
||||
// updateFactRequest 部分更新事实;指针字段省略=不修改,body 传 "" 可清空(仍走 merge 逻辑见 Upsert)。
|
||||
type updateFactRequest struct {
|
||||
FactKey *string `json:"fact_key"`
|
||||
Category *string `json:"category"`
|
||||
Summary *string `json:"summary"`
|
||||
Body *string `json:"body"`
|
||||
Confidence *string `json:"confidence"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
RelatedVulnerabilityID *string `json:"related_vulnerability_id"`
|
||||
ClearBody bool `json:"clear_body"`
|
||||
FactKey *string `json:"fact_key"`
|
||||
Category *string `json:"category"`
|
||||
Summary *string `json:"summary"`
|
||||
Body *string `json:"body"`
|
||||
Confidence *string `json:"confidence"`
|
||||
Pinned *bool `json:"pinned"`
|
||||
RelatedVulnerabilityID *string `json:"related_vulnerability_id"`
|
||||
ClearBody bool `json:"clear_body"`
|
||||
Links *[]factLinkRequest `json:"links"`
|
||||
LinksText *string `json:"links_text"`
|
||||
}
|
||||
|
||||
func factLinksFromRequest(links []factLinkRequest, linksText *string) (*project.ParsedFactLinks, error) {
|
||||
if len(links) > 0 {
|
||||
parsed := &project.ParsedFactLinks{}
|
||||
for i, l := range links {
|
||||
from := strings.TrimSpace(l.From)
|
||||
edgeType := strings.TrimSpace(l.Type)
|
||||
if from == "" {
|
||||
return nil, fmt.Errorf("links[%d] 须含 from", i)
|
||||
}
|
||||
if edgeType == "" {
|
||||
return nil, fmt.Errorf("links[%d] 须含 type", i)
|
||||
}
|
||||
parsed.Incoming = append(parsed.Incoming, database.ProjectFactEdgeFromInput{
|
||||
From: from, Type: edgeType, Confidence: strings.TrimSpace(l.Confidence),
|
||||
})
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
if linksText != nil {
|
||||
in, err := project.ParseFactLinksText(*linksText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &project.ParsedFactLinks{Incoming: in}, nil
|
||||
}
|
||||
return &project.ParsedFactLinks{Incoming: []database.ProjectFactEdgeFromInput{}}, nil
|
||||
}
|
||||
|
||||
type factWithLinksResponse struct {
|
||||
*database.ProjectFact
|
||||
OutgoingLinks []*database.ProjectFactEdge `json:"outgoing_links,omitempty"`
|
||||
IncomingLinks []*database.ProjectFactEdge `json:"incoming_links,omitempty"`
|
||||
LinkCounts *project.LinkCounts `json:"link_counts,omitempty"`
|
||||
}
|
||||
|
||||
func (h *ProjectHandler) applyFactLinksAfterUpsert(projectID string, fact *database.ProjectFact, links []factLinkRequest, linksText *string, explicitLinks, parseBody bool) error {
|
||||
if explicitLinks {
|
||||
parsed, err := factLinksFromRequest(links, linksText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return project.PersistFactLinksFromParsed(h.db, projectID, fact.FactKey, fact.SourceConversationID, parsed, true)
|
||||
}
|
||||
if parseBody {
|
||||
inputs := project.ParseLinksFromBody(fact.Body)
|
||||
if inputs == nil {
|
||||
return nil
|
||||
}
|
||||
return project.PersistFactIncomingLinks(h.db, projectID, fact.FactKey, inputs, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ProjectHandler) factResponseWithLinks(projectID string, f *database.ProjectFact, includeLinks bool) interface{} {
|
||||
if !includeLinks || f == nil {
|
||||
return f
|
||||
}
|
||||
out, _ := h.db.ListOutgoingProjectFactEdges(projectID, f.FactKey)
|
||||
in, _ := h.db.ListIncomingProjectFactEdges(projectID, f.FactKey)
|
||||
return &factWithLinksResponse{
|
||||
ProjectFact: f,
|
||||
OutgoingLinks: out,
|
||||
IncomingLinks: in,
|
||||
}
|
||||
}
|
||||
|
||||
// ListFacts GET /api/projects/:id/facts (fact_key 查询参数可获取单条详情)
|
||||
@@ -254,7 +332,8 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, f)
|
||||
includeLinks := c.Query("include_links") == "1" || c.Query("include_links") == "true"
|
||||
c.JSON(http.StatusOK, h.factResponseWithLinks(projectID, f, includeLinks))
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
||||
@@ -285,7 +364,52 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
||||
}
|
||||
list = filtered
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
includeLinkCounts := c.Query("include_link_counts") == "1" || c.Query("include_link_counts") == "true"
|
||||
if !includeLinkCounts {
|
||||
c.JSON(http.StatusOK, list)
|
||||
return
|
||||
}
|
||||
counts, err := project.LoadProjectFactLinkCounts(h.db, projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]factWithLinksResponse, 0, len(list))
|
||||
for _, f := range list {
|
||||
item := factWithLinksResponse{ProjectFact: f}
|
||||
if c, ok := counts[f.FactKey]; ok {
|
||||
cc := c
|
||||
item.LinkCounts = &cc
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// GetFactGraph GET /api/projects/:id/fact-graph?view=path|full
|
||||
func (h *ProjectHandler) GetFactGraph(c *gin.Context) {
|
||||
projectID := c.Param("id")
|
||||
if _, err := h.db.GetProject(projectID); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "项目不存在"})
|
||||
return
|
||||
}
|
||||
view := c.DefaultQuery("view", "path")
|
||||
excludeDeprecated := true
|
||||
if v := c.Query("exclude_deprecated"); v == "0" || v == "false" {
|
||||
excludeDeprecated = false
|
||||
}
|
||||
graph, err := project.BuildProjectFactGraph(h.db, projectID, view, excludeDeprecated)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if graph.Nodes == nil {
|
||||
graph.Nodes = []database.ProjectFactGraphNode{}
|
||||
}
|
||||
if graph.Edges == nil {
|
||||
graph.Edges = []database.ProjectFactGraphEdge{}
|
||||
}
|
||||
c.JSON(http.StatusOK, graph)
|
||||
}
|
||||
|
||||
// CreateFact POST /api/projects/:id/facts
|
||||
@@ -295,8 +419,9 @@ func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
projectID := c.Param("id")
|
||||
f := &database.ProjectFact{
|
||||
ProjectID: c.Param("id"),
|
||||
ProjectID: projectID,
|
||||
FactKey: req.FactKey,
|
||||
Category: req.Category,
|
||||
Summary: req.Summary,
|
||||
@@ -310,16 +435,24 @@ func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, created)
|
||||
explicitLinks := req.Links != nil || req.LinksText != nil
|
||||
if err := h.applyFactLinksAfterUpsert(projectID, created, req.Links, req.LinksText, explicitLinks, !explicitLinks); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
created, _ = h.db.GetProjectFactByKey(projectID, created.FactKey)
|
||||
c.JSON(http.StatusOK, h.factResponseWithLinks(projectID, created, true))
|
||||
}
|
||||
|
||||
// UpdateFact PUT /api/projects/:id/facts/:factId
|
||||
func (h *ProjectHandler) UpdateFact(c *gin.Context) {
|
||||
projectID := c.Param("id")
|
||||
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
||||
if err != nil || existing.ProjectID != c.Param("id") {
|
||||
if err != nil || existing.ProjectID != projectID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
||||
return
|
||||
}
|
||||
oldFactKey := existing.FactKey
|
||||
var req updateFactRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -355,7 +488,29 @@ func (h *ProjectHandler) UpdateFact(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updated)
|
||||
if oldFactKey != updated.FactKey {
|
||||
if err := h.db.RenameProjectFactKeyEdges(projectID, oldFactKey, updated.FactKey); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Links != nil || req.LinksText != nil {
|
||||
var links []factLinkRequest
|
||||
if req.Links != nil {
|
||||
links = *req.Links
|
||||
}
|
||||
if err := h.applyFactLinksAfterUpsert(projectID, updated, links, req.LinksText, true, false); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else if req.ClearBody || req.Body != nil {
|
||||
if err := h.applyFactLinksAfterUpsert(projectID, updated, nil, nil, false, true); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
updated, _ = h.db.GetProjectFactByKey(projectID, updated.FactKey)
|
||||
c.JSON(http.StatusOK, h.factResponseWithLinks(projectID, updated, true))
|
||||
}
|
||||
|
||||
// DeleteFact DELETE /api/projects/:id/facts/:factId
|
||||
@@ -408,3 +563,82 @@ func (h *ProjectHandler) RestoreFact(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
type createFactEdgeRequest struct {
|
||||
SourceFactKey string `json:"source_fact_key" binding:"required"`
|
||||
TargetFactKey string `json:"target_fact_key" binding:"required"`
|
||||
EdgeType string `json:"edge_type" binding:"required"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
// ListFactEdges GET /api/projects/:id/fact-edges
|
||||
func (h *ProjectHandler) ListFactEdges(c *gin.Context) {
|
||||
projectID := c.Param("id")
|
||||
edges, err := h.db.ListProjectFactEdgesByProject(projectID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if edges == nil {
|
||||
edges = []*database.ProjectFactEdge{}
|
||||
}
|
||||
c.JSON(http.StatusOK, edges)
|
||||
}
|
||||
|
||||
// CreateFactEdge POST /api/projects/:id/fact-edges
|
||||
func (h *ProjectHandler) CreateFactEdge(c *gin.Context) {
|
||||
projectID := c.Param("id")
|
||||
var req createFactEdgeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
edge, err := h.db.AddProjectFactEdge(projectID, database.ProjectFactEdgeInput{
|
||||
To: req.TargetFactKey,
|
||||
Type: req.EdgeType,
|
||||
Confidence: req.Confidence,
|
||||
}, req.SourceFactKey, "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if f, err := h.db.GetProjectFactByKey(projectID, req.TargetFactKey); err == nil {
|
||||
in, _ := h.db.ListIncomingProjectFactEdges(projectID, req.TargetFactKey)
|
||||
f.Body = project.SyncBodyLinksSection(f.Body, in)
|
||||
_, _ = h.db.UpsertProjectFact(f)
|
||||
}
|
||||
c.JSON(http.StatusOK, edge)
|
||||
}
|
||||
|
||||
// DeleteFactEdge DELETE /api/projects/:id/fact-edges/:edgeId
|
||||
func (h *ProjectHandler) DeleteFactEdge(c *gin.Context) {
|
||||
projectID := c.Param("id")
|
||||
edgeID := c.Param("edgeId")
|
||||
edge, err := h.db.GetProjectFactEdge(edgeID)
|
||||
if err != nil || edge.ProjectID != projectID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "边不存在"})
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteProjectFactEdge(edgeID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if f, err := h.db.GetProjectFactByKey(projectID, edge.TargetFactKey); err == nil {
|
||||
in, _ := h.db.ListIncomingProjectFactEdges(projectID, edge.TargetFactKey)
|
||||
f.Body = project.SyncBodyLinksSection(f.Body, in)
|
||||
_, _ = h.db.UpsertProjectFact(f)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// PromoteAttackChain POST /api/projects/:id/promote-attack-chain/:conversationId
|
||||
func (h *ProjectHandler) PromoteAttackChain(c *gin.Context) {
|
||||
projectID := c.Param("id")
|
||||
conversationID := c.Param("conversationId")
|
||||
result, err := attackchain.PromoteToProject(h.db, projectID, conversationID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -30,3 +30,19 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
||||
}
|
||||
return strings.TrimSpace(block)
|
||||
}
|
||||
|
||||
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
|
||||
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
||||
if h == nil || h.db == nil {
|
||||
return ""
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" {
|
||||
return ""
|
||||
}
|
||||
projectID, err := h.db.GetConversationProjectID(conversationID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(projectID)
|
||||
}
|
||||
|
||||
@@ -447,7 +447,7 @@ func (h *RobotHandler) cmdUnbindProject(platform, userID string) string {
|
||||
}
|
||||
|
||||
func (h *RobotHandler) cmdList() string {
|
||||
convs, err := h.db.ListConversations(50, 0, "")
|
||||
convs, err := h.db.ListConversations(50, 0, "", "")
|
||||
if err != nil {
|
||||
return "获取对话列表失败: " + err.Error()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
// MonitorStorage 监控数据存储接口
|
||||
type MonitorStorage interface {
|
||||
SaveToolExecution(exec *ToolExecution) error
|
||||
UpdateToolExecutionResult(id string, result *ToolResult) error
|
||||
LoadToolExecutions() ([]*ToolExecution, error)
|
||||
GetToolExecution(id string) (*ToolExecution, error)
|
||||
SaveToolStats(toolName string, stats *ToolStats) error
|
||||
@@ -963,6 +964,26 @@ func (s *Server) RecordCompletedToolInvocation(toolName string, args map[string]
|
||||
return executionID
|
||||
}
|
||||
|
||||
// UpdateToolExecutionResult 将监控库中的工具结果更新为送入模型的展示正文(如 reduction 后的 persisted-output)。
|
||||
func (s *Server) UpdateToolExecutionResult(executionID string, result *ToolResult) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
executionID = strings.TrimSpace(executionID)
|
||||
if executionID == "" || result == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if exec, ok := s.executions[executionID]; ok && exec != nil {
|
||||
exec.Result = result
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if s.storage != nil {
|
||||
return s.storage.UpdateToolExecutionResult(executionID, result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupOldExecutions 清理旧的执行记录,防止内存无限增长
|
||||
func (s *Server) cleanupOldExecutions() {
|
||||
if len(s.executions) <= s.maxExecutionsInMemory {
|
||||
|
||||
@@ -88,6 +88,7 @@ type einoADKRunLoopArgs struct {
|
||||
// 在完成时写入 MCP 监控;execute 仍由 eino_execute_monitor 记录,此处跳过。
|
||||
FilesystemMonitorAgent *agent.Agent
|
||||
FilesystemMonitorRecord einomcp.ExecutionRecorder
|
||||
MCPExecutionBinder *MCPExecutionBinder
|
||||
|
||||
// ToolInvokeNotify 与 einomcp.ToolsFromDefinitions 共享:run loop 在迭代前 Set,MCP 桥 Fire 以补全 tool_result。
|
||||
ToolInvokeNotify *einomcp.ToolInvokeNotifyHolder
|
||||
@@ -285,53 +286,63 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
executeStdoutDupMu.Unlock()
|
||||
}
|
||||
|
||||
var toolResultSent sync.Map // toolCallID -> struct{};与 ADK Tool 消息去重,避免 bridge 与事件流各推一次
|
||||
if args.ToolInvokeNotify != nil {
|
||||
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
removePendingByID(tid)
|
||||
if tid == "" || progress == nil {
|
||||
return
|
||||
var toolResultSent sync.Map // toolCallID -> struct{};ADK Tool 事件去重(权威正文来自 reduction 处理后的 agent 上下文)
|
||||
tryEmitToolResultProgress := func(toolName, content, toolCallID string, isErr bool, agentName string) {
|
||||
if progress == nil {
|
||||
return
|
||||
}
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
toolName = "unknown"
|
||||
}
|
||||
preview := content
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"success": !isErr,
|
||||
"isError": isErr,
|
||||
"result": content,
|
||||
"resultPreview": preview,
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": agentName,
|
||||
"einoRole": einoRoleTag(agentName),
|
||||
"source": "eino",
|
||||
}
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
if tid == "" {
|
||||
if inferred, ok := popNextPendingForAgent(agentName); ok {
|
||||
tid = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(orchestratorName); ok {
|
||||
tid = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
||||
tid = inferred.ToolCallID
|
||||
} else if inferred, ok := popAnyPending(); ok {
|
||||
tid = inferred.ToolCallID
|
||||
}
|
||||
}
|
||||
if tid != "" {
|
||||
removePendingByID(tid)
|
||||
if _, loaded := toolResultSent.LoadOrStore(tid, struct{}{}); loaded {
|
||||
return
|
||||
}
|
||||
isErr := !success || invokeErr != nil
|
||||
body := content
|
||||
if invokeErr != nil {
|
||||
// 保留已流式累计的 stdout(如 execute 超时前的一半输出),避免 tool_result 只剩错误串、模型与 UI 丢失上下文
|
||||
tail := friendlyEinoExecuteInvokeTail(invokeErr)
|
||||
// execute 流式包装可能已把超时句写入 content(供 ADK tool 与流式 delta);勿重复拼接
|
||||
if tail != "" && strings.Contains(content, tail) {
|
||||
body = content
|
||||
} else if strings.TrimSpace(content) != "" {
|
||||
body = strings.TrimRight(content, "\n") + "\n\n" + tail
|
||||
} else {
|
||||
body = tail
|
||||
}
|
||||
isErr = true
|
||||
data["toolCallId"] = tid
|
||||
toolCallID = tid
|
||||
}
|
||||
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
|
||||
if args.FilesystemMonitorAgent != nil && args.MCPExecutionBinder != nil {
|
||||
if execID := args.MCPExecutionBinder.ExecutionID(toolCallID); execID != "" {
|
||||
args.FilesystemMonitorAgent.UpdateMCPExecutionDisplayResult(execID, content)
|
||||
}
|
||||
recordPendingExecuteStdoutDup(toolName, body, isErr)
|
||||
preview := body
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
agentTag := strings.TrimSpace(einoAgent)
|
||||
if agentTag == "" {
|
||||
agentTag = orchestratorName
|
||||
}
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"success": !isErr,
|
||||
"isError": isErr,
|
||||
"result": body,
|
||||
"resultPreview": preview,
|
||||
"toolCallId": tid,
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": agentTag,
|
||||
"einoRole": einoRoleTag(agentTag),
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||
}
|
||||
if args.ToolInvokeNotify != nil {
|
||||
args.ToolInvokeNotify.Set(func(toolCallID, toolName, einoAgent string, success bool, content string, invokeErr error) {
|
||||
removePendingByID(strings.TrimSpace(toolCallID))
|
||||
// tool_result 仅由下方 ADK schema.Tool 事件推送,正文与送入模型的上下文一致(含 reduction 截断)。
|
||||
})
|
||||
}
|
||||
|
||||
@@ -632,6 +643,50 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
mv := ev.Output.MessageOutput
|
||||
|
||||
if mv.IsStreaming && mv.MessageStream != nil && mv.Role == schema.Tool {
|
||||
toolName := strings.TrimSpace(mv.ToolName)
|
||||
var toolBuf strings.Builder
|
||||
streamToolCallID := ""
|
||||
var toolStreamRecvErr error
|
||||
for {
|
||||
chunk, rerr := mv.MessageStream.Recv()
|
||||
if errors.Is(rerr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
toolStreamRecvErr = rerr
|
||||
break
|
||||
}
|
||||
if chunk == nil {
|
||||
continue
|
||||
}
|
||||
if chunk.Content != "" {
|
||||
toolBuf.WriteString(chunk.Content)
|
||||
}
|
||||
if tid := strings.TrimSpace(chunk.ToolCallID); tid != "" {
|
||||
streamToolCallID = tid
|
||||
}
|
||||
}
|
||||
content := toolBuf.String()
|
||||
isErr := false
|
||||
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||
isErr = true
|
||||
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
||||
}
|
||||
if streamToolCallID != "" {
|
||||
opts := []schema.ToolMessageOption{schema.WithToolName(toolName)}
|
||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.ToolMessage(content, streamToolCallID, opts...))
|
||||
}
|
||||
tryEmitToolResultProgress(toolName, content, streamToolCallID, isErr, ev.AgentName)
|
||||
if toolStreamRecvErr != nil && logger != nil {
|
||||
logger.Warn("eino tool result stream recv error",
|
||||
zap.Error(toolStreamRecvErr),
|
||||
zap.String("agent", ev.AgentName),
|
||||
zap.String("tool", toolName))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if mv.IsStreaming && mv.MessageStream != nil {
|
||||
mainStreamID := fmt.Sprintf("eino-main-%s-%d", conversationID, atomic.AddInt64(&mainResponseStreamSeq, 1))
|
||||
streamHeaderSent := false
|
||||
@@ -785,6 +840,16 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
}
|
||||
}
|
||||
if progress != nil && reasoningStreamID != "" && strings.TrimSpace(reasoningBuf) != "" {
|
||||
progress("reasoning_chain_stream_end", openai.DisplayReasoningContent(strings.TrimSpace(reasoningBuf)), map[string]interface{}{
|
||||
"streamId": reasoningStreamID,
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"einoAgent": ev.AgentName,
|
||||
"einoRole": einoRoleTag(ev.AgentName),
|
||||
"orchestration": orchMode,
|
||||
})
|
||||
}
|
||||
if streamsMainAssistant(ev.AgentName) {
|
||||
s := strings.TrimSpace(mainAssistantBuf)
|
||||
if mainAssistDupTarget != "" {
|
||||
@@ -963,7 +1028,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
}
|
||||
}
|
||||
|
||||
if mv.Role == schema.Tool && progress != nil {
|
||||
if (mv.Role == schema.Tool || msg.Role == schema.Tool) && progress != nil {
|
||||
toolName := msg.ToolName
|
||||
if toolName == "" {
|
||||
toolName = mv.ToolName
|
||||
@@ -976,46 +1041,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
||||
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
||||
}
|
||||
|
||||
preview := content
|
||||
if len(preview) > 200 {
|
||||
preview = preview[:200] + "..."
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"success": !isErr,
|
||||
"isError": isErr,
|
||||
"result": content,
|
||||
"resultPreview": preview,
|
||||
"conversationId": conversationID,
|
||||
"einoAgent": ev.AgentName,
|
||||
"einoRole": einoRoleTag(ev.AgentName),
|
||||
"source": "eino",
|
||||
}
|
||||
toolCallID := strings.TrimSpace(msg.ToolCallID)
|
||||
if toolCallID == "" {
|
||||
if inferred, ok := popNextPendingForAgent(ev.AgentName); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(orchestratorName); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
} else if inferred, ok := popAnyPending(); ok {
|
||||
toolCallID = inferred.ToolCallID
|
||||
}
|
||||
}
|
||||
if toolCallID != "" {
|
||||
removePendingByID(toolCallID)
|
||||
if _, loaded := toolResultSent.LoadOrStore(toolCallID, struct{}{}); loaded {
|
||||
// ToolInvokeNotify 可能已推过 tool_result(如 execute 流式包装里 Fire 仅携带截断后的 stdout),
|
||||
// 此处仍应用 ADK Tool 消息中的完整内容刷新去重基准,避免模型复述全文时与截断串比对失败而重复展示「助手输出」。
|
||||
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||
continue
|
||||
}
|
||||
data["toolCallId"] = toolCallID
|
||||
}
|
||||
recordPendingExecuteStdoutDup(toolName, content, isErr)
|
||||
recordEinoADKFilesystemToolMonitor(args.FilesystemMonitorAgent, args.FilesystemMonitorRecord, toolName, toolCallID, runAccumulatedMsgs, content, isErr)
|
||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||
tryEmitToolResultProgress(toolName, content, toolCallID, isErr, ev.AgentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
// newEinoExecuteMonitorCallback 在 Eino filesystem execute 结束时写入 MCP 监控库并 recorder(executionId),
|
||||
// 与 CallTool 路径一致,供助手消息展示「渗透测试详情」芯片。
|
||||
func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRecorder) func(command, stdout string, success bool, invokeErr error) {
|
||||
return func(command, stdout string, success bool, invokeErr error) {
|
||||
func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRecorder) func(toolCallID, command, stdout string, success bool, invokeErr error) {
|
||||
return func(toolCallID, command, stdout string, success bool, invokeErr error) {
|
||||
if ag == nil || recorder == nil {
|
||||
return
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func newEinoExecuteMonitorCallback(ag *agent.Agent, recorder einomcp.ExecutionRe
|
||||
args := map[string]interface{}{"command": command}
|
||||
id := ag.RecordLocalToolExecution("execute", args, stdout, err)
|
||||
if id != "" {
|
||||
recorder(id)
|
||||
recorder(id, toolCallID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ type einoStreamingShellWrap struct {
|
||||
// toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。
|
||||
toolTimeoutMinutes int
|
||||
// recordMonitor 在 execute 流结束后写入 tool_executions 并 recorder(executionId),使「渗透测试详情」与常规 MCP 一致。
|
||||
recordMonitor func(command, stdout string, success bool, invokeErr error)
|
||||
recordMonitor func(toolCallID, command, stdout string, success bool, invokeErr error)
|
||||
}
|
||||
|
||||
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
|
||||
@@ -84,7 +84,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
execCancel()
|
||||
}
|
||||
if w.recordMonitor != nil {
|
||||
w.recordMonitor(userCmd, "", false, err)
|
||||
w.recordMonitor(tid, userCmd, "", false, err)
|
||||
}
|
||||
if w.invokeNotify != nil && tid != "" {
|
||||
w.invokeNotify.Fire(tid, "execute", agentTag, false, "", err)
|
||||
@@ -107,7 +107,6 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
const maxCapture = 16 * 1024
|
||||
success := true
|
||||
var invokeErr error
|
||||
exitCode := 0
|
||||
@@ -130,15 +129,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
exitCode = *resp.ExitCode
|
||||
}
|
||||
var appended string
|
||||
if remain := maxCapture - sb.Len(); remain > 0 {
|
||||
out := resp.Output
|
||||
if len(out) > remain {
|
||||
out = out[:remain]
|
||||
}
|
||||
sb.WriteString(out)
|
||||
appended = out
|
||||
if resp.Output != "" {
|
||||
sb.WriteString(resp.Output)
|
||||
appended = resp.Output
|
||||
}
|
||||
// 仅推送写入 sb 的片段,与末尾 Fire/recordMonitor 的截断累计一致,避免最终 tool_result 短于已展示增量。
|
||||
if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
|
||||
w.outputChunk("execute", tid, appended)
|
||||
}
|
||||
@@ -167,16 +161,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
if w.outputChunk != nil && tid != "" {
|
||||
w.outputChunk("execute", tid, hint)
|
||||
}
|
||||
if remain := maxCapture - sb.Len(); remain > 0 {
|
||||
h := hint
|
||||
if len(h) > remain {
|
||||
h = h[:remain]
|
||||
}
|
||||
sb.WriteString(h)
|
||||
}
|
||||
sb.WriteString(hint)
|
||||
}
|
||||
if w.recordMonitor != nil {
|
||||
w.recordMonitor(command, sb.String(), success, invokeErr)
|
||||
w.recordMonitor(tid, command, sb.String(), success, invokeErr)
|
||||
}
|
||||
w.invokeNotify.Fire(tid, "execute", agentTag, success, sb.String(), invokeErr)
|
||||
outW.Close()
|
||||
|
||||
@@ -96,6 +96,6 @@ func recordEinoADKFilesystemToolMonitor(
|
||||
}
|
||||
id := ag.RecordLocalToolExecution(storedName, args, resultText, invErr)
|
||||
if id != "" {
|
||||
rec(id)
|
||||
rec(id, toolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,14 +103,26 @@ func mergeAlwaysVisibleToolNames(configured []string) []string {
|
||||
return merged
|
||||
}
|
||||
|
||||
func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddlewareConfig, convID string, loc *localbk.Local, logger *zap.Logger) (adk.ChatModelAgentMiddleware, error) {
|
||||
func reductionCacheRootDir(configuredBase, projectID, conversationID string) string {
|
||||
base := strings.TrimSpace(configuredBase)
|
||||
if base == "" {
|
||||
base = filepath.Join("tmp", "reduction")
|
||||
}
|
||||
if pid := strings.TrimSpace(projectID); pid != "" {
|
||||
return filepath.Join(base, "projects", sanitizeEinoPathSegment(pid))
|
||||
}
|
||||
conv := strings.TrimSpace(conversationID)
|
||||
if conv == "" {
|
||||
conv = "default"
|
||||
}
|
||||
return filepath.Join(base, "conversations", sanitizeEinoPathSegment(conv))
|
||||
}
|
||||
|
||||
func buildReductionMiddleware(ctx context.Context, mw config.MultiAgentEinoMiddlewareConfig, projectID, convID string, loc *localbk.Local, logger *zap.Logger) (adk.ChatModelAgentMiddleware, error) {
|
||||
if loc == nil {
|
||||
return nil, fmt.Errorf("reduction: local backend nil")
|
||||
}
|
||||
root := strings.TrimSpace(mw.ReductionRootDir)
|
||||
if root == "" {
|
||||
root = filepath.Join(os.TempDir(), "cyberstrike-reduction", sanitizeEinoPathSegment(convID))
|
||||
}
|
||||
root := reductionCacheRootDir(mw.ReductionRootDir, projectID, convID)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("reduction root: %w", err)
|
||||
}
|
||||
@@ -148,6 +160,7 @@ func prependEinoMiddlewares(
|
||||
einoLoc *localbk.Local,
|
||||
skillsRoot string,
|
||||
conversationID string,
|
||||
projectID string,
|
||||
logger *zap.Logger,
|
||||
) (outTools []tool.BaseTool, extraHandlers []adk.ChatModelAgentMiddleware, toolSearchActive bool, err error) {
|
||||
if mw == nil {
|
||||
@@ -167,7 +180,7 @@ func prependEinoMiddlewares(
|
||||
if place == einoMWSub && !mw.ReductionSubAgents {
|
||||
// skip
|
||||
} else {
|
||||
redMW, rerr := buildReductionMiddleware(ctx, *mw, conversationID, einoLoc, logger)
|
||||
redMW, rerr := buildReductionMiddleware(ctx, *mw, projectID, conversationID, einoLoc, logger)
|
||||
if rerr != nil {
|
||||
return nil, nil, false, rerr
|
||||
}
|
||||
|
||||
@@ -3,12 +3,31 @@ package multiagent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestReductionCacheRootDir(t *testing.T) {
|
||||
got := reductionCacheRootDir("", "proj-1", "conv-1")
|
||||
want := filepath.Join("tmp", "reduction", "projects", "proj-1")
|
||||
if got != want {
|
||||
t.Fatalf("project scope: got %q want %q", got, want)
|
||||
}
|
||||
got = reductionCacheRootDir("", "", "conv-abc")
|
||||
want = filepath.Join("tmp", "reduction", "conversations", "conv-abc")
|
||||
if got != want {
|
||||
t.Fatalf("conversation scope: got %q want %q", got, want)
|
||||
}
|
||||
custom := reductionCacheRootDir("/data/cache", "p1", "c1")
|
||||
if !strings.HasSuffix(custom, filepath.Join("projects", "p1")) {
|
||||
t.Fatalf("custom base should still scope by project, got %q", custom)
|
||||
}
|
||||
}
|
||||
|
||||
type stubTool struct{ name string }
|
||||
|
||||
func (s stubTool) Info(_ context.Context) (*schema.ToolInfo, error) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
@@ -29,7 +30,9 @@ type PlanExecuteRootArgs struct {
|
||||
MwCfg *config.MultiAgentEinoMiddlewareConfig
|
||||
// ConversationID is used for transcript/isolation paths in middleware.
|
||||
ConversationID string
|
||||
Logger *zap.Logger
|
||||
DB *database.DB
|
||||
ProjectID string
|
||||
Logger *zap.Logger
|
||||
// ModelName is used for model input token estimation logs.
|
||||
ModelName string
|
||||
// ExecPreMiddlewares 是由 prependEinoMiddlewares 构建的前置中间件(patchtoolcalls, reduction, toolsearch, plantask),
|
||||
@@ -93,7 +96,7 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
||||
}
|
||||
// 4. summarization(最后,与 Deep/Supervisor 一致)
|
||||
if a.AppCfg != nil {
|
||||
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.Logger)
|
||||
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
|
||||
if sumErr != nil {
|
||||
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/project"
|
||||
@@ -32,8 +33,10 @@ func RunEinoSingleChatModelAgent(
|
||||
appCfg *config.Config,
|
||||
ma *config.MultiAgentConfig,
|
||||
ag *agent.Agent,
|
||||
db *database.DB,
|
||||
logger *zap.Logger,
|
||||
conversationID string,
|
||||
projectID string,
|
||||
userMessage string,
|
||||
history []agent.ChatMessage,
|
||||
roleTools []string,
|
||||
@@ -58,10 +61,12 @@ func RunEinoSingleChatModelAgent(
|
||||
|
||||
var mcpIDsMu sync.Mutex
|
||||
var mcpIDs []string
|
||||
recorder := func(id string) {
|
||||
mcpExecBinder := NewMCPExecutionBinder()
|
||||
recorder := func(id, toolCallID string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
mcpExecBinder.Bind(toolCallID, id)
|
||||
mcpIDsMu.Lock()
|
||||
mcpIDs = append(mcpIDs, id)
|
||||
mcpIDsMu.Unlock()
|
||||
@@ -75,29 +80,15 @@ func RunEinoSingleChatModelAgent(
|
||||
return out
|
||||
}
|
||||
|
||||
toolOutputChunk := func(toolName, toolCallID, chunk string) {
|
||||
if progress == nil || toolCallID == "" {
|
||||
return
|
||||
}
|
||||
progress("tool_result_delta", chunk, map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"index": 0,
|
||||
"total": 0,
|
||||
"iteration": 0,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
|
||||
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
||||
einoExecMonitor := newEinoExecuteMonitorCallback(ag, recorder)
|
||||
mainDefs := ag.ToolsForRole(roleTools)
|
||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, einoSingleAgentName)
|
||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, einoSingleAgentName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mainToolsForCfg, mainOrchestratorPre, singleToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
|
||||
mainToolsForCfg, mainOrchestratorPre, singleToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, projectID, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eino single eino 中间件: %w", err)
|
||||
}
|
||||
@@ -132,7 +123,7 @@ func RunEinoSingleChatModelAgent(
|
||||
return nil, fmt.Errorf("eino single 模型: %w", err)
|
||||
}
|
||||
|
||||
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
|
||||
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eino single summarization: %w", err)
|
||||
}
|
||||
@@ -145,7 +136,7 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
if einoSkillMW != nil {
|
||||
if einoFSTools && einoLoc != nil {
|
||||
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
|
||||
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecMonitor, agentToolTimeoutMinutes(appCfg), nil)
|
||||
if fsErr != nil {
|
||||
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
|
||||
}
|
||||
@@ -237,6 +228,7 @@ func RunEinoSingleChatModelAgent(
|
||||
McpIDs: &mcpIDs,
|
||||
FilesystemMonitorAgent: ag,
|
||||
FilesystemMonitorRecord: recorder,
|
||||
MCPExecutionBinder: mcpExecBinder,
|
||||
ToolInvokeNotify: toolInvokeNotify,
|
||||
DA: chatAgent,
|
||||
ModelFacingTrace: modelFacingTrace,
|
||||
|
||||
@@ -81,7 +81,7 @@ func subAgentFilesystemMiddleware(
|
||||
loc *localbk.Local,
|
||||
invokeNotify *einomcp.ToolInvokeNotifyHolder,
|
||||
einoAgentName string,
|
||||
recordMonitor func(command, stdout string, success bool, invokeErr error),
|
||||
recordMonitor func(toolCallID, command, stdout string, success bool, invokeErr error),
|
||||
toolTimeoutMinutes int,
|
||||
outputChunk func(toolName, toolCallID, chunk string),
|
||||
) (adk.ChatModelAgentMiddleware, error) {
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
copenai "cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/project"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
@@ -40,6 +42,8 @@ func newEinoSummarizationMiddleware(
|
||||
appCfg *config.Config,
|
||||
mwCfg *config.MultiAgentEinoMiddlewareConfig,
|
||||
conversationID string,
|
||||
db *database.DB,
|
||||
projectID string,
|
||||
logger *zap.Logger,
|
||||
) (adk.ChatModelAgentMiddleware, error) {
|
||||
if summaryModel == nil || appCfg == nil {
|
||||
@@ -143,7 +147,14 @@ func newEinoSummarizationMiddleware(
|
||||
},
|
||||
},
|
||||
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
||||
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
||||
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
||||
if ferr != nil {
|
||||
return nil, ferr
|
||||
}
|
||||
if appCfg != nil {
|
||||
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
Callback: func(ctx context.Context, before, after adk.ChatModelAgentState) error {
|
||||
if transcriptPath != "" && len(before.Messages) > 0 {
|
||||
@@ -176,6 +187,50 @@ func newEinoSummarizationMiddleware(
|
||||
return mw, nil
|
||||
}
|
||||
|
||||
// refreshFactIndexInMessages 在 summarization 压缩后,用 DB 最新索引替换 system 中已有的项目黑板索引段。
|
||||
func refreshFactIndexInMessages(msgs []adk.Message, db *database.DB, projectID string, cfg config.ProjectConfig, logger *zap.Logger) []adk.Message {
|
||||
if db == nil || !cfg.Enabled {
|
||||
return msgs
|
||||
}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return msgs
|
||||
}
|
||||
freshIndex, err := project.BuildFactIndexBlock(db, projectID, cfg)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("summarization: 刷新项目黑板索引失败", zap.String("projectId", projectID), zap.Error(err))
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
freshIndex = strings.TrimSpace(freshIndex)
|
||||
if freshIndex == "" {
|
||||
return msgs
|
||||
}
|
||||
|
||||
changed := false
|
||||
out := make([]adk.Message, len(msgs))
|
||||
for i, msg := range msgs {
|
||||
if msg == nil || msg.Role != schema.System {
|
||||
out[i] = msg
|
||||
continue
|
||||
}
|
||||
newContent, ok := project.ReplaceFactIndexSection(msg.Content, freshIndex)
|
||||
if !ok {
|
||||
out[i] = msg
|
||||
continue
|
||||
}
|
||||
cloned := *msg
|
||||
cloned.Content = newContent
|
||||
out[i] = &cloned
|
||||
changed = true
|
||||
}
|
||||
if changed && logger != nil {
|
||||
logger.Info("summarization: 已刷新项目黑板索引", zap.String("projectId", projectID))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// summarizeFinalizeWithRecentAssistantToolTrail 在摘要消息后保留最近 assistant/tool 轨迹,避免压缩后执行链断裂。
|
||||
//
|
||||
// 关键不变量:tool_call ↔ tool_result 的 pair 必须整体保留或整体丢弃。
|
||||
|
||||
@@ -7,9 +7,14 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/project"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fixedTokenCounter 让 tool 消息按 tokensPerToolMessage 计,其它消息按 1 计。
|
||||
@@ -389,9 +394,11 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
|
||||
"你是CyberStrikeAI,是一个专业的网络安全渗透测试专家。",
|
||||
"高强度扫描要求:全力出击",
|
||||
"",
|
||||
project.FactIndexSectionStartMarker,
|
||||
"## 项目黑板索引(project: 123, id: abc)",
|
||||
"(暂无事实)",
|
||||
"需要写入请使用 upsert_project_fact。",
|
||||
project.FactIndexSectionEndMarker,
|
||||
"",
|
||||
"# Skills System",
|
||||
"**How to Use Skills**",
|
||||
@@ -419,7 +426,7 @@ func TestSanitizeSystemContentForTranscript_BestPractice(t *testing.T) {
|
||||
func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
msgs := []adk.Message{
|
||||
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n## 项目黑板索引(project: p1, id: x)\n(暂无事实)\n# Skills System\nboiler"),
|
||||
schema.SystemMessage("以下是当前会话绑定的工具名称索引\n- nmap\n\n你是CyberStrikeAI\n" + project.FactIndexSectionStartMarker + "\n## 项目黑板索引(project: p1, id: x)\n(暂无事实)\n" + project.FactIndexSectionEndMarker + "\n# Skills System\nboiler"),
|
||||
schema.UserMessage("hello"),
|
||||
schema.AssistantMessage("reply", nil),
|
||||
}
|
||||
@@ -434,3 +441,51 @@ func TestFormatSummarizationTranscript_OmitsBloatedSystem(t *testing.T) {
|
||||
t.Fatalf("dynamic blackboard missing: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshFactIndexInMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
dbPath := filepath.Join(t.TempDir(), "summarize-facts.db")
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
proj, err := db.CreateProject(&database.Project{Name: "summarize-proj"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg := config.ProjectConfig{Enabled: true}
|
||||
oldIndex, err := project.BuildFactIndexBlock(db, proj.ID, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "target/host",
|
||||
Category: "target",
|
||||
Summary: "fresh host fact",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
msgs := []adk.Message{
|
||||
schema.SystemMessage("instruction\n\n" + oldIndex),
|
||||
schema.UserMessage("hi"),
|
||||
}
|
||||
|
||||
out := refreshFactIndexInMessages(msgs, db, proj.ID, cfg, nil)
|
||||
sys := out[0].Content
|
||||
if strings.Contains(sys, "(暂无事实)") {
|
||||
t.Fatalf("expected refreshed index, got: %q", sys)
|
||||
}
|
||||
if !strings.Contains(sys, "fresh host fact") {
|
||||
t.Fatalf("expected new fact in index: %q", sys)
|
||||
}
|
||||
if !strings.Contains(sys, "instruction") {
|
||||
t.Fatalf("non-index system content should be preserved: %q", sys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
|
||||
"cyberstrike-ai/internal/project"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
@@ -19,7 +21,6 @@ const (
|
||||
transcriptToolIndexStartMarker = "以下是当前会话绑定的工具名称索引"
|
||||
transcriptPersonaStartMarker = "你是CyberStrikeAI"
|
||||
transcriptSkillsSystemMarker = "# Skills System"
|
||||
transcriptProjectBlackboardMarker = "## 项目黑板索引"
|
||||
)
|
||||
|
||||
// formatSummarizationTranscript renders pre-compaction messages for transcript.txt.
|
||||
@@ -88,11 +89,17 @@ func stripSkillsSystemBoilerplate(s string) string {
|
||||
}
|
||||
|
||||
func extractProjectBlackboardSection(s string) string {
|
||||
idx := strings.Index(s, transcriptProjectBlackboardMarker)
|
||||
if idx < 0 {
|
||||
start := strings.Index(s, project.FactIndexSectionStartMarker)
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s[idx:])
|
||||
section := s[start:]
|
||||
end := strings.Index(section, project.FactIndexSectionEndMarker)
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
section = section[:end+len(project.FactIndexSectionEndMarker)]
|
||||
return strings.TrimSpace(section)
|
||||
}
|
||||
|
||||
func appendTranscriptSection(sb *strings.Builder, role schema.RoleType, body string) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package multiagent
|
||||
|
||||
import "strings"
|
||||
|
||||
// MCPExecutionBinder maps ADK toolCallID → MCP monitor execution ID for a single agent run.
|
||||
type MCPExecutionBinder struct {
|
||||
byToolCall map[string]string
|
||||
}
|
||||
|
||||
func NewMCPExecutionBinder() *MCPExecutionBinder {
|
||||
return &MCPExecutionBinder{byToolCall: make(map[string]string)}
|
||||
}
|
||||
|
||||
func (b *MCPExecutionBinder) Bind(toolCallID, executionID string) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
eid := strings.TrimSpace(executionID)
|
||||
if tid == "" || eid == "" {
|
||||
return
|
||||
}
|
||||
b.byToolCall[tid] = eid
|
||||
}
|
||||
|
||||
func (b *MCPExecutionBinder) ExecutionID(toolCallID string) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return b.byToolCall[strings.TrimSpace(toolCallID)]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMCPExecutionBinder(t *testing.T) {
|
||||
b := NewMCPExecutionBinder()
|
||||
b.Bind("call-1", "exec-1")
|
||||
if got := b.ExecutionID("call-1"); got != "exec-1" {
|
||||
t.Fatalf("expected exec-1, got %q", got)
|
||||
}
|
||||
if got := b.ExecutionID("missing"); got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/agents"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/einomcp"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/project"
|
||||
@@ -56,8 +57,10 @@ func RunDeepAgent(
|
||||
appCfg *config.Config,
|
||||
ma *config.MultiAgentConfig,
|
||||
ag *agent.Agent,
|
||||
db *database.DB,
|
||||
logger *zap.Logger,
|
||||
conversationID string,
|
||||
projectID string,
|
||||
userMessage string,
|
||||
history []agent.ChatMessage,
|
||||
roleTools []string,
|
||||
@@ -107,10 +110,12 @@ func RunDeepAgent(
|
||||
|
||||
var mcpIDsMu sync.Mutex
|
||||
var mcpIDs []string
|
||||
recorder := func(id string) {
|
||||
mcpExecBinder := NewMCPExecutionBinder()
|
||||
recorder := func(id, toolCallID string) {
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
mcpExecBinder.Bind(toolCallID, id)
|
||||
mcpIDsMu.Lock()
|
||||
mcpIDs = append(mcpIDs, id)
|
||||
mcpIDsMu.Unlock()
|
||||
@@ -128,21 +133,6 @@ func RunDeepAgent(
|
||||
|
||||
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
||||
mainDefs := ag.ToolsForRole(roleTools)
|
||||
toolOutputChunk := func(toolName, toolCallID, chunk string) {
|
||||
// When toolCallId is missing, frontend ignores tool_result_delta.
|
||||
if progress == nil || toolCallID == "" {
|
||||
return
|
||||
}
|
||||
progress("tool_result_delta", chunk, map[string]interface{}{
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
// index/total/iteration are optional for UI; we don't know them in this bridge.
|
||||
"index": 0,
|
||||
"total": 0,
|
||||
"iteration": 0,
|
||||
"source": "eino",
|
||||
})
|
||||
}
|
||||
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Minute,
|
||||
@@ -210,19 +200,19 @@ func RunDeepAgent(
|
||||
}
|
||||
|
||||
subDefs := ag.ToolsForRole(roleTools)
|
||||
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, toolOutputChunk, toolInvokeNotify, id)
|
||||
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, nil, toolInvokeNotify, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("子代理 %q 工具: %w", id, err)
|
||||
}
|
||||
|
||||
subToolsForCfg, subPre, subToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWSub, subTools, einoLoc, skillsRoot, conversationID, logger)
|
||||
subToolsForCfg, subPre, subToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWSub, subTools, einoLoc, skillsRoot, conversationID, projectID, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("子代理 %q eino 中间件: %w", id, err)
|
||||
}
|
||||
|
||||
subMax := resolveMaxIterations(appCfg, sub.MaxIterations)
|
||||
|
||||
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
|
||||
subSumMw, err := newEinoSummarizationMiddleware(ctx, subModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("子代理 %q summarization 中间件: %w", id, err)
|
||||
}
|
||||
@@ -233,7 +223,7 @@ func RunDeepAgent(
|
||||
}
|
||||
if einoSkillMW != nil {
|
||||
if einoFSTools && einoLoc != nil {
|
||||
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
|
||||
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecMonitor, agentToolTimeoutMinutes(appCfg), nil)
|
||||
if fsErr != nil {
|
||||
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
|
||||
}
|
||||
@@ -293,7 +283,7 @@ func RunDeepAgent(
|
||||
return nil, fmt.Errorf("多代理主模型: %w", err)
|
||||
}
|
||||
|
||||
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, logger)
|
||||
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("多代理主 summarization 中间件: %w", err)
|
||||
}
|
||||
@@ -320,11 +310,11 @@ func RunDeepAgent(
|
||||
}
|
||||
}
|
||||
|
||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, toolOutputChunk, toolInvokeNotify, orchestratorName)
|
||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, orchestratorName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mainToolsForCfg, mainOrchestratorPre, mainToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, logger)
|
||||
mainToolsForCfg, mainOrchestratorPre, mainToolSearchActive, err := prependEinoMiddlewares(ctx, &ma.EinoMiddleware, einoMWMain, mainTools, einoLoc, skillsRoot, conversationID, projectID, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -371,7 +361,7 @@ func RunDeepAgent(
|
||||
inner: einoLoc,
|
||||
invokeNotify: toolInvokeNotify,
|
||||
einoAgentName: orchestratorName,
|
||||
outputChunk: toolOutputChunk,
|
||||
outputChunk: nil,
|
||||
recordMonitor: einoExecMonitor,
|
||||
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
|
||||
}
|
||||
@@ -438,7 +428,7 @@ func RunDeepAgent(
|
||||
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
||||
var peFsMw adk.ChatModelAgentMiddleware
|
||||
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
|
||||
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecMonitor, agentToolTimeoutMinutes(appCfg), toolOutputChunk)
|
||||
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecMonitor, agentToolTimeoutMinutes(appCfg), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
|
||||
}
|
||||
@@ -453,6 +443,8 @@ func RunDeepAgent(
|
||||
AppCfg: appCfg,
|
||||
MwCfg: &ma.EinoMiddleware,
|
||||
ConversationID: conversationID,
|
||||
DB: db,
|
||||
ProjectID: projectID,
|
||||
Logger: logger,
|
||||
ModelName: appCfg.OpenAI.Model,
|
||||
ExecPreMiddlewares: mainOrchestratorPre,
|
||||
@@ -565,6 +557,7 @@ func RunDeepAgent(
|
||||
McpIDs: &mcpIDs,
|
||||
FilesystemMonitorAgent: ag,
|
||||
FilesystemMonitorRecord: recorder,
|
||||
MCPExecutionBinder: mcpExecBinder,
|
||||
ToolInvokeNotify: toolInvokeNotify,
|
||||
DA: da,
|
||||
ModelFacingTrace: modelFacingTrace,
|
||||
|
||||
@@ -10,7 +10,7 @@ package openai
|
||||
// Auth: Bearer → x-api-key
|
||||
// Tools: OpenAI tools[] → Claude tools[] (input_schema)
|
||||
//
|
||||
// Extended thinking: 顶层 `thinking` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
|
||||
// Extended thinking: 顶层 `thinking` / `output_config` 从 OpenAI 请求体透传;响应中 `thinking` block 映射为
|
||||
// `reasoning_content`(可读前缀 + 内部 JSON 尾缀以保留 signature,供多轮工具续跑;UI 用 openai.DisplayReasoningContent 剥离)。
|
||||
|
||||
import (
|
||||
@@ -40,8 +40,9 @@ type claudeRequest struct {
|
||||
System string `json:"system,omitempty"`
|
||||
Messages []claudeMessage `json:"messages"`
|
||||
Tools []claudeTool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Thinking json.RawMessage `json:"thinking,omitempty"`
|
||||
OutputConfig json.RawMessage `json:"output_config,omitempty"`
|
||||
}
|
||||
|
||||
type claudeMessage struct {
|
||||
@@ -304,12 +305,17 @@ func convertOpenAIToClaude(payload interface{}) (*claudeRequest, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extended thinking (Anthropic top-level); merged from Eino ExtraFields / admin extras.
|
||||
// Extended thinking + effort (Anthropic top-level); merged from Eino ExtraFields / admin extras.
|
||||
if th, ok := oai["thinking"]; ok && th != nil {
|
||||
if raw, err := json.Marshal(th); err == nil && len(raw) > 0 && string(raw) != "null" {
|
||||
req.Thinking = json.RawMessage(raw)
|
||||
}
|
||||
}
|
||||
if oc, ok := oai["output_config"]; ok && oc != nil {
|
||||
if raw, err := json.Marshal(oc); err == nil && len(raw) > 0 && string(raw) != "null" {
|
||||
req.OutputConfig = json.RawMessage(raw)
|
||||
}
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
@@ -73,6 +73,39 @@ func TestConvertOpenAIToClaude_AssistantReasoningReplay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIToClaude_OutputConfigEffort(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"model": "claude-opus-4-8",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{"role": "user", "content": "hi"},
|
||||
},
|
||||
"thinking": map[string]interface{}{
|
||||
"type": "adaptive",
|
||||
"display": "summarized",
|
||||
},
|
||||
"output_config": map[string]interface{}{
|
||||
"effort": "high",
|
||||
},
|
||||
}
|
||||
req, err := convertOpenAIToClaude(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(req.Thinking) == 0 {
|
||||
t.Fatal("expected thinking")
|
||||
}
|
||||
if len(req.OutputConfig) == 0 {
|
||||
t.Fatal("expected output_config")
|
||||
}
|
||||
var oc map[string]interface{}
|
||||
if err := json.Unmarshal(req.OutputConfig, &oc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oc["effort"] != "high" {
|
||||
t.Fatalf("effort=%v", oc["effort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeToOpenAIResponseJSON_Thinking(t *testing.T) {
|
||||
claudeBody := []byte(`{
|
||||
"id":"msg_1","type":"message","role":"assistant","model":"x","stop_reason":"end_turn",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -535,3 +536,81 @@ func (c *Client) ChatCompletionStreamWithToolCalls(
|
||||
|
||||
return full.String(), toolCalls, finishReason, nil
|
||||
}
|
||||
|
||||
// ModelsListResponse 表示 OpenAI 兼容 GET /models 响应。
|
||||
type ModelsListResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object,omitempty"`
|
||||
OwnedBy string `json:"owned_by,omitempty"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// ListModels 调用 GET {baseURL}/models 获取可用模型 id 列表(按字典序)。
|
||||
func (c *Client) ListModels(ctx context.Context) ([]string, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("openai client is not initialized")
|
||||
}
|
||||
if c.config == nil {
|
||||
return nil, fmt.Errorf("openai config is nil")
|
||||
}
|
||||
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||
return nil, fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
if c.isClaude() {
|
||||
return nil, fmt.Errorf("claude provider does not support models list API")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build openai models request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("call openai models api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read openai models response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
}
|
||||
}
|
||||
|
||||
var list ModelsListResponse
|
||||
if err := json.Unmarshal(respBody, &list); err != nil {
|
||||
return nil, fmt.Errorf("decode openai models response: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(list.Data))
|
||||
models := make([]string, 0, len(list.Data))
|
||||
for _, item := range list.Data {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
models = append(models, id)
|
||||
}
|
||||
sort.Strings(models)
|
||||
if len(models) == 0 {
|
||||
return nil, fmt.Errorf("models list is empty")
|
||||
}
|
||||
return models, nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
@@ -22,7 +21,13 @@ func AppendSystemPromptBlock(base, block string) string {
|
||||
return base + "\n\n" + block
|
||||
}
|
||||
|
||||
// BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(仅 key + summary,不含 body)。
|
||||
const (
|
||||
factIndexFooterGetDetail = "需要完整内容(攻击链、POC、请求响应等)时必须调用 get_project_fact(fact_key),禁止凭摘要臆造细节。"
|
||||
factIndexFooterWriteHint = "写入事实 links 时用 from(来源 fact_key → 当前 fact),如 finding 上 {from:target/*, type:discovered_on};body 写可复现全流程(发现/利用类 fact_key 建议 finding|chain|exploit|poc/ 前缀)。"
|
||||
factIndexFooterEmpty = "需要写入请使用 upsert_project_fact;需要详情请调用 get_project_fact(fact_key)。"
|
||||
)
|
||||
|
||||
// BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(key + summary + 关系边 + 攻击路径,不含 body)。
|
||||
func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) {
|
||||
if db == nil || !cfg.Enabled {
|
||||
return "", nil
|
||||
@@ -41,27 +46,38 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
allEdges, _ := db.ListProjectFactEdgesByProject(projectID)
|
||||
_, incomingByTarget := indexEdgeGroupMaps(allEdges)
|
||||
|
||||
if len(facts) == 0 {
|
||||
return fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n(暂无事实)\n需要写入请使用 upsert_project_fact;需要详情请调用 get_project_fact(fact_key)。", proj.Name, proj.ID), nil
|
||||
return wrapFactIndexBlock(fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n(暂无事实)\n%s", proj.Name, proj.ID, factIndexFooterEmpty)), nil
|
||||
}
|
||||
|
||||
sort.SliceStable(facts, func(i, j int) bool {
|
||||
if facts[i].Pinned != facts[j].Pinned {
|
||||
return facts[i].Pinned
|
||||
}
|
||||
return facts[i].UpdatedAt.After(facts[j].UpdatedAt)
|
||||
})
|
||||
sortFactsForIndex(facts)
|
||||
|
||||
maxRunes := cfg.FactIndexMaxRunesEffective()
|
||||
pathMaxRunes := cfg.FactIndexPathMaxRunesEffective()
|
||||
footer := factIndexFooterGetDetail + "\n" + factIndexFooterWriteHint
|
||||
footerRunes := len([]rune(footer))
|
||||
factsBudget := maxRunes - pathMaxRunes - footerRunes
|
||||
if factsBudget < 800 {
|
||||
factsBudget = maxRunes - footerRunes
|
||||
pathMaxRunes = 0
|
||||
}
|
||||
|
||||
indexedKeys := make(map[string]struct{}, len(facts))
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("## 项目黑板索引(project: %s, id: %s)\n", proj.Name, proj.ID))
|
||||
used := len([]rune(b.String()))
|
||||
omitted := 0
|
||||
|
||||
for _, f := range facts {
|
||||
line := fmt.Sprintf("- [%s] %s — %s (%s)\n", f.FactKey, f.Category, strings.TrimSpace(f.Summary), f.Confidence)
|
||||
indexedKeys[f.FactKey] = struct{}{}
|
||||
line := fmt.Sprintf("- [%s] %s — %s (%s)", f.FactKey, f.Category, strings.TrimSpace(f.Summary), f.Confidence)
|
||||
line += FormatFactIndexLinksHint(f.FactKey, incomingByTarget[f.FactKey])
|
||||
line += "\n"
|
||||
lineRunes := len([]rune(line))
|
||||
if used+lineRunes > maxRunes {
|
||||
if used+lineRunes > factsBudget {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
@@ -72,7 +88,12 @@ func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectCo
|
||||
if omitted > 0 {
|
||||
b.WriteString(fmt.Sprintf("\n(另有 %d 条未列入索引,请使用 list_project_facts 或 search_project_facts 查询。)\n", omitted))
|
||||
}
|
||||
b.WriteString("需要完整内容(攻击链、POC、请求响应等)时必须调用 get_project_fact(fact_key),禁止凭摘要臆造细节。\n")
|
||||
b.WriteString("写入事实时:summary 写「什么+在哪+如何验证」;body 写可复现全流程(发现/利用类 fact_key 建议 finding|chain|exploit|poc/ 前缀)。\n")
|
||||
return b.String(), nil
|
||||
|
||||
if pathSection := BuildFactPathOverviewSection(allEdges, indexedKeys, pathMaxRunes); pathSection != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(pathSection)
|
||||
}
|
||||
|
||||
b.WriteString(footer)
|
||||
return wrapFactIndexBlock(b.String()), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package project
|
||||
|
||||
import "strings"
|
||||
|
||||
// FactIndexSectionHeading 黑板索引可读标题行前缀(块内保留,供 Agent 阅读)。
|
||||
const FactIndexSectionHeading = "## 项目黑板索引"
|
||||
|
||||
// FactIndexSectionStartMarker / EndMarker:HTML 注释边界,供程序化替换;对模型无指令语义。
|
||||
const (
|
||||
FactIndexSectionStartMarker = "<!-- fact-index-start -->"
|
||||
FactIndexSectionEndMarker = "<!-- fact-index-end -->"
|
||||
)
|
||||
|
||||
// ReplaceFactIndexSection 用 freshIndex 替换 content 中已有的项目黑板索引段。
|
||||
// freshIndex 须为 BuildFactIndexBlock 的完整输出。起止 HTML 注释缺失时返回 (_, false)。
|
||||
func ReplaceFactIndexSection(content, freshIndex string) (string, bool) {
|
||||
freshIndex = strings.TrimSpace(freshIndex)
|
||||
if freshIndex == "" {
|
||||
return content, false
|
||||
}
|
||||
start, ok := factIndexSectionStart(content)
|
||||
if !ok {
|
||||
return content, false
|
||||
}
|
||||
end, ok := factIndexSectionEnd(content, start)
|
||||
if !ok || end <= start {
|
||||
return content, false
|
||||
}
|
||||
return content[:start] + freshIndex + content[end:], true
|
||||
}
|
||||
|
||||
// wrapFactIndexBlock 为 BuildFactIndexBlock 正文加上统一起止 HTML 注释边界。
|
||||
func wrapFactIndexBlock(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
return FactIndexSectionStartMarker + "\n" + content + "\n" + FactIndexSectionEndMarker + "\n"
|
||||
}
|
||||
|
||||
func factIndexSectionStart(content string) (int, bool) {
|
||||
idx := strings.Index(content, FactIndexSectionStartMarker)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return idx, true
|
||||
}
|
||||
|
||||
func factIndexSectionEnd(content string, start int) (int, bool) {
|
||||
if start < 0 || start >= len(content) {
|
||||
return 0, false
|
||||
}
|
||||
tail := content[start:]
|
||||
idx := strings.LastIndex(tail, FactIndexSectionEndMarker)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return start + idx + len(FactIndexSectionEndMarker), true
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func sampleFactIndexWithFacts(projectLabel, summary string) string {
|
||||
return wrapFactIndexBlock("## 项目黑板索引(project: " + projectLabel + ", id: x)\n" +
|
||||
"- [target/a] target — " + summary + " (tentative)\n" +
|
||||
factIndexFooterGetDetail + "\n" +
|
||||
factIndexFooterWriteHint)
|
||||
}
|
||||
|
||||
func TestReplaceFactIndexSection(t *testing.T) {
|
||||
t.Parallel()
|
||||
oldIndex := sampleFactIndexWithFacts("p1", "old summary")
|
||||
newIndex := sampleFactIndexWithFacts("p1", "new summary")
|
||||
|
||||
t.Run("replaces index before next section", func(t *testing.T) {
|
||||
content := "你是助手\n\n" + oldIndex + "\n\n## 图片分析\n看截图"
|
||||
out, ok := ReplaceFactIndexSection(content, newIndex)
|
||||
if !ok {
|
||||
t.Fatal("expected replacement")
|
||||
}
|
||||
if strings.Contains(out, "old summary") {
|
||||
t.Fatalf("old index should be gone: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "new summary") || !strings.Contains(out, "## 图片分析") {
|
||||
t.Fatalf("expected new index and preserved vision section: %q", out)
|
||||
}
|
||||
if strings.Count(out, FactIndexSectionStartMarker) != 1 || strings.Count(out, FactIndexSectionEndMarker) != 1 {
|
||||
t.Fatalf("expected exactly one start/end marker pair: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces index at end", func(t *testing.T) {
|
||||
content := "## 项目测试范围\nscope\n\n" + oldIndex
|
||||
out, ok := ReplaceFactIndexSection(content, newIndex)
|
||||
if !ok {
|
||||
t.Fatal("expected replacement")
|
||||
}
|
||||
if !strings.Contains(out, "## 项目测试范围") || !strings.Contains(out, "new summary") {
|
||||
t.Fatalf("scope preserved, index updated: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("summary with false markdown header does not truncate early", func(t *testing.T) {
|
||||
summaryWithFakeHeader := "see\n\n## fake header in summary"
|
||||
old := sampleFactIndexWithFacts("p1", summaryWithFakeHeader)
|
||||
newIdx := sampleFactIndexWithFacts("p1", "new summary")
|
||||
content := old + "\n\n## 图片分析\nvision"
|
||||
out, ok := ReplaceFactIndexSection(content, newIdx)
|
||||
if !ok {
|
||||
t.Fatal("expected replacement")
|
||||
}
|
||||
if strings.Contains(out, "fake header in summary") {
|
||||
t.Fatalf("old index tail should be fully removed: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("summary containing end marker text does not truncate early", func(t *testing.T) {
|
||||
summary := "note " + FactIndexSectionEndMarker + " in summary"
|
||||
old := sampleFactIndexWithFacts("p1", summary)
|
||||
newIdx := sampleFactIndexWithFacts("p1", "clean")
|
||||
content := old + "\n\n## 图片分析\nvision"
|
||||
out, ok := ReplaceFactIndexSection(content, newIdx)
|
||||
if !ok {
|
||||
t.Fatal("expected replacement")
|
||||
}
|
||||
if strings.Contains(out, "in summary") {
|
||||
t.Fatalf("old block should be fully removed: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing html markers does not replace", func(t *testing.T) {
|
||||
legacy := "## 项目黑板索引(project: p1, id: x)\n- [a] note — old (tentative)\n"
|
||||
newIdx := sampleFactIndexWithFacts("p1", "new")
|
||||
out, ok := ReplaceFactIndexSection("prefix\n\n"+legacy, newIdx)
|
||||
if ok {
|
||||
t.Fatalf("expected no replacement without markers: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty facts block", func(t *testing.T) {
|
||||
oldEmpty := wrapFactIndexBlock("## 项目黑板索引(project: p1, id: x)\n(暂无事实)\n" + factIndexFooterEmpty)
|
||||
newEmpty := sampleFactIndexWithFacts("p1", "first fact")
|
||||
out, ok := ReplaceFactIndexSection(oldEmpty, newEmpty)
|
||||
if !ok {
|
||||
t.Fatal("expected replacement")
|
||||
}
|
||||
if strings.Contains(out, "(暂无事实)") {
|
||||
t.Fatalf("old empty block should be gone: %q", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no marker", func(t *testing.T) {
|
||||
_, ok := ReplaceFactIndexSection("no blackboard here", newIndex)
|
||||
if ok {
|
||||
t.Fatal("expected false when marker missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty fresh index", func(t *testing.T) {
|
||||
_, ok := ReplaceFactIndexSection(oldIndex, " ")
|
||||
if ok {
|
||||
t.Fatal("expected false for empty fresh index")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFactIndexSectionBounds_useHTMLMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := sampleFactIndexWithFacts("p", "line with\n\n## not a real section") + "TAIL_SHOULD_DROP"
|
||||
start, ok := factIndexSectionStart(body)
|
||||
if !ok || !strings.HasPrefix(body[start:], FactIndexSectionStartMarker) {
|
||||
t.Fatalf("start should be at html start marker, got %d", start)
|
||||
}
|
||||
end, ok := factIndexSectionEnd(body, start)
|
||||
if !ok || body[end:] != "\nTAIL_SHOULD_DROP" {
|
||||
t.Fatalf("end should be after end marker, got remainder %q", body[end:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFactIndexBlock_includesHTMLMarkers(t *testing.T) {
|
||||
t.Parallel()
|
||||
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
proj, err := db.CreateProject(&database.Project{Name: "marker-proj"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, err := BuildFactIndexBlock(db, proj.ID, config.ProjectConfig{Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(block), FactIndexSectionStartMarker) {
|
||||
t.Fatalf("block should start with start marker: %q", block)
|
||||
}
|
||||
if !strings.Contains(block, FactIndexSectionEndMarker) {
|
||||
t.Fatalf("block should include end marker: %q", block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
)
|
||||
|
||||
var (
|
||||
bodyDepFactLine = regexp.MustCompile(`(?im)^[\s\-*]*依赖事实\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
||||
bodyRelFactLine = regexp.MustCompile(`(?im)^[\s\-*]*相关\s*fact_key\s*[::]\s*([a-z0-9][a-z0-9._/-]*)`)
|
||||
bodyAssocSection = regexp.MustCompile(`(?im)^##\s*关联\s*$`)
|
||||
bodySyncLinksHead = "结构化关系边(自动同步)"
|
||||
)
|
||||
|
||||
// ParseLinksFromBody 从 body「关联」段落解析 from 语义的关系边(无显式 links 时的兜底)。
|
||||
func ParseLinksFromBody(body string) []database.ProjectFactEdgeFromInput {
|
||||
body = strings.TrimSpace(body)
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
var out []database.ProjectFactEdgeFromInput
|
||||
add := func(key, edgeType string) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if err := database.ValidateFactKey(key); err != nil {
|
||||
return
|
||||
}
|
||||
sig := edgeType + "\x00" + key
|
||||
if _, ok := seen[sig]; ok {
|
||||
return
|
||||
}
|
||||
seen[sig] = struct{}{}
|
||||
out = append(out, database.ProjectFactEdgeFromInput{From: key, Type: edgeType})
|
||||
}
|
||||
for _, m := range bodyDepFactLine.FindAllStringSubmatch(body, -1) {
|
||||
if len(m) > 1 {
|
||||
add(m[1], "depends_on")
|
||||
}
|
||||
}
|
||||
for _, m := range bodyRelFactLine.FindAllStringSubmatch(body, -1) {
|
||||
if len(m) > 1 {
|
||||
add(m[1], "supports")
|
||||
}
|
||||
}
|
||||
// 自动同步块:type: key
|
||||
syncBlock := extractBodySyncLinksBlock(body)
|
||||
for _, line := range strings.Split(syncBlock, "\n") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-"))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
edgeType, source, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
edgeType = strings.TrimSpace(edgeType)
|
||||
source = strings.TrimSpace(source)
|
||||
if err := database.ValidateProjectFactEdgeType(edgeType); err != nil {
|
||||
continue
|
||||
}
|
||||
add(source, edgeType)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractBodySyncLinksBlock(body string) string {
|
||||
lines := strings.Split(body, "\n")
|
||||
var b strings.Builder
|
||||
inAssoc := false
|
||||
inSync := false
|
||||
for _, line := range lines {
|
||||
trim := strings.TrimSpace(line)
|
||||
if bodyAssocSection.MatchString(trim) {
|
||||
inAssoc = true
|
||||
inSync = false
|
||||
continue
|
||||
}
|
||||
if inAssoc && strings.HasPrefix(trim, "## ") && !strings.HasPrefix(trim, "## 关联") {
|
||||
break
|
||||
}
|
||||
if inAssoc && strings.Contains(trim, bodySyncLinksHead) {
|
||||
inSync = true
|
||||
continue
|
||||
}
|
||||
if inSync {
|
||||
if trim == "" || strings.HasPrefix(trim, "-") || strings.Contains(trim, ":") {
|
||||
if strings.HasPrefix(trim, "-") || (strings.Contains(trim, ":") && !strings.Contains(trim, "related_vulnerability")) {
|
||||
b.WriteString(trim)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
} else if strings.HasPrefix(trim, "##") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SyncBodyLinksSection 将入边镜像写入 body 的「关联」段(人读用;结构化以 links 为准)。
|
||||
func SyncBodyLinksSection(body string, edges []*database.ProjectFactEdge) string {
|
||||
body = strings.TrimSpace(body)
|
||||
block := formatBodySyncLinksBlock(edges)
|
||||
if block == "" {
|
||||
return body
|
||||
}
|
||||
if body == "" {
|
||||
return "## 关联\n" + block
|
||||
}
|
||||
lines := strings.Split(body, "\n")
|
||||
var out []string
|
||||
inAssoc := false
|
||||
replaced := false
|
||||
for i := 0; i < len(lines); i++ {
|
||||
trim := strings.TrimSpace(lines[i])
|
||||
if bodyAssocSection.MatchString(trim) {
|
||||
inAssoc = true
|
||||
out = append(out, lines[i])
|
||||
// 跳过旧同步块
|
||||
j := i + 1
|
||||
for j < len(lines) {
|
||||
t := strings.TrimSpace(lines[j])
|
||||
if strings.HasPrefix(t, "## ") {
|
||||
break
|
||||
}
|
||||
if strings.Contains(t, bodySyncLinksHead) {
|
||||
for j < len(lines) {
|
||||
t2 := strings.TrimSpace(lines[j])
|
||||
if t2 != "" && !strings.HasPrefix(t2, "-") && !strings.Contains(t2, ":") && !strings.Contains(t2, bodySyncLinksHead) {
|
||||
if strings.HasPrefix(t2, "##") {
|
||||
break
|
||||
}
|
||||
}
|
||||
j++
|
||||
if j < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[j]), "## ") {
|
||||
break
|
||||
}
|
||||
if j >= len(lines) {
|
||||
break
|
||||
}
|
||||
if j > i+1 && strings.TrimSpace(lines[j-1]) == "" && strings.HasPrefix(strings.TrimSpace(lines[j]), "## ") {
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
j++
|
||||
}
|
||||
out = append(out, block)
|
||||
i = j - 1
|
||||
replaced = true
|
||||
continue
|
||||
}
|
||||
out = append(out, lines[i])
|
||||
}
|
||||
if !replaced {
|
||||
if !inAssoc {
|
||||
out = append(out, "", "## 关联", block)
|
||||
} else {
|
||||
out = append(out, block)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
func formatBodySyncLinksBlock(edges []*database.ProjectFactEdge) string {
|
||||
if len(edges) == 0 {
|
||||
return fmt.Sprintf("- %s:\n (暂无)", bodySyncLinksHead)
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("- ")
|
||||
b.WriteString(bodySyncLinksHead)
|
||||
b.WriteString(":\n")
|
||||
for _, e := range edges {
|
||||
b.WriteString(fmt.Sprintf(" - %s: %s\n", e.EdgeType, e.SourceFactKey))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// ResolveFactLinksForUpsert 合并显式 links、links_text 与 body 解析结果。
|
||||
func ResolveFactLinksForUpsert(explicit []database.ProjectFactEdgeFromInput, linksText *string, body string, explicitSet bool) ([]database.ProjectFactEdgeFromInput, bool, error) {
|
||||
if explicitSet {
|
||||
if len(explicit) > 0 {
|
||||
return explicit, true, nil
|
||||
}
|
||||
if linksText != nil {
|
||||
parsed, err := ParseFactLinksText(*linksText)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if parsed == nil {
|
||||
return []database.ProjectFactEdgeFromInput{}, true, nil
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
return []database.ProjectFactEdgeFromInput{}, true, nil
|
||||
}
|
||||
if parsed := ParseLinksFromBody(body); len(parsed) > 0 {
|
||||
return parsed, true, nil
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// MergeLinkFromInputsUnique 合并多组 from 入边输入并去重。
|
||||
func MergeLinkFromInputsUnique(groups ...[]database.ProjectFactEdgeFromInput) []database.ProjectFactEdgeFromInput {
|
||||
seen := map[string]struct{}{}
|
||||
var out []database.ProjectFactEdgeFromInput
|
||||
for _, g := range groups {
|
||||
for _, in := range g {
|
||||
sig := in.Type + "\x00" + in.From
|
||||
if _, ok := seen[sig]; ok {
|
||||
continue
|
||||
}
|
||||
if err := database.ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := database.ValidateFactKey(in.From); err != nil {
|
||||
continue
|
||||
}
|
||||
seen[sig] = struct{}{}
|
||||
out = append(out, in)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MergeLinkInputsUnique 合并多组 link 输入并去重(内部出边写入用)。
|
||||
func MergeLinkInputsUnique(groups ...[]database.ProjectFactEdgeInput) []database.ProjectFactEdgeInput {
|
||||
seen := map[string]struct{}{}
|
||||
var out []database.ProjectFactEdgeInput
|
||||
for _, g := range groups {
|
||||
for _, in := range g {
|
||||
sig := in.Type + "\x00" + in.To
|
||||
if _, ok := seen[sig]; ok {
|
||||
continue
|
||||
}
|
||||
if err := database.ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := database.ValidateFactKey(in.To); err != nil {
|
||||
continue
|
||||
}
|
||||
seen[sig] = struct{}{}
|
||||
out = append(out, in)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestParseLinksFromBodyDependsOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := "## 关联\n- 依赖事实: target/api\n- 相关 fact_key: auth/session"
|
||||
links := ParseLinksFromBody(body)
|
||||
if len(links) != 2 {
|
||||
t.Fatalf("want 2 links, got %d", len(links))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncBodyLinksSection(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := "## 结论\nx\n\n## 关联\n- 依赖事实: old/key"
|
||||
edges := []*database.ProjectFactEdge{{EdgeType: "discovered_on", SourceFactKey: "target/a"}}
|
||||
out := SyncBodyLinksSection(body, edges)
|
||||
if !strings.Contains(out, "discovered_on: target/a") {
|
||||
t.Fatalf("missing synced edge: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactGraphIntegration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p, err := db.CreateProject(&database.Project{Name: "g"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range []struct{ key, cat, summary string }{
|
||||
{"target/root", "target", "root"},
|
||||
{"finding/x", "finding", "finding x"},
|
||||
} {
|
||||
_, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: p.ID, FactKey: spec.key, Category: spec.cat, Summary: spec.summary, Confidence: "confirmed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "finding/x", []database.ProjectFactEdgeFromInput{
|
||||
{From: "target/root", Type: "discovered_on"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
graph, err := BuildProjectFactGraph(db, p.ID, "path", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(graph.Nodes) < 2 || len(graph.Edges) < 1 {
|
||||
t.Fatalf("expected graph nodes/edges, got %d/%d", len(graph.Nodes), len(graph.Edges))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/projectprompt"
|
||||
)
|
||||
|
||||
// PathGraphCategories 攻击路径视图包含的事实分类。
|
||||
var PathGraphCategories = map[string]struct{}{
|
||||
FactCategoryTarget: {},
|
||||
FactCategoryFinding: {},
|
||||
FactCategoryChain: {},
|
||||
FactCategoryExploit: {},
|
||||
FactCategoryPOC: {},
|
||||
"vuln": {},
|
||||
}
|
||||
|
||||
// GraphNodeType 将 fact category 映射为图节点类型(供前端样式与 ELK 分层)。
|
||||
func GraphNodeType(category, factKey string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(factKey))
|
||||
switch {
|
||||
case strings.HasPrefix(key, "target/"):
|
||||
return "target"
|
||||
case strings.HasPrefix(key, "exploit/"), strings.HasPrefix(key, "evidence/"):
|
||||
return "exploit"
|
||||
case strings.HasPrefix(key, "poc/"):
|
||||
return "poc"
|
||||
case strings.HasPrefix(key, "chain/"):
|
||||
return "chain"
|
||||
case strings.HasPrefix(key, "finding/"):
|
||||
return "finding"
|
||||
case strings.HasPrefix(key, "auth/"):
|
||||
return "auth"
|
||||
case strings.HasPrefix(key, "infra/"), strings.HasPrefix(key, "business/"):
|
||||
return "infra"
|
||||
case strings.HasPrefix(key, "vuln:"):
|
||||
return "vulnerability"
|
||||
}
|
||||
c := strings.ToLower(strings.TrimSpace(category))
|
||||
switch c {
|
||||
case FactCategoryTarget:
|
||||
return "target"
|
||||
case FactCategoryExploit:
|
||||
return "exploit"
|
||||
case FactCategoryPOC:
|
||||
return "poc"
|
||||
case FactCategoryChain:
|
||||
return "chain"
|
||||
case FactCategoryFinding, "vuln":
|
||||
return "finding"
|
||||
case "auth":
|
||||
return "auth"
|
||||
case "infra", "business":
|
||||
return "infra"
|
||||
default:
|
||||
return "note"
|
||||
}
|
||||
}
|
||||
|
||||
func truncateGraphLabel(summary string, maxRunes int) string {
|
||||
summary = strings.TrimSpace(summary)
|
||||
if summary == "" {
|
||||
return "—"
|
||||
}
|
||||
r := []rune(summary)
|
||||
if len(r) <= maxRunes {
|
||||
return summary
|
||||
}
|
||||
return string(r[:maxRunes]) + "…"
|
||||
}
|
||||
|
||||
// BuildProjectFactGraph 构建项目事实图(nodes + edges)。
|
||||
func BuildProjectFactGraph(db *database.DB, projectID string, view string, excludeDeprecated bool) (*database.ProjectFactGraph, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database 未初始化")
|
||||
}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf("project_id 不能为空")
|
||||
}
|
||||
|
||||
view = strings.TrimSpace(strings.ToLower(view))
|
||||
if view == "" {
|
||||
view = "path"
|
||||
}
|
||||
|
||||
filter := database.ProjectFactListFilter{}
|
||||
if excludeDeprecated {
|
||||
filter.ExcludeDeprecated = true
|
||||
}
|
||||
facts, err := db.ListProjectFacts(projectID, filter, 1000, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
edges, err := db.ListProjectFactEdgesByProject(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if excludeDeprecated {
|
||||
edges = filterDeprecatedEdges(edges)
|
||||
}
|
||||
|
||||
factByKey := make(map[string]*database.ProjectFact, len(facts))
|
||||
for _, f := range facts {
|
||||
factByKey[f.FactKey] = f
|
||||
}
|
||||
|
||||
pathMode := view == "path"
|
||||
nodeKeys := make(map[string]struct{})
|
||||
|
||||
if pathMode {
|
||||
for _, f := range facts {
|
||||
if isPathGraphFact(f.Category, f.FactKey) {
|
||||
nodeKeys[f.FactKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
// 路径视图中保留作为依赖目标的 auth/infra 节点
|
||||
for _, e := range edges {
|
||||
if _, ok := nodeKeys[e.SourceFactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
if f, ok := factByKey[e.TargetFactKey]; ok && isDependencyGraphFact(f.Category, f.FactKey) {
|
||||
nodeKeys[e.TargetFactKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, f := range facts {
|
||||
nodeKeys[f.FactKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// 边上引用的 endpoint 纳入节点集
|
||||
for _, e := range edges {
|
||||
if pathMode {
|
||||
if _, ok := nodeKeys[e.SourceFactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := nodeKeys[e.TargetFactKey]; ok {
|
||||
// already included
|
||||
} else if f, ok := factByKey[e.TargetFactKey]; !ok {
|
||||
nodeKeys[e.TargetFactKey] = struct{}{} // 占位节点
|
||||
} else if isPathGraphFact(f.Category, f.FactKey) || isDependencyGraphFact(f.Category, f.FactKey) {
|
||||
nodeKeys[e.TargetFactKey] = struct{}{}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
nodeKeys[e.SourceFactKey] = struct{}{}
|
||||
nodeKeys[e.TargetFactKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
nodes := make([]database.ProjectFactGraphNode, 0, len(nodeKeys))
|
||||
for key := range nodeKeys {
|
||||
if f, ok := factByKey[key]; ok {
|
||||
nodes = append(nodes, database.ProjectFactGraphNode{
|
||||
ID: f.FactKey,
|
||||
FactKey: f.FactKey,
|
||||
Category: f.Category,
|
||||
Label: truncateGraphLabel(f.Summary, 48),
|
||||
Summary: strings.TrimSpace(f.Summary),
|
||||
Confidence: f.Confidence,
|
||||
Type: GraphNodeType(f.Category, f.FactKey),
|
||||
Pinned: f.Pinned,
|
||||
})
|
||||
continue
|
||||
}
|
||||
nodes = append(nodes, database.ProjectFactGraphNode{
|
||||
ID: key,
|
||||
FactKey: key,
|
||||
Category: "missing",
|
||||
Label: key,
|
||||
Confidence: "tentative",
|
||||
Type: "missing",
|
||||
Pinned: false,
|
||||
})
|
||||
}
|
||||
|
||||
graphEdges := make([]database.ProjectFactGraphEdge, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
if pathMode {
|
||||
if _, ok := nodeKeys[e.SourceFactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := nodeKeys[e.TargetFactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if _, ok := nodeKeys[e.SourceFactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := nodeKeys[e.TargetFactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
graphEdges = append(graphEdges, database.ProjectFactGraphEdge{
|
||||
ID: e.ID,
|
||||
Source: e.SourceFactKey,
|
||||
Target: e.TargetFactKey,
|
||||
Type: e.EdgeType,
|
||||
Confidence: e.Confidence,
|
||||
})
|
||||
}
|
||||
|
||||
// related_vulnerability_id 合成边(source=fact → target=vuln:<id>)
|
||||
for _, f := range facts {
|
||||
if _, ok := nodeKeys[f.FactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
vid := strings.TrimSpace(f.RelatedVulnerabilityID)
|
||||
if vid == "" {
|
||||
continue
|
||||
}
|
||||
vulnNodeID := "vuln:" + vid
|
||||
if _, exists := nodeKeys[vulnNodeID]; !exists {
|
||||
nodeKeys[vulnNodeID] = struct{}{}
|
||||
label := "漏洞"
|
||||
if len(vid) >= 8 {
|
||||
label += " " + vid[:8] + "…"
|
||||
} else {
|
||||
label += " " + vid
|
||||
}
|
||||
nodes = append(nodes, database.ProjectFactGraphNode{
|
||||
ID: vulnNodeID,
|
||||
FactKey: vulnNodeID,
|
||||
Category: "vuln",
|
||||
Label: label,
|
||||
Confidence: f.Confidence,
|
||||
Type: "vulnerability",
|
||||
Pinned: false,
|
||||
})
|
||||
}
|
||||
graphEdges = append(graphEdges, database.ProjectFactGraphEdge{
|
||||
ID: "vuln-link:" + f.FactKey + ":" + vid,
|
||||
Source: f.FactKey,
|
||||
Target: vulnNodeID,
|
||||
Type: "links_vuln",
|
||||
Confidence: f.Confidence,
|
||||
})
|
||||
}
|
||||
|
||||
return &database.ProjectFactGraph{Nodes: nodes, Edges: graphEdges}, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func isPathGraphFact(category, factKey string) bool {
|
||||
c := strings.ToLower(strings.TrimSpace(category))
|
||||
if _, ok := PathGraphCategories[c]; ok {
|
||||
return true
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(factKey))
|
||||
for _, p := range []string{"target/", "finding/", "chain/", "exploit/", "poc/", "evidence/"} {
|
||||
if strings.HasPrefix(key, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isDependencyGraphFact(category, factKey string) bool {
|
||||
c := strings.ToLower(strings.TrimSpace(category))
|
||||
if c == "auth" || c == "infra" || c == "business" {
|
||||
return true
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(factKey))
|
||||
return strings.HasPrefix(key, "auth/") || strings.HasPrefix(key, "infra/") || strings.HasPrefix(key, "business/")
|
||||
}
|
||||
|
||||
func filterDeprecatedEdges(edges []*database.ProjectFactEdge) []*database.ProjectFactEdge {
|
||||
out := make([]*database.ProjectFactEdge, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
if strings.EqualFold(strings.TrimSpace(e.Confidence), "deprecated") {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParsedFactLinks 解析 links 参数(from → 当前 fact)。
|
||||
type ParsedFactLinks struct {
|
||||
Incoming []database.ProjectFactEdgeFromInput
|
||||
}
|
||||
|
||||
// ParseFactLinkInputs 从 MCP links 参数解析;空数组表示清空全部入边。
|
||||
func ParseFactLinkInputs(raw interface{}) (*ParsedFactLinks, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("links 须为数组")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return &ParsedFactLinks{
|
||||
Incoming: []database.ProjectFactEdgeFromInput{},
|
||||
}, nil
|
||||
}
|
||||
parsed := &ParsedFactLinks{}
|
||||
for i, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("links[%d] 格式无效", i)
|
||||
}
|
||||
from, _ := m["from"].(string)
|
||||
edgeType, _ := m["type"].(string)
|
||||
from = strings.TrimSpace(from)
|
||||
edgeType = strings.TrimSpace(edgeType)
|
||||
if from == "" {
|
||||
return nil, fmt.Errorf("links[%d] 须含 from", i)
|
||||
}
|
||||
if edgeType == "" {
|
||||
return nil, fmt.Errorf("links[%d] 须含 type", i)
|
||||
}
|
||||
conf, _ := m["confidence"].(string)
|
||||
parsed.Incoming = append(parsed.Incoming, database.ProjectFactEdgeFromInput{
|
||||
From: from, Type: edgeType, Confidence: strings.TrimSpace(conf),
|
||||
})
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// ParseFactLinksText 解析 UI 文本:`type: source_fact_key` 每行一条(from 语义)。
|
||||
func ParseFactLinksText(text string) ([]database.ProjectFactEdgeFromInput, error) {
|
||||
return ParseFactIncomingLinksText(text)
|
||||
}
|
||||
|
||||
// FormatFactLinksText 将入边格式化为 UI 文本。
|
||||
func FormatFactLinksText(edges []*database.ProjectFactEdge) string {
|
||||
return FormatFactIncomingLinksText(edges)
|
||||
}
|
||||
|
||||
// ParseFactIncomingLinksText 解析 UI 入边文本:`type: source_fact_key` 每行一条。
|
||||
func ParseFactIncomingLinksText(text string) ([]database.ProjectFactEdgeFromInput, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var out []database.ProjectFactEdgeFromInput
|
||||
for i, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
edgeType, source, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("第 %d 行格式无效,应为 type: fact_key", i+1)
|
||||
}
|
||||
edgeType = strings.TrimSpace(edgeType)
|
||||
source = strings.TrimSpace(source)
|
||||
if edgeType == "" || source == "" {
|
||||
return nil, fmt.Errorf("第 %d 行 type 或 fact_key 为空", i+1)
|
||||
}
|
||||
out = append(out, database.ProjectFactEdgeFromInput{From: source, Type: edgeType})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormatFactIncomingLinksText 将入边格式化为 UI 文本。
|
||||
func FormatFactIncomingLinksText(edges []*database.ProjectFactEdge) string {
|
||||
if len(edges) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, e := range edges {
|
||||
if i > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(e.EdgeType)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(e.SourceFactKey)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FactEdgeRecordingGuidance 写入边时的 Agent 规范。
|
||||
func FactEdgeRecordingGuidance() string {
|
||||
return projectprompt.FactEdgeRecordingGuidance()
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"cyberstrike-ai/internal/database"
|
||||
)
|
||||
|
||||
// ApplyFactOutgoingLinks 替换某事实的出边(links 为 nil 时不修改)。
|
||||
func ApplyFactOutgoingLinks(db *database.DB, projectID, sourceFactKey, sourceConversationID string, links []database.ProjectFactEdgeInput) error {
|
||||
if links == nil {
|
||||
return nil
|
||||
}
|
||||
return db.ReplaceOutgoingProjectFactEdges(projectID, sourceFactKey, sourceConversationID, links)
|
||||
}
|
||||
|
||||
// ResolveFactLinkInputs 合并 links 数组与 links_text 文本(数组优先)。
|
||||
func ResolveFactLinkInputs(links []database.ProjectFactEdgeFromInput, linksText string) ([]database.ProjectFactEdgeFromInput, error) {
|
||||
if len(links) > 0 {
|
||||
return links, nil
|
||||
}
|
||||
return ParseFactLinksText(linksText)
|
||||
}
|
||||
|
||||
// ApplyFactIncomingLinks 替换某事实的入边(links 为 nil 时不修改)。
|
||||
func ApplyFactIncomingLinks(db *database.DB, projectID, targetFactKey string, links []database.ProjectFactEdgeFromInput) error {
|
||||
if links == nil {
|
||||
return nil
|
||||
}
|
||||
return db.ReplaceIncomingProjectFactEdges(projectID, targetFactKey, links)
|
||||
}
|
||||
|
||||
// PersistFactIncomingLinks 写入入边并可选同步当前事实 body「关联」段。
|
||||
func PersistFactIncomingLinks(db *database.DB, projectID, targetFactKey string, links []database.ProjectFactEdgeFromInput, syncBody bool) error {
|
||||
if links == nil {
|
||||
return nil
|
||||
}
|
||||
if err := ApplyFactIncomingLinks(db, projectID, targetFactKey, links); err != nil {
|
||||
return err
|
||||
}
|
||||
if !syncBody {
|
||||
return nil
|
||||
}
|
||||
f, err := db.GetProjectFactByKey(projectID, targetFactKey)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
in, err := db.ListIncomingProjectFactEdges(projectID, targetFactKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Body = SyncBodyLinksSection(f.Body, in)
|
||||
_, err = db.UpsertProjectFact(f)
|
||||
return err
|
||||
}
|
||||
|
||||
// PersistFactLinksFromParsed 写入解析后的 links(parsed 为 nil 表示不修改)。
|
||||
func PersistFactLinksFromParsed(db *database.DB, projectID, factKey, sourceConversationID string, parsed *ParsedFactLinks, syncBody bool) error {
|
||||
if parsed == nil || parsed.Incoming == nil {
|
||||
return nil
|
||||
}
|
||||
return PersistFactIncomingLinks(db, projectID, factKey, parsed.Incoming, syncBody)
|
||||
}
|
||||
|
||||
// PersistFactOutgoingLinks 写入出边(图连线等低层 API;body 同步请用 PersistFactIncomingLinks)。
|
||||
func PersistFactOutgoingLinks(db *database.DB, projectID, sourceFactKey, sourceConversationID string, links []database.ProjectFactEdgeInput, syncBody bool) error {
|
||||
if links == nil {
|
||||
return nil
|
||||
}
|
||||
return ApplyFactOutgoingLinks(db, projectID, sourceFactKey, sourceConversationID, links)
|
||||
}
|
||||
|
||||
// LinkCountMap 项目内各 fact 的入/出边计数。
|
||||
type LinkCountMap map[string]LinkCounts
|
||||
|
||||
// LinkCounts 单 fact 的入/出边数。
|
||||
type LinkCounts struct {
|
||||
Outgoing int `json:"outgoing"`
|
||||
Incoming int `json:"incoming"`
|
||||
}
|
||||
|
||||
// LoadProjectFactLinkCounts 批量加载边计数。
|
||||
func LoadProjectFactLinkCounts(db *database.DB, projectID string) (LinkCountMap, error) {
|
||||
edges, err := db.ListProjectFactEdgesByProject(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := LinkCountMap{}
|
||||
for _, e := range edges {
|
||||
c := m[e.SourceFactKey]
|
||||
c.Outgoing++
|
||||
m[e.SourceFactKey] = c
|
||||
c = m[e.TargetFactKey]
|
||||
c.Incoming++
|
||||
m[e.TargetFactKey] = c
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestParseFactLinksText(t *testing.T) {
|
||||
t.Parallel()
|
||||
inputs, err := ParseFactLinksText("discovered_on: target/api\nleads_to: finding/swagger")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inputs) != 2 {
|
||||
t.Fatalf("want 2 links, got %d", len(inputs))
|
||||
}
|
||||
if inputs[0].Type != "discovered_on" || inputs[0].From != "target/api" {
|
||||
t.Fatalf("unexpected first link: %+v", inputs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFactIncomingLinksText(t *testing.T) {
|
||||
t.Parallel()
|
||||
inputs, err := ParseFactIncomingLinksText("leads_to: finding/swagger\ndepends_on: target/api")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(inputs) != 2 {
|
||||
t.Fatalf("want 2 links, got %d", len(inputs))
|
||||
}
|
||||
if inputs[0].Type != "leads_to" || inputs[0].From != "finding/swagger" {
|
||||
t.Fatalf("unexpected first link: %+v", inputs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFactIncomingLinksText(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := FormatFactIncomingLinksText([]*database.ProjectFactEdge{
|
||||
{EdgeType: "leads_to", SourceFactKey: "finding/a"},
|
||||
{EdgeType: "depends_on", SourceFactKey: "target/b"},
|
||||
})
|
||||
want := "leads_to: finding/a\ndepends_on: target/b"
|
||||
if text != want {
|
||||
t.Fatalf("got %q want %q", text, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFactLinkInputsEmptyClears(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed, err := ParseFactLinkInputs([]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed == nil || parsed.Incoming == nil || len(parsed.Incoming) != 0 {
|
||||
t.Fatalf("empty array should clear incoming links, got %v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFactLinkInputsFrom(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := []interface{}{
|
||||
map[string]interface{}{
|
||||
"from": "target/primary_domain",
|
||||
"type": "discovered_on",
|
||||
},
|
||||
}
|
||||
parsed, err := ParseFactLinkInputs(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(parsed.Incoming) != 1 || parsed.Incoming[0].From != "target/primary_domain" {
|
||||
t.Fatalf("unexpected incoming: %+v", parsed.Incoming)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFactLinkInputsRequiresFrom(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := []interface{}{
|
||||
map[string]interface{}{
|
||||
"to": "target/primary_domain",
|
||||
"type": "discovered_on",
|
||||
},
|
||||
}
|
||||
_, err := ParseFactLinkInputs(raw)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when from is missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphNodeType(t *testing.T) {
|
||||
t.Parallel()
|
||||
if GraphNodeType("chain", "chain/x") != "chain" {
|
||||
t.Fatal("chain category")
|
||||
}
|
||||
if GraphNodeType("finding", "finding/x") != "finding" {
|
||||
t.Fatal("finding category")
|
||||
}
|
||||
if GraphNodeType("exploit", "exploit/x") != "exploit" {
|
||||
t.Fatal("exploit category")
|
||||
}
|
||||
if GraphNodeType("finding", "evidence/x") != "exploit" {
|
||||
t.Fatal("evidence prefix")
|
||||
}
|
||||
if GraphNodeType("note", "target/x") != "target" {
|
||||
t.Fatal("target prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProjectFactGraphPreservesStoredEdgeDirection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(dir, "test.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p, err := db.CreateProject(&database.Project{Name: "path-edges"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range []struct{ key, cat string }{
|
||||
{"target/primary_domain", "target"},
|
||||
{"chain/full_attack_path", "chain"},
|
||||
{"finding/mysql_public", "finding"},
|
||||
{"exploit/mysql_creds_extract", "exploit"},
|
||||
} {
|
||||
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: p.ID, FactKey: spec.key, Category: spec.cat, Summary: spec.key, Confidence: "confirmed",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "finding/mysql_public", []database.ProjectFactEdgeFromInput{
|
||||
{From: "target/primary_domain", Type: "discovered_on"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "finding/mysql_public", []database.ProjectFactEdgeFromInput{
|
||||
{From: "target/primary_domain", Type: "discovered_on"},
|
||||
{From: "exploit/mysql_creds_extract", Type: "exploits"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "chain/full_attack_path", []database.ProjectFactEdgeFromInput{
|
||||
{From: "target/primary_domain", Type: "discovered_on"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "exploit/mysql_creds_extract", []database.ProjectFactEdgeFromInput{
|
||||
{From: "chain/full_attack_path", Type: "leads_to"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
graph, err := BuildProjectFactGraph(db, p.ID, "path", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]struct{}{
|
||||
"target/primary_domain|discovered_on|finding/mysql_public": {},
|
||||
"exploit/mysql_creds_extract|exploits|finding/mysql_public": {},
|
||||
"target/primary_domain|discovered_on|chain/full_attack_path": {},
|
||||
"chain/full_attack_path|leads_to|exploit/mysql_creds_extract": {},
|
||||
}
|
||||
for _, e := range graph.Edges {
|
||||
key := e.Source + "|" + e.Type + "|" + e.Target
|
||||
delete(want, key)
|
||||
}
|
||||
if len(want) > 0 {
|
||||
t.Fatalf("missing expected stored-direction edges: %v", want)
|
||||
}
|
||||
countInOut := func(factKey string) (out, in int) {
|
||||
for _, e := range graph.Edges {
|
||||
if e.Source == factKey {
|
||||
out++
|
||||
}
|
||||
if e.Target == factKey {
|
||||
in++
|
||||
}
|
||||
}
|
||||
return out, in
|
||||
}
|
||||
if out, in := countInOut("chain/full_attack_path"); out != 1 || in != 1 {
|
||||
t.Fatalf("chain/full_attack_path want out=1 in=1 got out=%d in=%d", out, in)
|
||||
}
|
||||
if out, in := countInOut("exploit/mysql_creds_extract"); out != 1 || in != 1 {
|
||||
t.Fatalf("exploit/mysql_creds_extract want out=1 in=1 got out=%d in=%d", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistFactLinksFromUsesFromAsIncoming(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(dir, "test.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p, err := db.CreateProject(&database.Project{Name: "from-links"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, spec := range []struct{ key, cat string }{
|
||||
{"target/primary_domain", "target"},
|
||||
{"finding/sqli", "finding"},
|
||||
} {
|
||||
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: p.ID, FactKey: spec.key, Category: spec.cat, Summary: spec.key, Confidence: "confirmed",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
parsed := &ParsedFactLinks{
|
||||
Incoming: []database.ProjectFactEdgeFromInput{
|
||||
{From: "target/primary_domain", Type: "discovered_on"},
|
||||
},
|
||||
}
|
||||
if err := PersistFactLinksFromParsed(db, p.ID, "finding/sqli", "", parsed, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
graph, err := BuildProjectFactGraph(db, p.ID, "path", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "target/primary_domain|discovered_on|finding/sqli"
|
||||
for _, e := range graph.Edges {
|
||||
key := e.Source + "|" + e.Type + "|" + e.Target
|
||||
if key == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("expected edge %s, got %+v", want, graph.Edges)
|
||||
}
|
||||
|
||||
func TestFormatOutgoingLinksHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
hint := FormatOutgoingLinksHint([]*database.ProjectFactEdge{
|
||||
{EdgeType: "discovered_on", TargetFactKey: "target/a"},
|
||||
})
|
||||
if hint == "" || hint[0] != ' ' {
|
||||
t.Fatalf("unexpected hint: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceIncomingAllowsNotYetCreatedSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(dir, "test.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p, err := db.CreateProject(&database.Project{Name: "parallel-links"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: p.ID, FactKey: "exploit/sqli", Category: "exploit", Summary: "exploit", Confidence: "confirmed",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.ReplaceIncomingProjectFactEdges(p.ID, "exploit/sqli", []database.ProjectFactEdgeFromInput{
|
||||
{From: "finding/sqli_endpoint", Type: "exploits"},
|
||||
}); err != nil {
|
||||
t.Fatalf("incoming edge should not require source fact to exist yet: %v", err)
|
||||
}
|
||||
if _, err := db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: p.ID, FactKey: "finding/sqli_endpoint", Category: "finding", Summary: "finding", Confidence: "confirmed",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, err := db.ListIncomingProjectFactEdges(p.ID, "exploit/sqli")
|
||||
if err != nil || len(in) != 1 || in[0].SourceFactKey != "finding/sqli_endpoint" {
|
||||
t.Fatalf("expected persisted edge from finding, got %+v err=%v", in, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProjectFactEdgeType(t *testing.T) {
|
||||
t.Parallel()
|
||||
if err := database.ValidateProjectFactEdgeType("leads_to"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.ValidateProjectFactEdgeType("invalid"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
)
|
||||
|
||||
var factIndexEdgeTypeOrder = []string{
|
||||
"discovered_on", "leads_to", "enables", "depends_on", "exploits", "contains", "part_of", "supports",
|
||||
}
|
||||
|
||||
func filterIndexEdges(edges []*database.ProjectFactEdge) []*database.ProjectFactEdge {
|
||||
if len(edges) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]*database.ProjectFactEdge, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(e.Confidence), "deprecated") {
|
||||
continue
|
||||
}
|
||||
edgeType := strings.ToLower(strings.TrimSpace(e.EdgeType))
|
||||
if _, ok := database.ValidProjectFactEdgeTypes[edgeType]; !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func edgeConfidenceSuffix(confidence string) string {
|
||||
c := strings.ToLower(strings.TrimSpace(confidence))
|
||||
if c == "" || c == "confirmed" {
|
||||
return ""
|
||||
}
|
||||
return " (" + c + ")"
|
||||
}
|
||||
|
||||
func formatRelationHintPart(e *database.ProjectFactEdge) string {
|
||||
return fmt.Sprintf("%s←%s%s", e.EdgeType, e.SourceFactKey, edgeConfidenceSuffix(e.Confidence))
|
||||
}
|
||||
|
||||
func formatOutgoingHintPart(e *database.ProjectFactEdge) string {
|
||||
return fmt.Sprintf("%s→%s%s", e.EdgeType, e.TargetFactKey, edgeConfidenceSuffix(e.Confidence))
|
||||
}
|
||||
|
||||
func formatIncomingHintPart(e *database.ProjectFactEdge) string {
|
||||
return formatRelationHintPart(e)
|
||||
}
|
||||
|
||||
func joinEdgeHintParts(edges []*database.ProjectFactEdge, formatter func(*database.ProjectFactEdge) string) string {
|
||||
parts := make([]string, 0, len(edges))
|
||||
for _, e := range edges {
|
||||
parts = append(parts, formatter(e))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// FormatOutgoingLinksHint 黑板索引用出边摘要(全部有效边类型,不截断)。
|
||||
func FormatOutgoingLinksHint(edges []*database.ProjectFactEdge) string {
|
||||
edges = filterIndexEdges(edges)
|
||||
if len(edges) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " {出边: " + joinEdgeHintParts(edges, formatOutgoingHintPart) + "}"
|
||||
}
|
||||
|
||||
// FormatIncomingLinksHint 黑板索引用入边摘要(全部有效边类型,不截断)。
|
||||
func FormatIncomingLinksHint(edges []*database.ProjectFactEdge) string {
|
||||
edges = filterIndexEdges(edges)
|
||||
if len(edges) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " {入边: " + joinEdgeHintParts(edges, formatIncomingHintPart) + "}"
|
||||
}
|
||||
|
||||
// FormatFactIndexLinksHint 黑板索引行内关系边(from → 当前 fact,与 upsert links 一致)。
|
||||
func FormatFactIndexLinksHint(_ string, incoming []*database.ProjectFactEdge) string {
|
||||
in := filterIndexEdges(incoming)
|
||||
if len(in) == 0 {
|
||||
return ""
|
||||
}
|
||||
return " {关系边: " + joinEdgeHintParts(in, formatRelationHintPart) + "}"
|
||||
}
|
||||
|
||||
func indexEdgeGroupMaps(edges []*database.ProjectFactEdge) (outgoing, incoming map[string][]*database.ProjectFactEdge) {
|
||||
outgoing = map[string][]*database.ProjectFactEdge{}
|
||||
incoming = map[string][]*database.ProjectFactEdge{}
|
||||
for _, e := range filterIndexEdges(edges) {
|
||||
outgoing[e.SourceFactKey] = append(outgoing[e.SourceFactKey], e)
|
||||
incoming[e.TargetFactKey] = append(incoming[e.TargetFactKey], e)
|
||||
}
|
||||
return outgoing, incoming
|
||||
}
|
||||
|
||||
func relationOverviewLine(e *database.ProjectFactEdge) string {
|
||||
return fmt.Sprintf("- %s → %s%s · %s", e.SourceFactKey, e.TargetFactKey, edgeConfidenceSuffix(e.Confidence), e.EdgeType)
|
||||
}
|
||||
|
||||
func indexEdgeSortKey(e *database.ProjectFactEdge) (int, int, string) {
|
||||
confRank := 0
|
||||
if strings.EqualFold(strings.TrimSpace(e.Confidence), "tentative") {
|
||||
confRank = 1
|
||||
}
|
||||
typeRank := len(factIndexEdgeTypeOrder) + 1
|
||||
for i, t := range factIndexEdgeTypeOrder {
|
||||
if strings.EqualFold(e.EdgeType, t) {
|
||||
typeRank = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return confRank, typeRank, e.SourceFactKey + ">" + e.TargetFactKey + ">" + e.EdgeType
|
||||
}
|
||||
|
||||
func sortIndexOverviewEdges(edges []*database.ProjectFactEdge) {
|
||||
sort.SliceStable(edges, func(i, j int) bool {
|
||||
ci, ti, ki := indexEdgeSortKey(edges[i])
|
||||
cj, tj, kj := indexEdgeSortKey(edges[j])
|
||||
if ci != cj {
|
||||
return ci < cj
|
||||
}
|
||||
if ti != tj {
|
||||
return ti < tj
|
||||
}
|
||||
return ki < kj
|
||||
})
|
||||
}
|
||||
|
||||
// BuildFactPathOverviewSection 生成事实关系速览(全部有效边类型,不含 body)。
|
||||
func BuildFactPathOverviewSection(edges []*database.ProjectFactEdge, indexedKeys map[string]struct{}, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
candidates := filterIndexEdges(edges)
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
filtered := make([]*database.ProjectFactEdge, 0, len(candidates))
|
||||
for _, e := range candidates {
|
||||
if len(indexedKeys) > 0 {
|
||||
if _, ok := indexedKeys[e.SourceFactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := indexedKeys[e.TargetFactKey]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return ""
|
||||
}
|
||||
sortIndexOverviewEdges(filtered)
|
||||
|
||||
header := "### 攻击路径(事实关系)\n"
|
||||
header += "source → target · type(与攻击路径图/库中方向一致;写入时在目标 fact 的 links 用 from 声明来源)\n"
|
||||
var b strings.Builder
|
||||
b.WriteString(header)
|
||||
used := len([]rune(header))
|
||||
omitted := 0
|
||||
|
||||
for _, e := range filtered {
|
||||
line := relationOverviewLine(e) + "\n"
|
||||
lineRunes := len([]rune(line))
|
||||
if used+lineRunes > maxRunes {
|
||||
omitted++
|
||||
continue
|
||||
}
|
||||
b.WriteString(line)
|
||||
used += lineRunes
|
||||
}
|
||||
if omitted > 0 {
|
||||
extra := fmt.Sprintf("(另有 %d 条关系边未列入,请 get_project_fact 查看完整关系。)\n", omitted)
|
||||
if used+len([]rune(extra)) <= maxRunes {
|
||||
b.WriteString(extra)
|
||||
}
|
||||
}
|
||||
if used <= len([]rune(header)) {
|
||||
return ""
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func factIndexSortPriority(f *database.ProjectFact) int {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
score := 0
|
||||
if f.Pinned {
|
||||
score += 1000
|
||||
}
|
||||
c := strings.ToLower(strings.TrimSpace(f.Category))
|
||||
switch c {
|
||||
case FactCategoryTarget:
|
||||
score += 400
|
||||
case FactCategoryFinding, FactCategoryChain:
|
||||
score += 300
|
||||
case FactCategoryExploit, FactCategoryPOC:
|
||||
score += 250
|
||||
case "auth", "infra", "business":
|
||||
score += 200
|
||||
case "note":
|
||||
score += 50
|
||||
default:
|
||||
key := strings.ToLower(strings.TrimSpace(f.FactKey))
|
||||
if strings.HasPrefix(key, "target/") {
|
||||
score += 400
|
||||
} else if strings.HasPrefix(key, "finding/") || strings.HasPrefix(key, "chain/") {
|
||||
score += 300
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(f.Confidence), "confirmed") {
|
||||
score += 80
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func sortFactsForIndex(facts []*database.ProjectFact) {
|
||||
sort.SliceStable(facts, func(i, j int) bool {
|
||||
pi, pj := factIndexSortPriority(facts[i]), factIndexSortPriority(facts[j])
|
||||
if pi != pj {
|
||||
return pi > pj
|
||||
}
|
||||
return facts[i].UpdatedAt.After(facts[j].UpdatedAt)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestFormatIncomingLinksHint(t *testing.T) {
|
||||
t.Parallel()
|
||||
hint := FormatIncomingLinksHint([]*database.ProjectFactEdge{
|
||||
{EdgeType: "discovered_on", SourceFactKey: "finding/x", Confidence: "tentative"},
|
||||
})
|
||||
if !strings.Contains(hint, "入边:") {
|
||||
t.Fatalf("expected 入边 label: %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "discovered_on←finding/x") {
|
||||
t.Fatalf("unexpected hint: %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "tentative") {
|
||||
t.Fatalf("expected tentative in hint: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatIncomingLinksHint_allEdges(t *testing.T) {
|
||||
t.Parallel()
|
||||
edges := make([]*database.ProjectFactEdge, 0, 5)
|
||||
for i := 1; i <= 5; i++ {
|
||||
edges = append(edges, &database.ProjectFactEdge{
|
||||
EdgeType: "discovered_on",
|
||||
SourceFactKey: fmt.Sprintf("finding/f%d", i),
|
||||
Confidence: "tentative",
|
||||
})
|
||||
}
|
||||
hint := FormatIncomingLinksHint(edges)
|
||||
if strings.Contains(hint, "+") {
|
||||
t.Fatalf("should not truncate with +N: %q", hint)
|
||||
}
|
||||
for i := 1; i <= 5; i++ {
|
||||
if !strings.Contains(hint, fmt.Sprintf("finding/f%d", i)) {
|
||||
t.Fatalf("missing edge f%d in hint: %q", i, hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFactIndexLinksHint_incomingOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := []*database.ProjectFactEdge{
|
||||
{EdgeType: "discovered_on", SourceFactKey: "target/dev", Confidence: "tentative"},
|
||||
{EdgeType: "exploits", SourceFactKey: "exploit/rce", Confidence: "confirmed"},
|
||||
}
|
||||
hint := FormatFactIndexLinksHint("finding/sqli", in)
|
||||
if !strings.Contains(hint, "关系边:") {
|
||||
t.Fatalf("missing 关系边 label: %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "discovered_on←target/dev") {
|
||||
t.Fatalf("missing discovered_on: %q", hint)
|
||||
}
|
||||
if !strings.Contains(hint, "exploits←exploit/rce") {
|
||||
t.Fatalf("missing exploits: %q", hint)
|
||||
}
|
||||
if strings.Contains(hint, "出边") || strings.Contains(hint, "入边") {
|
||||
t.Fatalf("should not use legacy 出边/入边 labels: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatFactIndexLinksHint_includesAuxiliaryEdgeTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := []*database.ProjectFactEdge{{EdgeType: "supports", SourceFactKey: "note/log"}}
|
||||
hint := FormatFactIndexLinksHint("finding/x", in)
|
||||
if !strings.Contains(hint, "supports←note/log") {
|
||||
t.Fatalf("supports edge should be included: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFactPathOverviewSection(t *testing.T) {
|
||||
t.Parallel()
|
||||
edges := []*database.ProjectFactEdge{
|
||||
{EdgeType: "discovered_on", SourceFactKey: "target/dev", TargetFactKey: "finding/sqli", Confidence: "tentative"},
|
||||
{EdgeType: "exploits", SourceFactKey: "exploit/rce", TargetFactKey: "finding/sqli", Confidence: "confirmed"},
|
||||
{EdgeType: "supports", SourceFactKey: "note/log", TargetFactKey: "finding/sqli"},
|
||||
}
|
||||
keys := map[string]struct{}{
|
||||
"target/dev": {}, "finding/sqli": {}, "exploit/rce": {}, "note/log": {},
|
||||
}
|
||||
section := BuildFactPathOverviewSection(edges, keys, 800)
|
||||
if !strings.Contains(section, "### 攻击路径(事实关系)") {
|
||||
t.Fatalf("missing header: %q", section)
|
||||
}
|
||||
if !strings.Contains(section, "target/dev → finding/sqli") {
|
||||
t.Fatalf("missing discovered_on line: %q", section)
|
||||
}
|
||||
if !strings.Contains(section, "exploit/rce → finding/sqli") {
|
||||
t.Fatalf("missing exploits line: %q", section)
|
||||
}
|
||||
if !strings.Contains(section, "note/log → finding/sqli") {
|
||||
t.Fatalf("supports edge should be included: %q", section)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFactIndexBlock_withLinksAndPathOverview(t *testing.T) {
|
||||
t.Parallel()
|
||||
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
||||
db, err := database.NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
proj, err := db.CreateProject(&database.Project{Name: "path-proj"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "target/dev",
|
||||
Category: "target",
|
||||
Summary: "dev 子域",
|
||||
Confidence: "confirmed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.UpsertProjectFact(&database.ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "finding/sqli",
|
||||
Category: "finding",
|
||||
Summary: "时间盲注",
|
||||
Confidence: "tentative",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.AddProjectFactEdge(proj.ID, database.ProjectFactEdgeInput{
|
||||
To: "finding/sqli",
|
||||
Type: "discovered_on",
|
||||
}, "target/dev", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
block, err := BuildFactIndexBlock(db, proj.ID, config.ProjectConfig{Enabled: true, FactIndexMaxRunes: 6500, FactIndexPathMaxRunes: 1000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(block, "关系边: discovered_on←target/dev") {
|
||||
t.Fatalf("finding line should include relation hint: %q", block)
|
||||
}
|
||||
if !strings.Contains(block, "### 攻击路径(事实关系)") {
|
||||
t.Fatalf("missing relation overview: %q", block)
|
||||
}
|
||||
if !strings.Contains(block, "target/dev → finding/sqli") {
|
||||
t.Fatalf("missing overview edge: %q", block)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,23 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"strings"
|
||||
import "cyberstrike-ai/internal/projectprompt"
|
||||
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
)
|
||||
|
||||
// 边渗透边记录:统一节奏文案(agents/*.md 须与 FactRecordingIncrementalRhythmMarkdown 保持一致)。
|
||||
const (
|
||||
factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。"
|
||||
factRhythmCoordinatorSuffix = "委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。"
|
||||
factRhythmSubAgentSuffix = "若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。"
|
||||
)
|
||||
|
||||
// FactRecordingIncrementalRhythmMarkdown 返回边渗透边记录节奏(Markdown,供 agents/*.md 与文档对齐)。
|
||||
// FactRecordingIncrementalRhythmMarkdown 见 projectprompt。
|
||||
func FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("- **边渗透边记录(强制节奏)**:")
|
||||
b.WriteString(factRhythmCore)
|
||||
if coordinator {
|
||||
b.WriteString(factRhythmCoordinatorSuffix)
|
||||
}
|
||||
if subAgent {
|
||||
b.WriteString(factRhythmSubAgentSuffix)
|
||||
}
|
||||
return b.String()
|
||||
return projectprompt.FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent)
|
||||
}
|
||||
|
||||
func factRecordingIncrementalRhythmBuiltin(coordinator, subAgent bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 ")
|
||||
b.WriteString(builtin.ToolUpsertProjectFact)
|
||||
b.WriteString("(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 ")
|
||||
b.WriteString(builtin.ToolRecordVulnerability)
|
||||
b.WriteString(";与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。")
|
||||
if coordinator {
|
||||
b.WriteString(factRhythmCoordinatorSuffix)
|
||||
}
|
||||
if subAgent {
|
||||
b.WriteString(factRhythmSubAgentSuffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FactRecordingBlackboardSection 项目黑板与漏洞记录的完整系统提示块(单/多 Agent 主代理共用)。
|
||||
// coordinatorDelegate 为 true 时追加「协调者代子代理落库」说明(Deep / plan_execute / supervisor)。
|
||||
// FactRecordingBlackboardSection 见 projectprompt。
|
||||
func FactRecordingBlackboardSection(coordinatorDelegate bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
||||
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 fact_key + 摘要)。**摘要不足时必须调用 ")
|
||||
b.WriteString(builtin.ToolGetProjectFact)
|
||||
b.WriteString("(fact_key) 获取 body,禁止凭摘要臆造细节。**\n\n")
|
||||
b.WriteString(factRecordingIncrementalRhythmBuiltin(coordinatorDelegate, false))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞条目):使用 ")
|
||||
b.WriteString(builtin.ToolUpsertProjectFact)
|
||||
b.WriteString(",fact_key 建议 `category/slug`(如 target/primary_domain),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
||||
b.WriteString("- **发现与利用上下文**(审计复现):fact_key 建议 finding/、chain/、exploit/、poc/ 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 related_vulnerability_id),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
||||
b.WriteString("- **可交付漏洞**:使用 ")
|
||||
b.WriteString(builtin.ToolRecordVulnerability)
|
||||
b.WriteString(",含标题、严重程度、类型、目标、证明(POC)、影响、修复建议。记前可先 ")
|
||||
b.WriteString(builtin.ToolListVulnerabilities)
|
||||
b.WriteString(" 查重,详情用 ")
|
||||
b.WriteString(builtin.ToolGetVulnerability)
|
||||
b.WriteString("(id)(默认仅当前项目/会话)。\n")
|
||||
b.WriteString("- 同一发现可能需**各记一次**(事实记**完整攻击链与 exploit 细节**供复现,漏洞记正式 findings)。误报用 ")
|
||||
b.WriteString(builtin.ToolDeprecateProjectFact)
|
||||
b.WriteString(" 或漏洞状态 false_positive。\n")
|
||||
b.WriteString("- 事实多时用 ")
|
||||
b.WriteString(builtin.ToolListProjectFacts)
|
||||
b.WriteString(" / ")
|
||||
b.WriteString(builtin.ToolSearchProjectFacts)
|
||||
b.WriteString(" 检索。\n\n")
|
||||
b.WriteString(FactRecordingGuidanceBlock())
|
||||
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
||||
return b.String()
|
||||
return projectprompt.FactRecordingBlackboardSection(coordinatorDelegate)
|
||||
}
|
||||
|
||||
// FactRecordingSubAgentSection 子代理边渗透边记录(无工具时输出待落库条目)。
|
||||
// FactRecordingSubAgentSection 见 projectprompt。
|
||||
func FactRecordingSubAgentSection() string {
|
||||
return "## 边渗透边记录\n\n" + factRecordingIncrementalRhythmBuiltin(false, true) + "\n"
|
||||
return projectprompt.FactRecordingSubAgentSection()
|
||||
}
|
||||
|
||||
// FactRecordingBlackboardSectionMarkdown 与 FactRecordingBlackboardSection 等价的 Markdown(工具名为字面量,供 agents/*.md)。
|
||||
// FactRecordingBlackboardSectionMarkdown 见 projectprompt。
|
||||
func FactRecordingBlackboardSectionMarkdown(coordinatorDelegate bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
||||
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**\n\n")
|
||||
b.WriteString(FactRecordingIncrementalRhythmMarkdown(coordinatorDelegate, false))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
||||
b.WriteString("- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
||||
b.WriteString("- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。\n")
|
||||
b.WriteString("- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。\n")
|
||||
b.WriteString("- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。\n\n")
|
||||
b.WriteString(FactRecordingGuidanceBlock())
|
||||
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
||||
return b.String()
|
||||
return projectprompt.FactRecordingBlackboardSectionMarkdown(coordinatorDelegate)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package project
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/projectprompt"
|
||||
)
|
||||
|
||||
// 事实 category 常量(写入 upsert_project_fact 的 category 字段)。
|
||||
@@ -90,7 +92,8 @@ const attackChainFactBodyTemplate = `## 结论(可验证,一句话)
|
||||
|
||||
## 关联
|
||||
- related_vulnerability_id: <可选,对应 record_vulnerability 的 id>
|
||||
- 依赖事实: <fact_key,如 auth/session_cookie>
|
||||
- links(upsert 参数): [{ "from": "<fact_key>", "type": "discovered_on|..." }](from → 当前 fact)
|
||||
- 依赖事实(body 可读镜像): <fact_key,如 auth/session_cookie>
|
||||
|
||||
## 备注与不确定性
|
||||
<待验证假设、环境差异、绕过尝试记录>`
|
||||
@@ -109,15 +112,7 @@ const envFactBodyTemplate = `## 摘要
|
||||
|
||||
// FactRecordingGuidanceBlock 写入系统提示:要求事实沉淀攻击链上下文而非仅结论。
|
||||
func FactRecordingGuidanceBlock() string {
|
||||
return `### 事实写入规范(审计复现 / 知识沉淀)
|
||||
|
||||
- **summary**:索引用一行,须含「什么 + 在哪 + 如何触发/验证」要点,禁止只写结论(如仅写「存在 SQLi」)。
|
||||
- **body**:完整可复现上下文,写入 ` + "`upsert_project_fact`" + ` 的 body 字段;索引不含 body,后续会话须靠 ` + "`get_project_fact`" + ` 取回。
|
||||
- **category / fact_key 建议**:
|
||||
- 环境认知:` + "`target/`" + `、` + "`auth/`" + `、` + "`infra/`" + `、` + "`business/`" + `(body 用环境模板即可)
|
||||
- 发现与利用:` + "`finding/`" + `、` + "`chain/`" + `、` + "`exploit/`" + `、` + "`poc/`" + `(**必须**用攻击链模板填满 body:入口、逐步攻击链、原始请求/响应或命令、证据、关联漏洞 ID)
|
||||
- **与漏洞记录分工**:` + "`record_vulnerability`" + ` 记可交付 findings;事实记**复现所需的全部上下文**(含失败尝试、绕过、依赖会话),二者可各记一次。
|
||||
- 更新同一发现时保持相同 ` + "`fact_key`" + ` 覆盖写入,勿散落多个 key 导致上下文丢失。`
|
||||
return projectprompt.FactRecordingGuidanceBlock()
|
||||
}
|
||||
|
||||
// SparseBodyWarning 攻击链类事实 body 不足时的工具返回提示(不阻断保存)。
|
||||
|
||||
@@ -2,10 +2,14 @@ package project
|
||||
|
||||
import "strings"
|
||||
|
||||
// VisionImageSectionMarker 图片分析 section 标题(与 AppendVisionImageAnalysisIfReady 注入一致)。
|
||||
const VisionImageSectionMarker = "## 图片分析"
|
||||
|
||||
// VisionImageAnalysisSection 单/多代理共用的图片分析提示(analyze_image;上下文仅保留文字摘要)。
|
||||
func VisionImageAnalysisSection() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 图片分析\n\n")
|
||||
b.WriteString(VisionImageSectionMarker)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("- 遇到图片文件(截图、验证码、登录页、报告配图)时,若存在工具 analyze_image,请传入服务器上的文件路径进行分析。\n")
|
||||
b.WriteString("- 不要对二进制图片使用 read_file 指望理解内容;用户消息中「📎 xxx.png: /path」即为可传给 analyze_image 的路径。\n")
|
||||
b.WriteString("- 验证码类:若已从页面或接口保存为本地图片(如 captcha.png),用 analyze_image,question 写明「只输出验证码字符」;识别失败则刷新验证码后重新保存再识;复杂滑块/行为验证码勿指望单次识图成功。\n")
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package projectprompt 提供项目黑板相关的系统提示文本(纯字符串,无 database 依赖)。
|
||||
// 供 agent / multiagent 等包引用,避免 agent → project 导入环导致 gopls 元数据失败。
|
||||
package projectprompt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
)
|
||||
|
||||
const (
|
||||
factRhythmCore = "勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 `upsert_project_fact`(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 `record_vulnerability`;与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。"
|
||||
factRhythmCoordinatorSuffix = "委派/子任务返回新认知或漏洞时,由协调者及时写入,勿假定子代理已记。"
|
||||
factRhythmSubAgentSuffix = "若工具集中无上述工具,须在交付物末尾给出「待落库」结构化条目(fact_key 建议、summary、body/POC 要点),供协调者**立即**写入。"
|
||||
)
|
||||
|
||||
// FactRecordingIncrementalRhythmMarkdown 返回边渗透边记录节奏(Markdown,供 agents/*.md 与文档对齐)。
|
||||
func FactRecordingIncrementalRhythmMarkdown(coordinator, subAgent bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("- **边渗透边记录(强制节奏)**:")
|
||||
b.WriteString(factRhythmCore)
|
||||
if coordinator {
|
||||
b.WriteString(factRhythmCoordinatorSuffix)
|
||||
}
|
||||
if subAgent {
|
||||
b.WriteString(factRhythmSubAgentSuffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func factRecordingIncrementalRhythmBuiltin(coordinator, subAgent bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("- **边渗透边记录(强制节奏)**:勿等会话结束或收尾再批量写入。每**确认**一条新认知(开放端口/服务版本、入口路径、认证态或凭据特征、可利用点或攻击面变化)后,**立即**调用 ")
|
||||
b.WriteString(builtin.ToolUpsertProjectFact)
|
||||
b.WriteString("(同 fact_key 覆盖更新)。每**验证**出一条可复现漏洞(含 POC/影响)后,**立即**调用 ")
|
||||
b.WriteString(builtin.ToolRecordVulnerability)
|
||||
b.WriteString(";与事实可各记一次。继续下一步工作前优先落库,避免上下文压缩后细节丢失。未绑项目时说明无法写黑板,仍在本轮保留证据摘要。")
|
||||
if coordinator {
|
||||
b.WriteString(factRhythmCoordinatorSuffix)
|
||||
}
|
||||
if subAgent {
|
||||
b.WriteString(factRhythmSubAgentSuffix)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func factEdgeRecordingGuidance() string {
|
||||
return `### 事实关系边(links)
|
||||
|
||||
- 写入 **finding / chain / exploit / poc** 时,**必须**在 ` + "`upsert_project_fact`" + ` 中提供 ` + "`links`" + `(**推荐 ` + "`from`" + `**:来源 fact 指向当前 fact,即 ` + "`from`" + ` → 当前 ` + "`fact_key`" + `)。
|
||||
- **最少要求**:finding 类至少 1 条 from=target/* + type=discovered_on(即 target → finding);在 finding 上记录 exploit 用 from=exploit/* + type=exploits(即 exploit → finding)。
|
||||
- **常用 type**:` + "`discovered_on`" + `(发现在哪)、` + "`depends_on`" + `(复现前置)、` + "`leads_to`" + `(认知推进)、` + "`enables`" + `(扩大攻击面)、` + "`exploits`" + `(利用关系)、` + "`contains`" + `(资产包含)、` + "`part_of`" + `(属于链/组)、` + "`supports`" + `(证据支撑)。
|
||||
- 更新时:**省略 links 保留已有边**;传入 links 则**替换**全部关系边(from → 当前 fact)。
|
||||
- body 中「依赖事实」段落可与 links 并存(人读);结构化关系以 links 为准。`
|
||||
}
|
||||
|
||||
func factRecordingGuidanceBlock() string {
|
||||
return `### 事实写入规范(审计复现 / 知识沉淀)
|
||||
|
||||
- **summary**:索引用一行,须含「什么 + 在哪 + 如何触发/验证」要点,禁止只写结论(如仅写「存在 SQLi」)。
|
||||
- **body**:完整可复现上下文,写入 ` + "`upsert_project_fact`" + ` 的 body 字段;索引不含 body,后续会话须靠 ` + "`get_project_fact`" + ` 取回。
|
||||
- **category / fact_key 建议**:
|
||||
- 环境认知:` + "`target/`" + `、` + "`auth/`" + `、` + "`infra/`" + `、` + "`business/`" + `(body 用环境模板即可)
|
||||
- 发现与利用:` + "`finding/`" + `、` + "`chain/`" + `、` + "`exploit/`" + `、` + "`poc/`" + `(**必须**用攻击链模板填满 body:入口、逐步攻击链、原始请求/响应或命令、证据、关联漏洞 ID)
|
||||
- **与漏洞记录分工**:` + "`record_vulnerability`" + ` 记可交付 findings;事实记**复现所需的全部上下文**(含失败尝试、绕过、依赖会话),二者可各记一次。
|
||||
- 更新同一发现时保持相同 ` + "`fact_key`" + ` 覆盖写入,勿散落多个 key 导致上下文丢失。`
|
||||
}
|
||||
|
||||
// FactRecordingBlackboardSection 项目黑板与漏洞记录的完整系统提示块(单/多 Agent 主代理共用)。
|
||||
func FactRecordingBlackboardSection(coordinatorDelegate bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
||||
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 fact_key + 摘要)。**摘要不足时必须调用 ")
|
||||
b.WriteString(builtin.ToolGetProjectFact)
|
||||
b.WriteString("(fact_key) 获取 body,禁止凭摘要臆造细节。**\n\n")
|
||||
b.WriteString(factRecordingIncrementalRhythmBuiltin(coordinatorDelegate, false))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞条目):使用 ")
|
||||
b.WriteString(builtin.ToolUpsertProjectFact)
|
||||
b.WriteString(",fact_key 建议 `category/slug`(如 target/primary_domain),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
||||
b.WriteString("- **发现与利用上下文**(审计复现):fact_key 建议 finding/、chain/、exploit/、poc/ 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 related_vulnerability_id),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
||||
b.WriteString("- **可交付漏洞**:使用 ")
|
||||
b.WriteString(builtin.ToolRecordVulnerability)
|
||||
b.WriteString(",含标题、严重程度、类型、目标、证明(POC)、影响、修复建议。记前可先 ")
|
||||
b.WriteString(builtin.ToolListVulnerabilities)
|
||||
b.WriteString(" 查重,详情用 ")
|
||||
b.WriteString(builtin.ToolGetVulnerability)
|
||||
b.WriteString("(id)(默认仅当前项目/会话)。\n")
|
||||
b.WriteString("- 同一发现可能需**各记一次**(事实记**完整攻击链与 exploit 细节**供复现,漏洞记正式 findings)。误报用 ")
|
||||
b.WriteString(builtin.ToolDeprecateProjectFact)
|
||||
b.WriteString(" 或漏洞状态 false_positive。\n")
|
||||
b.WriteString("- 事实多时用 ")
|
||||
b.WriteString(builtin.ToolListProjectFacts)
|
||||
b.WriteString(" / ")
|
||||
b.WriteString(builtin.ToolSearchProjectFacts)
|
||||
b.WriteString(" 检索。\n\n")
|
||||
b.WriteString(factEdgeRecordingGuidance())
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(factRecordingGuidanceBlock())
|
||||
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FactRecordingSubAgentSection 子代理边渗透边记录(无工具时输出待落库条目)。
|
||||
func FactRecordingSubAgentSection() string {
|
||||
return "## 边渗透边记录\n\n" + factRecordingIncrementalRhythmBuiltin(false, true) + "\n"
|
||||
}
|
||||
|
||||
// FactRecordingBlackboardSectionMarkdown 与 FactRecordingBlackboardSection 等价的 Markdown(工具名为字面量,供 agents/*.md)。
|
||||
func FactRecordingBlackboardSectionMarkdown(coordinatorDelegate bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 项目黑板(事实)与漏洞记录(分离)\n\n")
|
||||
b.WriteString("当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**\n\n")
|
||||
b.WriteString(FactRecordingIncrementalRhythmMarkdown(coordinatorDelegate, false))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**,`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新;body 记端口/版本/凭据特征与证据来源。\n")
|
||||
b.WriteString("- **发现与利用上下文**(审计复现):`fact_key` 建议 `finding/`、`chain/`、`exploit/`、`poc/` 前缀;**body 必填**完整攻击链(入口 → 步骤 → 原始请求/响应或命令 → 现象 → 关联 `related_vulnerability_id`),**禁止仅写结论**;summary 写「什么 + 在哪 + 如何验证」一行要点。\n")
|
||||
b.WriteString("- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。\n")
|
||||
b.WriteString("- 同一发现可能需**各记一次**(事实记可复现攻击链,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。\n")
|
||||
b.WriteString("- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。\n\n")
|
||||
b.WriteString(factEdgeRecordingGuidance())
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(factRecordingGuidanceBlock())
|
||||
b.WriteString("\n\n严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FactEdgeRecordingGuidance 写入边时的 Agent 规范(供 project 包复用)。
|
||||
func FactEdgeRecordingGuidance() string { return factEdgeRecordingGuidance() }
|
||||
|
||||
// FactRecordingGuidanceBlock 事实写入规范块(供 project 包复用)。
|
||||
func FactRecordingGuidanceBlock() string { return factRecordingGuidanceBlock() }
|
||||
+46
-16
@@ -84,8 +84,9 @@ func ApplyToEinoChatModelConfig(cfg *einoopenai.ChatModelConfig, oa *config.Open
|
||||
}
|
||||
}
|
||||
|
||||
// applyClaudeExtendedThinking sets Anthropic Messages API `thinking` when absent from ExtraRequestFields.
|
||||
// Uses adaptive + summarized display by default (per Anthropic guidance for Claude 4.x); Sonnet 3.7 uses enabled+budget.
|
||||
// applyClaudeExtendedThinking sets Anthropic Messages API fields per official guidance:
|
||||
// - Adaptive models (4.6+): thinking.type=adaptive; output_config.effort only when user sets effort (API default is high).
|
||||
// - Sonnet 3.7: thinking.type=enabled + budget_tokens=10000 (doc example); effort is not mapped — use extra_request_fields for custom budget.
|
||||
func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort, model string) {
|
||||
if cfg == nil || mode == "off" {
|
||||
return
|
||||
@@ -93,31 +94,60 @@ func applyClaudeExtendedThinking(cfg *einoopenai.ChatModelConfig, mode, effort,
|
||||
if cfg.ExtraFields == nil {
|
||||
cfg.ExtraFields = make(map[string]any)
|
||||
}
|
||||
if _, exists := cfg.ExtraFields["thinking"]; exists {
|
||||
return
|
||||
}
|
||||
m := strings.ToLower(strings.TrimSpace(model))
|
||||
thinking := map[string]any{
|
||||
"type": "adaptive",
|
||||
"display": "summarized",
|
||||
sonnet37 := isClaudeSonnet37(m)
|
||||
|
||||
if _, exists := cfg.ExtraFields["thinking"]; !exists {
|
||||
cfg.ExtraFields["thinking"] = claudeThinkingForModel(m, sonnet37)
|
||||
}
|
||||
// Sonnet 3.7: manual extended thinking is the documented path.
|
||||
if strings.Contains(m, "claude-3-7-sonnet") || strings.Contains(m, "3-7-sonnet") || strings.Contains(m, "sonnet-3.7") {
|
||||
thinking = map[string]any{
|
||||
|
||||
applyClaudeOutputConfigEffort(cfg, effort, sonnet37)
|
||||
}
|
||||
|
||||
// claudeSonnet37DefaultBudgetTokens matches Anthropic extended-thinking documentation examples (budget_tokens with max_tokens 16000).
|
||||
const claudeSonnet37DefaultBudgetTokens = 10000
|
||||
|
||||
func isClaudeSonnet37(m string) bool {
|
||||
return strings.Contains(m, "claude-3-7-sonnet") ||
|
||||
strings.Contains(m, "3-7-sonnet") ||
|
||||
strings.Contains(m, "sonnet-3.7")
|
||||
}
|
||||
|
||||
func claudeThinkingForModel(m string, sonnet37 bool) map[string]any {
|
||||
if sonnet37 {
|
||||
return map[string]any{
|
||||
"type": "enabled",
|
||||
"budget_tokens": 10000,
|
||||
"budget_tokens": claudeSonnet37DefaultBudgetTokens,
|
||||
"display": "summarized",
|
||||
}
|
||||
}
|
||||
// Opus 4.7+: manual enabled+budget rejected — keep adaptive only.
|
||||
// Opus 4.7+: manual enabled+budget rejected — adaptive only.
|
||||
if strings.Contains(m, "opus-4-7") || strings.Contains(m, "opus-4.7") {
|
||||
thinking = map[string]any{
|
||||
return map[string]any{
|
||||
"type": "adaptive",
|
||||
"display": "summarized",
|
||||
}
|
||||
}
|
||||
_ = effort // reserved: map to Anthropic effort / output_config when API stabilizes in one place
|
||||
cfg.ExtraFields["thinking"] = thinking
|
||||
return map[string]any{
|
||||
"type": "adaptive",
|
||||
"display": "summarized",
|
||||
}
|
||||
}
|
||||
|
||||
// applyClaudeOutputConfigEffort sets top-level output_config.effort only when effort is explicitly configured.
|
||||
// Omitted effort uses the API default (high); do not inject effort on mode:on alone.
|
||||
func applyClaudeOutputConfigEffort(cfg *einoopenai.ChatModelConfig, effort string, sonnet37 bool) {
|
||||
if cfg == nil || sonnet37 {
|
||||
return
|
||||
}
|
||||
if _, exists := cfg.ExtraFields["output_config"]; exists {
|
||||
return
|
||||
}
|
||||
e := effortStringForAPI(effort)
|
||||
if e == "" {
|
||||
return
|
||||
}
|
||||
cfg.ExtraFields["output_config"] = map[string]any{"effort": e}
|
||||
}
|
||||
|
||||
func effectiveMode(sr *config.OpenAIReasoningConfig, client *ClientIntent, allowClient bool) string {
|
||||
|
||||
@@ -80,3 +80,80 @@ func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
|
||||
t.Fatalf("max effort wire=%q, want max", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaude_adaptiveOutputConfigEffort(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{
|
||||
Provider: "claude",
|
||||
Model: "claude-opus-4-8",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "on",
|
||||
Effort: "xhigh",
|
||||
},
|
||||
}
|
||||
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
||||
if !ok || th["type"] != "adaptive" {
|
||||
t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"])
|
||||
}
|
||||
oc, ok := cfg.ExtraFields["output_config"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected output_config")
|
||||
}
|
||||
if oc["effort"] != "xhigh" {
|
||||
t.Fatalf("effort=%v", oc["effort"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaude_sonnet37OfficialBudget(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{
|
||||
Provider: "claude",
|
||||
Model: "claude-3-7-sonnet-latest",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "on",
|
||||
Effort: "low", // 3.7 has no output_config.effort; effort is not mapped to budget_tokens
|
||||
},
|
||||
}
|
||||
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||
th, ok := cfg.ExtraFields["thinking"].(map[string]any)
|
||||
if !ok || th["type"] != "enabled" {
|
||||
t.Fatalf("thinking=%#v", cfg.ExtraFields["thinking"])
|
||||
}
|
||||
if th["budget_tokens"] != claudeSonnet37DefaultBudgetTokens {
|
||||
t.Fatalf("budget_tokens=%v, want official example %d", th["budget_tokens"], claudeSonnet37DefaultBudgetTokens)
|
||||
}
|
||||
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
|
||||
t.Fatal("sonnet 3.7 should not set output_config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaude_onWithoutEffortOmitsOutputConfig(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{
|
||||
Provider: "claude",
|
||||
Model: "claude-sonnet-4-6",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "on",
|
||||
},
|
||||
}
|
||||
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
|
||||
t.Fatal("on without explicit effort should omit output_config (API default high)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaude_autoWithoutEffortSkipsOutputConfig(t *testing.T) {
|
||||
cfg := &einoopenai.ChatModelConfig{}
|
||||
oa := &config.OpenAIConfig{
|
||||
Provider: "claude",
|
||||
Model: "claude-sonnet-4-6",
|
||||
Reasoning: config.OpenAIReasoningConfig{
|
||||
Mode: "auto",
|
||||
},
|
||||
}
|
||||
ApplyToEinoChatModelConfig(cfg, oa, nil)
|
||||
if _, hasOC := cfg.ExtraFields["output_config"]; hasOC {
|
||||
t.Fatal("auto without effort should omit output_config")
|
||||
}
|
||||
}
|
||||
|
||||
+11
-247
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"go.uber.org/zap"
|
||||
@@ -33,44 +32,25 @@ var ToolOutputCallbackCtxKey = toolOutputCallbackCtxKey{}
|
||||
|
||||
// Executor 安全工具执行器
|
||||
type Executor struct {
|
||||
config *config.SecurityConfig
|
||||
toolIndex map[string]*config.ToolConfig // 工具索引,用于 O(1) 查找
|
||||
mcpServer *mcp.Server
|
||||
logger *zap.Logger
|
||||
resultStorage ResultStorage // 结果存储(用于查询工具)
|
||||
}
|
||||
|
||||
// ResultStorage 结果存储接口(直接使用 storage 包的类型)
|
||||
type ResultStorage interface {
|
||||
SaveResult(executionID string, toolName string, result string) error
|
||||
GetResult(executionID string) (string, error)
|
||||
GetResultPage(executionID string, page int, limit int) (*storage.ResultPage, error)
|
||||
SearchResult(executionID string, keyword string, useRegex bool) ([]string, error)
|
||||
FilterResult(executionID string, filter string, useRegex bool) ([]string, error)
|
||||
GetResultMetadata(executionID string) (*storage.ResultMetadata, error)
|
||||
GetResultPath(executionID string) string
|
||||
DeleteResult(executionID string) error
|
||||
config *config.SecurityConfig
|
||||
toolIndex map[string]*config.ToolConfig // 工具索引,用于 O(1) 查找
|
||||
mcpServer *mcp.Server
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewExecutor 创建新的执行器
|
||||
func NewExecutor(cfg *config.SecurityConfig, mcpServer *mcp.Server, logger *zap.Logger) *Executor {
|
||||
executor := &Executor{
|
||||
config: cfg,
|
||||
toolIndex: make(map[string]*config.ToolConfig),
|
||||
mcpServer: mcpServer,
|
||||
logger: logger,
|
||||
resultStorage: nil, // 稍后通过 SetResultStorage 设置
|
||||
config: cfg,
|
||||
toolIndex: make(map[string]*config.ToolConfig),
|
||||
mcpServer: mcpServer,
|
||||
logger: logger,
|
||||
}
|
||||
// 构建工具索引
|
||||
executor.buildToolIndex()
|
||||
return executor
|
||||
}
|
||||
|
||||
// SetResultStorage 设置结果存储
|
||||
func (e *Executor) SetResultStorage(storage ResultStorage) {
|
||||
e.resultStorage = storage
|
||||
}
|
||||
|
||||
// buildToolIndex 构建工具索引,将 O(n) 查找优化为 O(1)
|
||||
func (e *Executor) buildToolIndex() {
|
||||
e.toolIndex = make(map[string]*config.ToolConfig)
|
||||
@@ -1245,238 +1225,22 @@ func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback
|
||||
|
||||
// executeInternalTool 执行内部工具(不执行外部命令)
|
||||
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
// 提取内部工具类型(去掉 "internal:" 前缀)
|
||||
internalToolType := strings.TrimPrefix(command, "internal:")
|
||||
|
||||
e.logger.Info("执行内部工具",
|
||||
e.logger.Warn("未知的内部工具",
|
||||
zap.String("toolName", toolName),
|
||||
zap.String("internalToolType", internalToolType),
|
||||
zap.Any("args", args),
|
||||
)
|
||||
|
||||
// 根据内部工具类型分发处理
|
||||
switch internalToolType {
|
||||
case "query_execution_result":
|
||||
return e.executeQueryExecutionResult(ctx, args)
|
||||
default:
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("错误: 未知的内部工具类型: %s", internalToolType),
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// executeQueryExecutionResult 执行查询执行结果工具
|
||||
func (e *Executor) executeQueryExecutionResult(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
// 获取 execution_id 参数
|
||||
executionID, ok := args["execution_id"].(string)
|
||||
if !ok || executionID == "" {
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: "错误: execution_id 参数必需且不能为空",
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 获取可选参数
|
||||
page := 1
|
||||
if p, ok := args["page"].(float64); ok {
|
||||
page = int(p)
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
limit := 100
|
||||
if l, ok := args["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500 // 限制最大每页行数
|
||||
}
|
||||
|
||||
search := ""
|
||||
if s, ok := args["search"].(string); ok {
|
||||
search = s
|
||||
}
|
||||
|
||||
filter := ""
|
||||
if f, ok := args["filter"].(string); ok {
|
||||
filter = f
|
||||
}
|
||||
|
||||
useRegex := false
|
||||
if r, ok := args["use_regex"].(bool); ok {
|
||||
useRegex = r
|
||||
}
|
||||
|
||||
// 检查结果存储是否可用
|
||||
if e.resultStorage == nil {
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: "错误: 结果存储未初始化",
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 执行查询
|
||||
var resultPage *storage.ResultPage
|
||||
var err error
|
||||
|
||||
if search != "" {
|
||||
// 搜索模式
|
||||
matchedLines, err := e.resultStorage.SearchResult(executionID, search, useRegex)
|
||||
if err != nil {
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("搜索失败: %v", err),
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
// 对搜索结果进行分页
|
||||
resultPage = paginateLines(matchedLines, page, limit)
|
||||
} else if filter != "" {
|
||||
// 过滤模式
|
||||
filteredLines, err := e.resultStorage.FilterResult(executionID, filter, useRegex)
|
||||
if err != nil {
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("过滤失败: %v", err),
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
// 对过滤结果进行分页
|
||||
resultPage = paginateLines(filteredLines, page, limit)
|
||||
} else {
|
||||
// 普通分页查询
|
||||
resultPage, err = e.resultStorage.GetResultPage(executionID, page, limit)
|
||||
if err != nil {
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("查询失败: %v", err),
|
||||
},
|
||||
},
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 获取元信息
|
||||
metadata, err := e.resultStorage.GetResultMetadata(executionID)
|
||||
if err != nil {
|
||||
// 元信息获取失败不影响查询结果
|
||||
e.logger.Warn("获取结果元信息失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 格式化返回结果
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("查询结果 (执行ID: %s)\n", executionID))
|
||||
|
||||
if metadata != nil {
|
||||
sb.WriteString(fmt.Sprintf("工具: %s | 大小: %d 字节 (%.2f KB) | 总行数: %d\n",
|
||||
metadata.ToolName, metadata.TotalSize, float64(metadata.TotalSize)/1024, metadata.TotalLines))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("第 %d/%d 页,每页 %d 行,共 %d 行\n\n",
|
||||
resultPage.Page, resultPage.TotalPages, resultPage.Limit, resultPage.TotalLines))
|
||||
|
||||
if len(resultPage.Lines) == 0 {
|
||||
sb.WriteString("没有找到匹配的结果。\n")
|
||||
} else {
|
||||
for i, line := range resultPage.Lines {
|
||||
lineNum := (resultPage.Page-1)*resultPage.Limit + i + 1
|
||||
sb.WriteString(fmt.Sprintf("%d: %s\n", lineNum, line))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
if resultPage.Page < resultPage.TotalPages {
|
||||
sb.WriteString(fmt.Sprintf("提示: 使用 page=%d 查看下一页", resultPage.Page+1))
|
||||
if search != "" {
|
||||
sb.WriteString(fmt.Sprintf(",或使用 search=\"%s\" 继续搜索", search))
|
||||
if useRegex {
|
||||
sb.WriteString(" (正则模式)")
|
||||
}
|
||||
}
|
||||
if filter != "" {
|
||||
sb.WriteString(fmt.Sprintf(",或使用 filter=\"%s\" 继续过滤", filter))
|
||||
if useRegex {
|
||||
sb.WriteString(" (正则模式)")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return &mcp.ToolResult{
|
||||
Content: []mcp.Content{
|
||||
{
|
||||
Type: "text",
|
||||
Text: sb.String(),
|
||||
Text: fmt.Sprintf("错误: 未知的内部工具类型: %s", internalToolType),
|
||||
},
|
||||
},
|
||||
IsError: false,
|
||||
IsError: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// paginateLines 对行列表进行分页
|
||||
func paginateLines(lines []string, page int, limit int) *storage.ResultPage {
|
||||
totalLines := len(lines)
|
||||
totalPages := (totalLines + limit - 1) / limit
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages && totalPages > 0 {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
start := (page - 1) * limit
|
||||
end := start + limit
|
||||
if end > totalLines {
|
||||
end = totalLines
|
||||
}
|
||||
|
||||
var pageLines []string
|
||||
if start < totalLines {
|
||||
pageLines = lines[start:end]
|
||||
} else {
|
||||
pageLines = []string{}
|
||||
}
|
||||
|
||||
return &storage.ResultPage{
|
||||
Lines: pageLines,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
TotalLines: totalLines,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
}
|
||||
|
||||
// buildInputSchema 构建输入模式
|
||||
func (e *Executor) buildInputSchema(toolConfig *config.ToolConfig) map[string]interface{} {
|
||||
schema := map[string]interface{}{
|
||||
|
||||
@@ -2,15 +2,12 @@ package security
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -28,137 +25,6 @@ func setupTestExecutor(t *testing.T) (*Executor, *mcp.Server) {
|
||||
return executor, mcpServer
|
||||
}
|
||||
|
||||
// setupTestStorage 创建测试用的存储
|
||||
func setupTestStorage(t *testing.T) *storage.FileResultStorage {
|
||||
tmpDir := filepath.Join(os.TempDir(), "test_executor_storage_"+time.Now().Format("20060102_150405"))
|
||||
logger := zap.NewNop()
|
||||
|
||||
storage, err := storage.NewFileResultStorage(tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试存储失败: %v", err)
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
func TestExecutor_ExecuteInternalTool_QueryExecutionResult(t *testing.T) {
|
||||
executor, _ := setupTestExecutor(t)
|
||||
testStorage := setupTestStorage(t)
|
||||
executor.SetResultStorage(testStorage)
|
||||
|
||||
// 准备测试数据
|
||||
executionID := "test_exec_001"
|
||||
toolName := "nmap_scan"
|
||||
result := "Line 1: Port 22 open\nLine 2: Port 80 open\nLine 3: Port 443 open\nLine 4: error occurred"
|
||||
|
||||
// 保存测试结果
|
||||
err := testStorage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存测试结果失败: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试1: 基本查询(第一页)
|
||||
args := map[string]interface{}{
|
||||
"execution_id": executionID,
|
||||
"page": float64(1),
|
||||
"limit": float64(2),
|
||||
}
|
||||
|
||||
toolResult, err := executor.executeQueryExecutionResult(ctx, args)
|
||||
if err != nil {
|
||||
t.Fatalf("执行查询失败: %v", err)
|
||||
}
|
||||
|
||||
if toolResult.IsError {
|
||||
t.Fatalf("查询应该成功,但返回了错误: %s", toolResult.Content[0].Text)
|
||||
}
|
||||
|
||||
// 验证结果包含预期内容
|
||||
resultText := toolResult.Content[0].Text
|
||||
if !strings.Contains(resultText, executionID) {
|
||||
t.Errorf("结果中应该包含执行ID: %s", executionID)
|
||||
}
|
||||
|
||||
if !strings.Contains(resultText, "第 1/") {
|
||||
t.Errorf("结果中应该包含分页信息")
|
||||
}
|
||||
|
||||
// 测试2: 搜索功能
|
||||
args2 := map[string]interface{}{
|
||||
"execution_id": executionID,
|
||||
"search": "error",
|
||||
"page": float64(1),
|
||||
"limit": float64(10),
|
||||
}
|
||||
|
||||
toolResult2, err := executor.executeQueryExecutionResult(ctx, args2)
|
||||
if err != nil {
|
||||
t.Fatalf("执行搜索失败: %v", err)
|
||||
}
|
||||
|
||||
if toolResult2.IsError {
|
||||
t.Fatalf("搜索应该成功,但返回了错误: %s", toolResult2.Content[0].Text)
|
||||
}
|
||||
|
||||
resultText2 := toolResult2.Content[0].Text
|
||||
if !strings.Contains(resultText2, "error") {
|
||||
t.Errorf("搜索结果中应该包含关键词: error")
|
||||
}
|
||||
|
||||
// 测试3: 过滤功能
|
||||
args3 := map[string]interface{}{
|
||||
"execution_id": executionID,
|
||||
"filter": "Port",
|
||||
"page": float64(1),
|
||||
"limit": float64(10),
|
||||
}
|
||||
|
||||
toolResult3, err := executor.executeQueryExecutionResult(ctx, args3)
|
||||
if err != nil {
|
||||
t.Fatalf("执行过滤失败: %v", err)
|
||||
}
|
||||
|
||||
if toolResult3.IsError {
|
||||
t.Fatalf("过滤应该成功,但返回了错误: %s", toolResult3.Content[0].Text)
|
||||
}
|
||||
|
||||
resultText3 := toolResult3.Content[0].Text
|
||||
if !strings.Contains(resultText3, "Port") {
|
||||
t.Errorf("过滤结果中应该包含关键词: Port")
|
||||
}
|
||||
|
||||
// 测试4: 缺少必需参数
|
||||
args4 := map[string]interface{}{
|
||||
"page": float64(1),
|
||||
}
|
||||
|
||||
toolResult4, err := executor.executeQueryExecutionResult(ctx, args4)
|
||||
if err != nil {
|
||||
t.Fatalf("执行查询失败: %v", err)
|
||||
}
|
||||
|
||||
if !toolResult4.IsError {
|
||||
t.Fatal("缺少execution_id应该返回错误")
|
||||
}
|
||||
|
||||
// 测试5: 不存在的执行ID
|
||||
args5 := map[string]interface{}{
|
||||
"execution_id": "nonexistent_id",
|
||||
"page": float64(1),
|
||||
}
|
||||
|
||||
toolResult5, err := executor.executeQueryExecutionResult(ctx, args5)
|
||||
if err != nil {
|
||||
t.Fatalf("执行查询失败: %v", err)
|
||||
}
|
||||
|
||||
if !toolResult5.IsError {
|
||||
t.Fatal("不存在的执行ID应该返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_ExecuteInternalTool_UnknownTool(t *testing.T) {
|
||||
executor, _ := setupTestExecutor(t)
|
||||
|
||||
@@ -182,29 +48,6 @@ func TestExecutor_ExecuteInternalTool_UnknownTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutor_ExecuteInternalTool_NoStorage(t *testing.T) {
|
||||
executor, _ := setupTestExecutor(t)
|
||||
// 不设置存储,测试未初始化的情况
|
||||
|
||||
ctx := context.Background()
|
||||
args := map[string]interface{}{
|
||||
"execution_id": "test_id",
|
||||
}
|
||||
|
||||
toolResult, err := executor.executeQueryExecutionResult(ctx, args)
|
||||
if err != nil {
|
||||
t.Fatalf("执行查询失败: %v", err)
|
||||
}
|
||||
|
||||
if !toolResult.IsError {
|
||||
t.Fatal("未初始化的存储应该返回错误")
|
||||
}
|
||||
|
||||
if !strings.Contains(toolResult.Content[0].Text, "结果存储未初始化") {
|
||||
t.Errorf("错误消息应该包含'结果存储未初始化'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T) {
|
||||
executor, _ := setupTestExecutor(t)
|
||||
// 子进程先向 stdout 写无换行字符再长时间 sleep;若与 echo $pid 共享管道且未重定向子进程 stdout,
|
||||
@@ -228,63 +71,58 @@ func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateLines(t *testing.T) {
|
||||
lines := []string{"Line 1", "Line 2", "Line 3", "Line 4", "Line 5"}
|
||||
|
||||
// 测试第一页
|
||||
page := paginateLines(lines, 1, 2)
|
||||
if page.Page != 1 {
|
||||
t.Errorf("页码不匹配。期望: 1, 实际: %d", page.Page)
|
||||
}
|
||||
if page.Limit != 2 {
|
||||
t.Errorf("每页行数不匹配。期望: 2, 实际: %d", page.Limit)
|
||||
}
|
||||
if page.TotalLines != 5 {
|
||||
t.Errorf("总行数不匹配。期望: 5, 实际: %d", page.TotalLines)
|
||||
}
|
||||
if page.TotalPages != 3 {
|
||||
t.Errorf("总页数不匹配。期望: 3, 实际: %d", page.TotalPages)
|
||||
}
|
||||
if len(page.Lines) != 2 {
|
||||
t.Errorf("第一页行数不匹配。期望: 2, 实际: %d", len(page.Lines))
|
||||
func TestBuildCommandArgs_NmapSkipsEmptyOptionalFlags(t *testing.T) {
|
||||
pos1 := 1
|
||||
executor, _ := setupTestExecutor(t)
|
||||
toolConfig := &config.ToolConfig{
|
||||
Name: "nmap",
|
||||
Command: "nmap",
|
||||
Args: []string{"-sT", "-sV", "-sC"},
|
||||
Parameters: []config.ParameterConfig{
|
||||
{Name: "target", Type: "string", Required: true, Position: &pos1, Format: "positional"},
|
||||
{Name: "ports", Type: "string", Flag: "-p", Format: "flag"},
|
||||
{Name: "timing", Type: "string", Template: "-T{value}", Format: "template"},
|
||||
{Name: "nse_scripts", Type: "string", Flag: "--script", Format: "flag"},
|
||||
{Name: "os_detection", Type: "bool", Flag: "-O", Format: "flag", Default: false},
|
||||
{Name: "aggressive", Type: "bool", Flag: "-A", Format: "flag", Default: false},
|
||||
{Name: "scan_type", Type: "string", Format: "template", Template: "{value}"},
|
||||
{Name: "additional_args", Type: "string", Format: "positional"},
|
||||
},
|
||||
}
|
||||
|
||||
// 测试第二页
|
||||
page2 := paginateLines(lines, 2, 2)
|
||||
if len(page2.Lines) != 2 {
|
||||
t.Errorf("第二页行数不匹配。期望: 2, 实际: %d", len(page2.Lines))
|
||||
}
|
||||
if page2.Lines[0] != "Line 3" {
|
||||
t.Errorf("第二页第一行不匹配。期望: Line 3, 实际: %s", page2.Lines[0])
|
||||
args := map[string]interface{}{
|
||||
"target": "110.52.223.114",
|
||||
"ports": "21, 22, 80, 443",
|
||||
"timing": "4",
|
||||
"nse_scripts": "",
|
||||
"scan_type": "",
|
||||
"os_detection": false,
|
||||
"aggressive": false,
|
||||
"additional_args": "-Pn",
|
||||
}
|
||||
|
||||
// 测试最后一页
|
||||
page3 := paginateLines(lines, 3, 2)
|
||||
if len(page3.Lines) != 1 {
|
||||
t.Errorf("第三页行数不匹配。期望: 1, 实际: %d", len(page3.Lines))
|
||||
}
|
||||
cmdArgs := executor.buildCommandArgs("nmap", toolConfig, args)
|
||||
joined := strings.Join(cmdArgs, " ")
|
||||
|
||||
// 测试超出范围的页码(应该返回最后一页)
|
||||
page4 := paginateLines(lines, 4, 2)
|
||||
if page4.Page != 3 {
|
||||
t.Errorf("超出范围的页码应该被修正为最后一页。期望: 3, 实际: %d", page4.Page)
|
||||
if strings.Contains(joined, "--script") {
|
||||
t.Fatalf("empty nse_scripts must not emit --script, got: %v", cmdArgs)
|
||||
}
|
||||
if len(page4.Lines) != 1 {
|
||||
t.Errorf("最后一页应该只有1行。实际: %d行", len(page4.Lines))
|
||||
if !strings.Contains(joined, "110.52.223.114") {
|
||||
t.Fatalf("target missing from args: %v", cmdArgs)
|
||||
}
|
||||
|
||||
// 测试无效页码(小于1)
|
||||
page0 := paginateLines(lines, 0, 2)
|
||||
if page0.Page != 1 {
|
||||
t.Errorf("无效页码应该被修正为1。实际: %d", page0.Page)
|
||||
}
|
||||
|
||||
// 测试空列表
|
||||
emptyPage := paginateLines([]string{}, 1, 10)
|
||||
if emptyPage.TotalLines != 0 {
|
||||
t.Errorf("空列表的总行数应该为0。实际: %d", emptyPage.TotalLines)
|
||||
}
|
||||
if len(emptyPage.Lines) != 0 {
|
||||
t.Errorf("空列表应该返回空结果。实际: %d行", len(emptyPage.Lines))
|
||||
// target 应出现在 -Pn 之前,避免被误当作 --script 的参数
|
||||
pnIdx := indexOf(cmdArgs, "-Pn")
|
||||
targetIdx := indexOf(cmdArgs, "110.52.223.114")
|
||||
if pnIdx < 0 || targetIdx < 0 || targetIdx >= pnIdx {
|
||||
t.Fatalf("expected target before -Pn, got: %v", cmdArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func indexOf(slice []string, s string) int {
|
||||
for i, v := range slice {
|
||||
if v == s {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ResultStorage 结果存储接口
|
||||
type ResultStorage interface {
|
||||
// SaveResult 保存工具执行结果
|
||||
SaveResult(executionID string, toolName string, result string) error
|
||||
|
||||
// GetResult 获取完整结果
|
||||
GetResult(executionID string) (string, error)
|
||||
|
||||
// GetResultPage 分页获取结果
|
||||
GetResultPage(executionID string, page int, limit int) (*ResultPage, error)
|
||||
|
||||
// SearchResult 搜索结果
|
||||
// useRegex: 如果为 true,将 keyword 作为正则表达式使用;如果为 false,使用简单的字符串包含匹配
|
||||
SearchResult(executionID string, keyword string, useRegex bool) ([]string, error)
|
||||
|
||||
// FilterResult 过滤结果
|
||||
// useRegex: 如果为 true,将 filter 作为正则表达式使用;如果为 false,使用简单的字符串包含匹配
|
||||
FilterResult(executionID string, filter string, useRegex bool) ([]string, error)
|
||||
|
||||
// GetResultMetadata 获取结果元信息
|
||||
GetResultMetadata(executionID string) (*ResultMetadata, error)
|
||||
|
||||
// GetResultPath 获取结果文件路径
|
||||
GetResultPath(executionID string) string
|
||||
|
||||
// DeleteResult 删除结果
|
||||
DeleteResult(executionID string) error
|
||||
}
|
||||
|
||||
// ResultPage 分页结果
|
||||
type ResultPage struct {
|
||||
Lines []string `json:"lines"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalLines int `json:"total_lines"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// ResultMetadata 结果元信息
|
||||
type ResultMetadata struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
ToolName string `json:"tool_name"`
|
||||
TotalSize int `json:"total_size"`
|
||||
TotalLines int `json:"total_lines"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// FileResultStorage 基于文件的结果存储实现
|
||||
type FileResultStorage struct {
|
||||
baseDir string
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewFileResultStorage 创建新的文件结果存储
|
||||
func NewFileResultStorage(baseDir string, logger *zap.Logger) (*FileResultStorage, error) {
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建存储目录失败: %w", err)
|
||||
}
|
||||
|
||||
return &FileResultStorage{
|
||||
baseDir: baseDir,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getResultPath 获取结果文件路径
|
||||
func (s *FileResultStorage) getResultPath(executionID string) string {
|
||||
return filepath.Join(s.baseDir, executionID+".txt")
|
||||
}
|
||||
|
||||
// getMetadataPath 获取元数据文件路径
|
||||
func (s *FileResultStorage) getMetadataPath(executionID string) string {
|
||||
return filepath.Join(s.baseDir, executionID+".meta.json")
|
||||
}
|
||||
|
||||
// SaveResult 保存工具执行结果
|
||||
func (s *FileResultStorage) SaveResult(executionID string, toolName string, result string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// 保存结果文件
|
||||
resultPath := s.getResultPath(executionID)
|
||||
if err := os.WriteFile(resultPath, []byte(result), 0644); err != nil {
|
||||
return fmt.Errorf("保存结果文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算统计信息
|
||||
lines := strings.Split(result, "\n")
|
||||
metadata := &ResultMetadata{
|
||||
ExecutionID: executionID,
|
||||
ToolName: toolName,
|
||||
TotalSize: len(result),
|
||||
TotalLines: len(lines),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// 保存元数据
|
||||
metadataPath := s.getMetadataPath(executionID)
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化元数据失败: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(metadataPath, metadataJSON, 0644); err != nil {
|
||||
return fmt.Errorf("保存元数据文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("保存工具执行结果",
|
||||
zap.String("executionID", executionID),
|
||||
zap.String("toolName", toolName),
|
||||
zap.Int("size", len(result)),
|
||||
zap.Int("lines", len(lines)),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetResult 获取完整结果
|
||||
func (s *FileResultStorage) GetResult(executionID string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
resultPath := s.getResultPath(executionID)
|
||||
data, err := os.ReadFile(resultPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("结果不存在: %s", executionID)
|
||||
}
|
||||
return "", fmt.Errorf("读取结果文件失败: %w", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// GetResultMetadata 获取结果元信息
|
||||
func (s *FileResultStorage) GetResultMetadata(executionID string) (*ResultMetadata, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
metadataPath := s.getMetadataPath(executionID)
|
||||
data, err := os.ReadFile(metadataPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("结果不存在: %s", executionID)
|
||||
}
|
||||
return nil, fmt.Errorf("读取元数据文件失败: %w", err)
|
||||
}
|
||||
|
||||
var metadata ResultMetadata
|
||||
if err := json.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("解析元数据失败: %w", err)
|
||||
}
|
||||
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
// GetResultPage 分页获取结果
|
||||
func (s *FileResultStorage) GetResultPage(executionID string, page int, limit int) (*ResultPage, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// 获取完整结果
|
||||
result, err := s.GetResult(executionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 分割为行
|
||||
lines := strings.Split(result, "\n")
|
||||
totalLines := len(lines)
|
||||
|
||||
// 计算分页
|
||||
totalPages := (totalLines + limit - 1) / limit
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages && totalPages > 0 {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
// 计算起始和结束索引
|
||||
start := (page - 1) * limit
|
||||
end := start + limit
|
||||
if end > totalLines {
|
||||
end = totalLines
|
||||
}
|
||||
|
||||
// 提取指定页的行
|
||||
var pageLines []string
|
||||
if start < totalLines {
|
||||
pageLines = lines[start:end]
|
||||
} else {
|
||||
pageLines = []string{}
|
||||
}
|
||||
|
||||
return &ResultPage{
|
||||
Lines: pageLines,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
TotalLines: totalLines,
|
||||
TotalPages: totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchResult 搜索结果
|
||||
func (s *FileResultStorage) SearchResult(executionID string, keyword string, useRegex bool) ([]string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// 获取完整结果
|
||||
result, err := s.GetResult(executionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果使用正则表达式,先编译正则
|
||||
var regex *regexp.Regexp
|
||||
if useRegex {
|
||||
compiledRegex, err := regexp.Compile(keyword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的正则表达式: %w", err)
|
||||
}
|
||||
regex = compiledRegex
|
||||
}
|
||||
|
||||
// 分割为行并搜索
|
||||
lines := strings.Split(result, "\n")
|
||||
var matchedLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
var matched bool
|
||||
if useRegex {
|
||||
matched = regex.MatchString(line)
|
||||
} else {
|
||||
matched = strings.Contains(line, keyword)
|
||||
}
|
||||
|
||||
if matched {
|
||||
matchedLines = append(matchedLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
return matchedLines, nil
|
||||
}
|
||||
|
||||
// FilterResult 过滤结果
|
||||
func (s *FileResultStorage) FilterResult(executionID string, filter string, useRegex bool) ([]string, error) {
|
||||
// 过滤和搜索逻辑相同,都是查找包含关键词的行
|
||||
return s.SearchResult(executionID, filter, useRegex)
|
||||
}
|
||||
|
||||
// GetResultPath 获取结果文件路径
|
||||
func (s *FileResultStorage) GetResultPath(executionID string) string {
|
||||
return s.getResultPath(executionID)
|
||||
}
|
||||
|
||||
// DeleteResult 删除结果
|
||||
func (s *FileResultStorage) DeleteResult(executionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
resultPath := s.getResultPath(executionID)
|
||||
metadataPath := s.getMetadataPath(executionID)
|
||||
|
||||
// 删除结果文件
|
||||
if err := os.Remove(resultPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除结果文件失败: %w", err)
|
||||
}
|
||||
|
||||
// 删除元数据文件
|
||||
if err := os.Remove(metadataPath); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("删除元数据文件失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("删除工具执行结果",
|
||||
zap.String("executionID", executionID),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// setupTestStorage 创建测试用的存储实例
|
||||
func setupTestStorage(t *testing.T) (*FileResultStorage, string) {
|
||||
tmpDir := filepath.Join(os.TempDir(), "test_result_storage_"+time.Now().Format("20060102_150405"))
|
||||
logger := zap.NewNop()
|
||||
|
||||
storage, err := NewFileResultStorage(tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("创建测试存储失败: %v", err)
|
||||
}
|
||||
|
||||
return storage, tmpDir
|
||||
}
|
||||
|
||||
// cleanupTestStorage 清理测试数据
|
||||
func cleanupTestStorage(t *testing.T, tmpDir string) {
|
||||
if err := os.RemoveAll(tmpDir); err != nil {
|
||||
t.Logf("清理测试目录失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewFileResultStorage(t *testing.T) {
|
||||
tmpDir := filepath.Join(os.TempDir(), "test_new_storage_"+time.Now().Format("20060102_150405"))
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
logger := zap.NewNop()
|
||||
storage, err := NewFileResultStorage(tmpDir, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("创建存储失败: %v", err)
|
||||
}
|
||||
|
||||
if storage == nil {
|
||||
t.Fatal("存储实例为nil")
|
||||
}
|
||||
|
||||
// 验证目录已创建
|
||||
if _, err := os.Stat(tmpDir); os.IsNotExist(err) {
|
||||
t.Fatal("存储目录未创建")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_SaveResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_001"
|
||||
toolName := "nmap_scan"
|
||||
result := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"
|
||||
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证结果文件存在
|
||||
resultPath := filepath.Join(tmpDir, executionID+".txt")
|
||||
if _, err := os.Stat(resultPath); os.IsNotExist(err) {
|
||||
t.Fatal("结果文件未创建")
|
||||
}
|
||||
|
||||
// 验证元数据文件存在
|
||||
metadataPath := filepath.Join(tmpDir, executionID+".meta.json")
|
||||
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
||||
t.Fatal("元数据文件未创建")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_GetResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_002"
|
||||
toolName := "test_tool"
|
||||
expectedResult := "Test result content\nLine 2\nLine 3"
|
||||
|
||||
// 先保存结果
|
||||
err := storage.SaveResult(executionID, toolName, expectedResult)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取结果
|
||||
result, err := storage.GetResult(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("获取结果失败: %v", err)
|
||||
}
|
||||
|
||||
if result != expectedResult {
|
||||
t.Errorf("结果不匹配。期望: %q, 实际: %q", expectedResult, result)
|
||||
}
|
||||
|
||||
// 测试不存在的执行ID
|
||||
_, err = storage.GetResult("nonexistent_id")
|
||||
if err == nil {
|
||||
t.Fatal("应该返回错误")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_GetResultMetadata(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_003"
|
||||
toolName := "test_tool"
|
||||
result := "Line 1\nLine 2\nLine 3"
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 获取元数据
|
||||
metadata, err := storage.GetResultMetadata(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("获取元数据失败: %v", err)
|
||||
}
|
||||
|
||||
if metadata.ExecutionID != executionID {
|
||||
t.Errorf("执行ID不匹配。期望: %s, 实际: %s", executionID, metadata.ExecutionID)
|
||||
}
|
||||
|
||||
if metadata.ToolName != toolName {
|
||||
t.Errorf("工具名称不匹配。期望: %s, 实际: %s", toolName, metadata.ToolName)
|
||||
}
|
||||
|
||||
if metadata.TotalSize != len(result) {
|
||||
t.Errorf("总大小不匹配。期望: %d, 实际: %d", len(result), metadata.TotalSize)
|
||||
}
|
||||
|
||||
expectedLines := len(strings.Split(result, "\n"))
|
||||
if metadata.TotalLines != expectedLines {
|
||||
t.Errorf("总行数不匹配。期望: %d, 实际: %d", expectedLines, metadata.TotalLines)
|
||||
}
|
||||
|
||||
// 验证创建时间在合理范围内
|
||||
now := time.Now()
|
||||
if metadata.CreatedAt.After(now) || metadata.CreatedAt.Before(now.Add(-time.Second)) {
|
||||
t.Errorf("创建时间不在合理范围内: %v", metadata.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_GetResultPage(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_004"
|
||||
toolName := "test_tool"
|
||||
// 创建包含10行的结果
|
||||
lines := make([]string, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
lines[i] = fmt.Sprintf("Line %d", i+1)
|
||||
}
|
||||
result := strings.Join(lines, "\n")
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 测试第一页(每页3行)
|
||||
page, err := storage.GetResultPage(executionID, 1, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第一页失败: %v", err)
|
||||
}
|
||||
|
||||
if page.Page != 1 {
|
||||
t.Errorf("页码不匹配。期望: 1, 实际: %d", page.Page)
|
||||
}
|
||||
|
||||
if page.Limit != 3 {
|
||||
t.Errorf("每页行数不匹配。期望: 3, 实际: %d", page.Limit)
|
||||
}
|
||||
|
||||
if page.TotalLines != 10 {
|
||||
t.Errorf("总行数不匹配。期望: 10, 实际: %d", page.TotalLines)
|
||||
}
|
||||
|
||||
if page.TotalPages != 4 {
|
||||
t.Errorf("总页数不匹配。期望: 4, 实际: %d", page.TotalPages)
|
||||
}
|
||||
|
||||
if len(page.Lines) != 3 {
|
||||
t.Errorf("第一页行数不匹配。期望: 3, 实际: %d", len(page.Lines))
|
||||
}
|
||||
|
||||
if page.Lines[0] != "Line 1" {
|
||||
t.Errorf("第一行内容不匹配。期望: Line 1, 实际: %s", page.Lines[0])
|
||||
}
|
||||
|
||||
// 测试第二页
|
||||
page2, err := storage.GetResultPage(executionID, 2, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第二页失败: %v", err)
|
||||
}
|
||||
|
||||
if len(page2.Lines) != 3 {
|
||||
t.Errorf("第二页行数不匹配。期望: 3, 实际: %d", len(page2.Lines))
|
||||
}
|
||||
|
||||
if page2.Lines[0] != "Line 4" {
|
||||
t.Errorf("第二页第一行内容不匹配。期望: Line 4, 实际: %s", page2.Lines[0])
|
||||
}
|
||||
|
||||
// 测试最后一页(可能不满一页)
|
||||
page4, err := storage.GetResultPage(executionID, 4, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第四页失败: %v", err)
|
||||
}
|
||||
|
||||
if len(page4.Lines) != 1 {
|
||||
t.Errorf("第四页行数不匹配。期望: 1, 实际: %d", len(page4.Lines))
|
||||
}
|
||||
|
||||
// 测试超出范围的页码(应该返回最后一页)
|
||||
page5, err := storage.GetResultPage(executionID, 5, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第五页失败: %v", err)
|
||||
}
|
||||
|
||||
// 超出范围的页码会被修正为最后一页,所以应该返回最后一页的内容
|
||||
if page5.Page != 4 {
|
||||
t.Errorf("超出范围的页码应该被修正为最后一页。期望: 4, 实际: %d", page5.Page)
|
||||
}
|
||||
|
||||
// 最后一页应该只有1行
|
||||
if len(page5.Lines) != 1 {
|
||||
t.Errorf("最后一页应该只有1行。实际: %d行", len(page5.Lines))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_SearchResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_005"
|
||||
toolName := "test_tool"
|
||||
result := "Line 1: error occurred\nLine 2: success\nLine 3: error again\nLine 4: ok"
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 搜索包含"error"的行(简单字符串匹配)
|
||||
matchedLines, err := storage.SearchResult(executionID, "error", false)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
|
||||
if len(matchedLines) != 2 {
|
||||
t.Errorf("搜索结果数量不匹配。期望: 2, 实际: %d", len(matchedLines))
|
||||
}
|
||||
|
||||
// 验证搜索结果内容
|
||||
for i, line := range matchedLines {
|
||||
if !strings.Contains(line, "error") {
|
||||
t.Errorf("搜索结果第%d行不包含关键词: %s", i+1, line)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试搜索不存在的关键词
|
||||
noMatch, err := storage.SearchResult(executionID, "nonexistent", false)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
|
||||
if len(noMatch) != 0 {
|
||||
t.Errorf("搜索不存在的关键词应该返回空结果。实际: %d行", len(noMatch))
|
||||
}
|
||||
|
||||
// 测试正则表达式搜索
|
||||
regexMatched, err := storage.SearchResult(executionID, "error.*again", true)
|
||||
if err != nil {
|
||||
t.Fatalf("正则搜索失败: %v", err)
|
||||
}
|
||||
|
||||
if len(regexMatched) != 1 {
|
||||
t.Errorf("正则搜索结果数量不匹配。期望: 1, 实际: %d", len(regexMatched))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_FilterResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_006"
|
||||
toolName := "test_tool"
|
||||
result := "Line 1: warning message\nLine 2: info message\nLine 3: warning again\nLine 4: debug message"
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 过滤包含"warning"的行(简单字符串匹配)
|
||||
filteredLines, err := storage.FilterResult(executionID, "warning", false)
|
||||
if err != nil {
|
||||
t.Fatalf("过滤失败: %v", err)
|
||||
}
|
||||
|
||||
if len(filteredLines) != 2 {
|
||||
t.Errorf("过滤结果数量不匹配。期望: 2, 实际: %d", len(filteredLines))
|
||||
}
|
||||
|
||||
// 验证过滤结果内容
|
||||
for i, line := range filteredLines {
|
||||
if !strings.Contains(line, "warning") {
|
||||
t.Errorf("过滤结果第%d行不包含关键词: %s", i+1, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_DeleteResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_007"
|
||||
toolName := "test_tool"
|
||||
result := "Test result"
|
||||
|
||||
// 保存结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证文件存在
|
||||
resultPath := filepath.Join(tmpDir, executionID+".txt")
|
||||
metadataPath := filepath.Join(tmpDir, executionID+".meta.json")
|
||||
|
||||
if _, err := os.Stat(resultPath); os.IsNotExist(err) {
|
||||
t.Fatal("结果文件不存在")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
||||
t.Fatal("元数据文件不存在")
|
||||
}
|
||||
|
||||
// 删除结果
|
||||
err = storage.DeleteResult(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("删除结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证文件已删除
|
||||
if _, err := os.Stat(resultPath); !os.IsNotExist(err) {
|
||||
t.Fatal("结果文件未被删除")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(metadataPath); !os.IsNotExist(err) {
|
||||
t.Fatal("元数据文件未被删除")
|
||||
}
|
||||
|
||||
// 测试删除不存在的执行ID(应该不报错)
|
||||
err = storage.DeleteResult("nonexistent_id")
|
||||
if err != nil {
|
||||
t.Errorf("删除不存在的执行ID不应该报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_ConcurrentAccess(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
// 并发保存多个结果
|
||||
done := make(chan bool, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(id int) {
|
||||
executionID := fmt.Sprintf("test_exec_%d", id)
|
||||
toolName := "test_tool"
|
||||
result := fmt.Sprintf("Result %d\nLine 2\nLine 3", id)
|
||||
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Errorf("并发保存失败 (ID: %s): %v", executionID, err)
|
||||
}
|
||||
|
||||
// 并发读取
|
||||
_, err = storage.GetResult(executionID)
|
||||
if err != nil {
|
||||
t.Errorf("并发读取失败 (ID: %s): %v", executionID, err)
|
||||
}
|
||||
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileResultStorage_LargeResult(t *testing.T) {
|
||||
storage, tmpDir := setupTestStorage(t)
|
||||
defer cleanupTestStorage(t, tmpDir)
|
||||
|
||||
executionID := "test_exec_large"
|
||||
toolName := "test_tool"
|
||||
|
||||
// 创建大结果(1000行)
|
||||
lines := make([]string, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
lines[i] = fmt.Sprintf("Line %d: This is a test line with some content", i+1)
|
||||
}
|
||||
result := strings.Join(lines, "\n")
|
||||
|
||||
// 保存大结果
|
||||
err := storage.SaveResult(executionID, toolName, result)
|
||||
if err != nil {
|
||||
t.Fatalf("保存大结果失败: %v", err)
|
||||
}
|
||||
|
||||
// 验证元数据
|
||||
metadata, err := storage.GetResultMetadata(executionID)
|
||||
if err != nil {
|
||||
t.Fatalf("获取元数据失败: %v", err)
|
||||
}
|
||||
|
||||
if metadata.TotalLines != 1000 {
|
||||
t.Errorf("总行数不匹配。期望: 1000, 实际: %d", metadata.TotalLines)
|
||||
}
|
||||
|
||||
// 测试分页查询大结果
|
||||
page, err := storage.GetResultPage(executionID, 1, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("获取第一页失败: %v", err)
|
||||
}
|
||||
|
||||
if page.TotalPages != 10 {
|
||||
t.Errorf("总页数不匹配。期望: 10, 实际: %d", page.TotalPages)
|
||||
}
|
||||
|
||||
if len(page.Lines) != 100 {
|
||||
t.Errorf("第一页行数不匹配。期望: 100, 实际: %d", len(page.Lines))
|
||||
}
|
||||
}
|
||||
+1178
-47
File diff suppressed because it is too large
Load Diff
+1853
-92
File diff suppressed because it is too large
Load Diff
+220
-3
@@ -258,10 +258,63 @@
|
||||
"vulnerabilityManagement": "Vulnerability management",
|
||||
"addFactCta": "+ Add fact",
|
||||
"tabFacts": "Fact board",
|
||||
"tabGraph": "Attack path",
|
||||
"tabConversations": "Bound conversations",
|
||||
"tabVulns": "Related vulnerabilities",
|
||||
"tabSettings": "Settings",
|
||||
"factToolbarHint": "Index includes key and summary only (must include what + where + how to verify); put attack chain / POC in body, and reproduce via get_project_fact.",
|
||||
"graphToolbarHint": "Graph arrows match stored fact links (source → target). Nodes are layered target→infra→finding→exploit. Dashed edges are tentative.",
|
||||
"graphView": "View",
|
||||
"graphViewPath": "Attack path",
|
||||
"graphViewFull": "Full graph",
|
||||
"graphSearchSr": "Search nodes",
|
||||
"graphSearchPlaceholder": "Search nodes…",
|
||||
"graphRefresh": "Refresh",
|
||||
"graphCenter": "Center",
|
||||
"graphEmpty": "No graph data yet. Add links on finding/exploit facts (discovered_on → target/*) to build the path.",
|
||||
"graphEmptyTitle": "Build your attack path",
|
||||
"graphEmptyStep1": "Add target facts (domains, endpoints, scope)",
|
||||
"graphEmptyStep2": "Record findings/exploits with links between facts",
|
||||
"graphEmptyStep3": "Use Connect mode or edit facts to add relationships",
|
||||
"graphEmptyCta": "Add first fact",
|
||||
"graphStats": "Nodes: {{nodes}} | Edges: {{edges}}",
|
||||
"graphStatsNodes": "Nodes",
|
||||
"graphStatsEdges": "Edges",
|
||||
"graphLegendDiscovered": "discovered_on",
|
||||
"graphLegendLeads": "leads_to",
|
||||
"graphLegendExploits": "exploits",
|
||||
"graphLegendTentative": "Tentative (dashed)",
|
||||
"factLinksLabel": "Links (from → this fact)",
|
||||
"factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce",
|
||||
"factLinksHint": "One per line: type: source_fact_key (source → this fact). Common types: discovered_on, depends_on, leads_to, enables, exploits. Saving replaces all links.",
|
||||
"factIncomingLinksLabel": "Incoming links (read-only)",
|
||||
"factIncomingLinksHint": "Derived from outgoing links on source facts. e.g. finding discovered_on → target/* appears as incoming on the target; edit the source fact's outgoing links.",
|
||||
"factIncomingLinksEmpty": "No incoming links",
|
||||
"graphEdgeFromSelf": "From this node",
|
||||
"graphEdgeToSelf": "To this node",
|
||||
"linksColumn": "Links",
|
||||
"linkCountsTitle": "Outgoing / incoming edge counts",
|
||||
"graphConnect": "Connect",
|
||||
"graphConnectActive": "Connecting…",
|
||||
"graphConnectPickTarget": "Source {{source}} selected — click target node",
|
||||
"graphEdgeTypePrompt": "Edge type (discovered_on / leads_to / depends_on / enables / exploits)",
|
||||
"graphConnectFailed": "Failed to create edge",
|
||||
"graphConnectSuccess": "Edge created",
|
||||
"graphEdgesTitle": "Links",
|
||||
"graphEdgesHint": "Arrow direction matches the database and edit modal (source → target). Click an edge to focus it.",
|
||||
"graphEdgesEmpty": "No links yet",
|
||||
"graphEdgeOutgoing": "Outgoing",
|
||||
"graphEdgeIncoming": "Incoming",
|
||||
"graphEdgeSynthetic": "Auto-generated from fact link; edit the fact to remove",
|
||||
"confirmDeleteGraphEdge": "Delete this link?",
|
||||
"graphEdgeDeleteFailed": "Failed to delete edge",
|
||||
"graphEdgeDeleteSuccess": "Edge deleted",
|
||||
"graphDeleteEdge": "Delete",
|
||||
"promoteAttackChain": "Promote chain",
|
||||
"promoteAttackChainTitle": "Promote conversation attack chain to project facts",
|
||||
"confirmPromoteAttackChain": "Promote this conversation's attack chain into the project? Facts and edges will be created or updated.",
|
||||
"promoteAttackChainFailed": "Promote failed",
|
||||
"promoteAttackChainSuccess": "Promoted: {{facts_created}} new / {{facts_updated}} updated / {{edges_created}} edges",
|
||||
"searchFactsSr": "Search facts",
|
||||
"searchFactsPlaceholder": "Search key, summary, body…",
|
||||
"category": "Category",
|
||||
@@ -436,6 +489,9 @@
|
||||
"conversationGroups": "Conversation groups",
|
||||
"addGroup": "New group",
|
||||
"recentConversations": "Recent conversations",
|
||||
"sortConversations": "Sort",
|
||||
"sortByCreatedAt": "Created time",
|
||||
"sortByUpdatedAt": "Updated time",
|
||||
"batchManage": "Batch manage",
|
||||
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
|
||||
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||
@@ -676,7 +732,12 @@
|
||||
"viewConversation": "View conversation",
|
||||
"viewVulnerabilities": "View vulnerabilities",
|
||||
"viewVulnerabilitiesQueueTitle": "View vulnerabilities: open management filtered to this queue",
|
||||
"retryTask": "Retry",
|
||||
"runSingleTask": "Run task",
|
||||
"confirmRunSingleTask": "Run this task only? The queue will pause when it finishes and will not continue other pending items.",
|
||||
"runSingleTaskFailed": "Failed to run task",
|
||||
"runSingleTaskUnavailable": "Unavailable while the queue or a task is running",
|
||||
"runSingleTaskUnavailableSelf": "This task is running",
|
||||
"runSingleTaskUnavailableQueue": "Queue is running; pause it before running another task individually",
|
||||
"conversationIdLabel": "Conversation ID",
|
||||
"statusPending": "Pending",
|
||||
"statusPaused": "Paused",
|
||||
@@ -1083,6 +1144,7 @@
|
||||
"botAgent": "Bot Agent",
|
||||
"ilinkBotId": "iLink Bot ID (filled after bind)",
|
||||
"boundSuccess": "Binding successful. WeChat bot is enabled.",
|
||||
"alreadyBound": "This WeChat account is already bound.",
|
||||
"openLink": "QR not showing? Open link in WeChat on your phone"
|
||||
},
|
||||
"wecom": {
|
||||
@@ -1938,6 +2000,13 @@
|
||||
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||
"openaiApiKeyPlaceholder": "Enter OpenAI API Key",
|
||||
"modelPlaceholder": "gpt-4",
|
||||
"fetchModels": "Fetch list",
|
||||
"modelsListFetching": "Fetching model list...",
|
||||
"modelsListSelectPlaceholder": "Select a model",
|
||||
"modelsListSuccess": "Loaded {count} models — use the dropdown on the right, or type in the input",
|
||||
"modelsListFailed": "Failed to fetch model list",
|
||||
"modelsListNeedApiKey": "Please enter API Key first",
|
||||
"modelsListClaudeHint": "Claude does not support auto model list; enter the model name manually",
|
||||
"maxTotalTokens": "Max Context Tokens",
|
||||
"maxTotalTokensPlaceholder": "120000",
|
||||
"maxTotalTokensHint": "Shared by memory compression and attack chain building. Default: 120000",
|
||||
@@ -2086,14 +2155,35 @@
|
||||
"filterResult": "Result",
|
||||
"pageSize": "Per page",
|
||||
"statTotal": "Filtered total",
|
||||
"statSuccess": "Success",
|
||||
"statFailures": "Failures",
|
||||
"statRecent7d": "Last 7 days",
|
||||
"retentionHint": "Audit records are kept for {{days}} days, then purged automatically.",
|
||||
"disabledHint": "Audit logging is disabled; new actions are not written.",
|
||||
"filterSince": "From",
|
||||
"filterUntil": "Until",
|
||||
"filterTimeZone": "Timezone: {{tz}} (filter uses your browser's local time)",
|
||||
"datetimePlaceholder": "Select date & time",
|
||||
"timePresets": "Quick range",
|
||||
"preset15m": "Last 15 min",
|
||||
"preset1h": "Last 1 hour",
|
||||
"preset24h": "Last 24 hours",
|
||||
"preset7d": "Last 7 days",
|
||||
"presetToday": "Today",
|
||||
"pickerHour": "Hour",
|
||||
"pickerMinute": "Min",
|
||||
"pickerClear": "Clear",
|
||||
"pickerToday": "Today",
|
||||
"pickerConfirm": "OK",
|
||||
"filterQuery": "Keyword",
|
||||
"filterQueryPlaceholder": "Message / resource ID / action",
|
||||
"colTime": "Time",
|
||||
"colMessage": "Message",
|
||||
"colCategory": "Category",
|
||||
"colAction": "Action",
|
||||
"colResult": "Result",
|
||||
"colIp": "IP",
|
||||
"colResource": "Resource ID",
|
||||
"cat": {
|
||||
"auth": "Auth",
|
||||
"config": "Config",
|
||||
@@ -2166,6 +2256,93 @@
|
||||
"exportDone": "Export complete",
|
||||
"loading": "Loading...",
|
||||
"empty": "No audit records",
|
||||
"result": {
|
||||
"success": "success",
|
||||
"failure": "failure"
|
||||
},
|
||||
"msg": {
|
||||
"auth": {
|
||||
"login": "Login successful",
|
||||
"login_failed": "Login failed: incorrect password",
|
||||
"logout": "Logged out",
|
||||
"change_password": "Login password changed",
|
||||
"change_password_failed": "Password change failed: current password incorrect"
|
||||
},
|
||||
"config": {
|
||||
"apply": "Configuration applied",
|
||||
"update": "In-memory configuration updated",
|
||||
"apply_fail_kb_init": "Failed to apply config: knowledge base init",
|
||||
"apply_fail_kb_reinit": "Failed to apply config: knowledge base re-init",
|
||||
"apply_fail_c2": "Failed to apply config: C2"
|
||||
},
|
||||
"conversation": {
|
||||
"create": "Conversation created",
|
||||
"delete": "Conversation deleted",
|
||||
"delete_turn": "Conversation turn deleted"
|
||||
},
|
||||
"c2": {
|
||||
"listener_create": "C2 listener created",
|
||||
"listener_delete": "C2 listener deleted",
|
||||
"listener_start": "C2 listener started",
|
||||
"listener_stop": "C2 listener stopped",
|
||||
"session_delete": "C2 session deleted",
|
||||
"task_create": "C2 task created",
|
||||
"task_cancel": "C2 task cancelled",
|
||||
"task_delete": "C2 tasks deleted (batch)"
|
||||
},
|
||||
"webshell": {
|
||||
"connection_create": "WebShell connection created",
|
||||
"connection_delete": "WebShell connection deleted"
|
||||
},
|
||||
"knowledge": {
|
||||
"item_delete": "Knowledge item deleted",
|
||||
"index_rebuild": "Knowledge index rebuilt"
|
||||
},
|
||||
"vulnerability": {
|
||||
"create": "Vulnerability record created",
|
||||
"update": "Vulnerability record updated",
|
||||
"delete": "Vulnerability record deleted",
|
||||
"delete_batch": "Vulnerability records deleted (batch)"
|
||||
},
|
||||
"external_mcp": {
|
||||
"upsert": "External MCP configuration updated",
|
||||
"delete": "External MCP configuration deleted"
|
||||
},
|
||||
"task": {
|
||||
"create_queue": "Batch task queue created",
|
||||
"start_queue": "Batch task queue started",
|
||||
"delete_queue": "Batch task queue deleted",
|
||||
"pause_queue": "Batch task queue paused",
|
||||
"rerun_queue": "Batch task queue rerun",
|
||||
"delete_batch_task": "Batch subtask deleted"
|
||||
},
|
||||
"tool": {
|
||||
"execution_delete": "Tool execution record deleted",
|
||||
"execution_delete_batch": "Tool execution records deleted (batch)"
|
||||
},
|
||||
"file": {
|
||||
"upload": "Chat attachment uploaded",
|
||||
"delete": "Chat attachment deleted"
|
||||
},
|
||||
"hitl": {
|
||||
"decision": "HITL approval decision"
|
||||
},
|
||||
"role": {
|
||||
"create": "Role created",
|
||||
"update": "Role updated",
|
||||
"delete": "Role deleted"
|
||||
},
|
||||
"skill": {
|
||||
"create": "Skill created",
|
||||
"update": "Skill updated",
|
||||
"delete": "Skill deleted"
|
||||
},
|
||||
"agent": {
|
||||
"markdown_create": "Markdown sub-agent created",
|
||||
"markdown_update": "Markdown sub-agent updated",
|
||||
"markdown_delete": "Markdown sub-agent deleted"
|
||||
}
|
||||
},
|
||||
"paginationShow": "{{start}}-{{end}} of {{total}}",
|
||||
"detailTitle": "Audit detail",
|
||||
"detailTime": "Time",
|
||||
@@ -2244,7 +2421,8 @@
|
||||
"copyContent": "Copy content",
|
||||
"correctInfo": "Correct info",
|
||||
"errorInfo": "Error info",
|
||||
"copyError": "Copy error"
|
||||
"copyError": "Copy error",
|
||||
"contentTruncated": "… (display truncated; use read_file on the path in persisted-output for the full file)"
|
||||
},
|
||||
"attackChainModal": {
|
||||
"title": "Attack chain",
|
||||
@@ -2574,6 +2752,11 @@
|
||||
},
|
||||
"c2": {
|
||||
"clipboardCopied": "Copied to clipboard",
|
||||
"common": {
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{{n}}m ago",
|
||||
"hoursAgo": "{{n}}h ago"
|
||||
},
|
||||
"fmt": {
|
||||
"durationMs": "{{n}}ms",
|
||||
"durationSec": "{{n}}s",
|
||||
@@ -2631,6 +2814,8 @@
|
||||
"bindHintExternal": "Use 0.0.0.0 to allow external access",
|
||||
"callbackHost": "Callback host (optional)",
|
||||
"callbackHostHint": "Public IP or hostname stored for payloads/beacons; separate from bind address. If empty, payload generation falls back to bind address / auto-detect.",
|
||||
"allowLegacyShell": "Allow unencrypted classic reverse shell (lab only)",
|
||||
"allowLegacyShellHint": "Off by default. When enabled, raw bash/nc TCP connections register sessions and are vulnerable to internet scanners; use encrypted Beacon builds for production.",
|
||||
"malleableProfile": "Malleable Profile",
|
||||
"malleableProfileHint": "Optional; HTTP/HTTPS Beacon response headers and traffic disguise. Stop and start the listener again for changes to take effect.",
|
||||
"malleableProfileNone": "None",
|
||||
@@ -2708,10 +2893,22 @@
|
||||
"infoFirstSeen": "First seen",
|
||||
"infoLastCheckin": "Last check-in",
|
||||
"infoNote": "Note",
|
||||
"infoNoteEmpty": "No notes",
|
||||
"infoSectionIdentity": "Identity",
|
||||
"infoSectionSystem": "System",
|
||||
"infoSectionNetwork": "Network & beacon",
|
||||
"infoSectionTimeline": "Timeline",
|
||||
"infoSectionNote": "Notes",
|
||||
"adminYes": "Yes",
|
||||
"adminNo": "No",
|
||||
"promptSleepSeconds": "Sleep interval (seconds)",
|
||||
"promptJitterPercent": "Jitter percent (0–100)",
|
||||
"sleepModalHint": "Saves to the server and queues a sleep task. The implant applies it on the next task poll; later check-ins keep this config.",
|
||||
"sleepModalTitle": "Beacon interval",
|
||||
"sleepModalCurrent": "Current {{sec}}s · jitter {{jitter}}%",
|
||||
"sleepModalPreview": "Estimated {{min}} – {{max}} s",
|
||||
"sleepModalPresets": "Presets",
|
||||
"toastSleepInvalid": "Sleep interval must be at least 1 second",
|
||||
"toastSleepUpdated": "Sleep settings updated",
|
||||
"confirmExitSession": "Send exit command to this session?",
|
||||
"confirmDeleteSession": "Remove this session and related tasks/files from the server? (Does not send exit to the implant; use Kill Session to exit the agent.)",
|
||||
@@ -2729,7 +2926,25 @@
|
||||
"termWaitFinish": "Please wait for the current command to finish",
|
||||
"termCtrlC": "Remote interrupt is not supported in this version",
|
||||
"termQueued": "[Command queued — will run after the current task completes]",
|
||||
"clearTerminal": "Clear"
|
||||
"clearTerminal": "Clear",
|
||||
"batchDelete": "Delete selected",
|
||||
"deleteFiltered": "Delete filtered",
|
||||
"selectAll": "Select all",
|
||||
"filterAllStatus": "All statuses",
|
||||
"filterAllListeners": "All listeners",
|
||||
"filterSearchPlaceholder": "Search hostname / user / IP",
|
||||
"filterApply": "Filter",
|
||||
"filterReset": "Reset",
|
||||
"filterSuspicious": "Likely false positives",
|
||||
"filterCount": "{{n}} total, {{selected}} selected",
|
||||
"emptyFilter": "No sessions match the current filters",
|
||||
"listEmpty": "No sessions",
|
||||
"selectPromptTitle": "Select a session",
|
||||
"selectPromptHint": "Click a session in the list on the left to view terminal, files, and tasks.",
|
||||
"confirmBatchDelete": "Delete {{n}} selected session(s)? Related tasks and file records will be removed.",
|
||||
"confirmDeleteFiltered": "Delete all {{n}} session(s) in the current filter results?",
|
||||
"toastSelectFirst": "Select at least one session to delete",
|
||||
"toastBatchDeleted": "Deleted {{n}} session(s)"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Task Management",
|
||||
@@ -2752,6 +2967,8 @@
|
||||
"pending": "Pending",
|
||||
"emptyAll": "No tasks yet",
|
||||
"emptySession": "No tasks for this session",
|
||||
"sessionTaskHistory": "Task history",
|
||||
"sessionTaskCount": "{{n}} tasks",
|
||||
"colTask": "Task",
|
||||
"colSession": "Session",
|
||||
"colType": "Type",
|
||||
|
||||
+220
-3
@@ -246,10 +246,63 @@
|
||||
"vulnerabilityManagement": "漏洞管理",
|
||||
"addFactCta": "+ 添加事实",
|
||||
"tabFacts": "事实黑板",
|
||||
"tabGraph": "攻击路径",
|
||||
"tabConversations": "关联对话",
|
||||
"tabVulns": "关联漏洞",
|
||||
"tabSettings": "设置",
|
||||
"factToolbarHint": "索引仅含 key 与摘要(须含「什么 + 在哪 + 如何验证」);攻击链 / POC 写在 body,Agent 通过 get_project_fact 复现",
|
||||
"graphToolbarHint": "攻击路径图箭头与事实存储方向一致(source → target);节点按 target→infra→finding→exploit 分层排布。虚线边为待确认。",
|
||||
"graphView": "视图",
|
||||
"graphViewPath": "攻击路径",
|
||||
"graphViewFull": "完整关系",
|
||||
"graphSearchSr": "搜索节点",
|
||||
"graphSearchPlaceholder": "搜索节点…",
|
||||
"graphRefresh": "刷新",
|
||||
"graphCenter": "居中",
|
||||
"graphEmpty": "暂无路径图数据。为 finding/exploit 类事实添加关系边(discovered_on → target/*)后将在此展示。",
|
||||
"graphEmptyTitle": "构建攻击路径图",
|
||||
"graphEmptyStep1": "添加 target 类事实(目标、域名、入口)",
|
||||
"graphEmptyStep2": "记录 finding / exploit 并在 links 中连边",
|
||||
"graphEmptyStep3": "使用「连边」模式或编辑事实手动补关系",
|
||||
"graphEmptyCta": "添加第一条事实",
|
||||
"graphStats": "节点: {{nodes}} | 边: {{edges}}",
|
||||
"graphStatsNodes": "节点",
|
||||
"graphStatsEdges": "边",
|
||||
"graphLegendDiscovered": "discovered_on",
|
||||
"graphLegendLeads": "leads_to",
|
||||
"graphLegendExploits": "exploits",
|
||||
"graphLegendTentative": "待确认(虚线)",
|
||||
"factLinksLabel": "关系边(from → 本事实)",
|
||||
"factLinksPlaceholder": "discovered_on: target/primary_domain\nexploits: exploit/upload-rce",
|
||||
"factLinksHint": "每行一条:type: source_fact_key(来源 → 当前事实)。常用 type:discovered_on、depends_on、leads_to、enables、exploits。保存时替换全部关系边。",
|
||||
"factIncomingLinksLabel": "入边(只读)",
|
||||
"factIncomingLinksHint": "由来源事实的出边产生。例如 finding 的 discovered_on → target/*,在目标上会显示为入边;请编辑来源事实的出边。",
|
||||
"factIncomingLinksEmpty": "暂无入边",
|
||||
"graphEdgeFromSelf": "本节点指出",
|
||||
"graphEdgeToSelf": "指向本节点",
|
||||
"linksColumn": "关系",
|
||||
"linkCountsTitle": "出边数 / 入边数",
|
||||
"graphConnect": "连边",
|
||||
"graphConnectActive": "连边中…",
|
||||
"graphConnectPickTarget": "已选 {{source}},请点击目标节点",
|
||||
"graphEdgeTypePrompt": "边类型(discovered_on / leads_to / depends_on / enables / exploits)",
|
||||
"graphConnectFailed": "创建边失败",
|
||||
"graphConnectSuccess": "边已创建",
|
||||
"graphEdgesTitle": "关系边",
|
||||
"graphEdgesHint": "箭头方向与数据库/编辑弹窗一致(source → target);点击连线可定位。",
|
||||
"graphEdgesEmpty": "暂无关系边",
|
||||
"graphEdgeOutgoing": "出边",
|
||||
"graphEdgeIncoming": "入边",
|
||||
"graphEdgeSynthetic": "由事实关联自动生成,请编辑事实解除",
|
||||
"confirmDeleteGraphEdge": "确定删除此关系边?",
|
||||
"graphEdgeDeleteFailed": "删除边失败",
|
||||
"graphEdgeDeleteSuccess": "边已删除",
|
||||
"graphDeleteEdge": "删边",
|
||||
"promoteAttackChain": "沉淀攻击链",
|
||||
"promoteAttackChainTitle": "将对话攻击链沉淀为项目事实与边",
|
||||
"confirmPromoteAttackChain": "将该对话的攻击链沉淀到本项目?会创建/更新事实与关系边。",
|
||||
"promoteAttackChainFailed": "沉淀失败",
|
||||
"promoteAttackChainSuccess": "已沉淀:新建 {{facts_created}} / 更新 {{facts_updated}} / 边 {{edges_created}}",
|
||||
"searchFactsSr": "搜索事实",
|
||||
"searchFactsPlaceholder": "搜索 key、摘要、body…",
|
||||
"category": "分类",
|
||||
@@ -424,6 +477,9 @@
|
||||
"conversationGroups": "对话分组",
|
||||
"addGroup": "新建分组",
|
||||
"recentConversations": "最近对话",
|
||||
"sortConversations": "排序",
|
||||
"sortByCreatedAt": "创建时间",
|
||||
"sortByUpdatedAt": "更新时间",
|
||||
"batchManage": "批量管理",
|
||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
||||
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||
@@ -664,7 +720,12 @@
|
||||
"viewConversation": "查看对话",
|
||||
"viewVulnerabilities": "查看漏洞",
|
||||
"viewVulnerabilitiesQueueTitle": "查看漏洞:打开漏洞管理并筛选本队列",
|
||||
"retryTask": "重试",
|
||||
"runSingleTask": "单条执行",
|
||||
"confirmRunSingleTask": "确定执行该任务?仅运行这一条,完成后队列会自动暂停,不会继续执行其他待执行项。",
|
||||
"runSingleTaskFailed": "单条执行失败",
|
||||
"runSingleTaskUnavailable": "队列或任务执行中,暂无法单条执行",
|
||||
"runSingleTaskUnavailableSelf": "该任务正在执行中",
|
||||
"runSingleTaskUnavailableQueue": "队列批量执行中,请暂停后再单条执行其它任务",
|
||||
"conversationIdLabel": "对话ID",
|
||||
"statusPending": "待执行",
|
||||
"statusPaused": "已暂停",
|
||||
@@ -1071,6 +1132,7 @@
|
||||
"botAgent": "Bot Agent",
|
||||
"ilinkBotId": "iLink Bot ID(绑定后自动填充)",
|
||||
"boundSuccess": "绑定成功,微信机器人已启用。",
|
||||
"alreadyBound": "该微信已绑定过,无需重复绑定。",
|
||||
"openLink": "无法显示二维码?点击用手机微信打开链接"
|
||||
},
|
||||
"wecom": {
|
||||
@@ -1926,6 +1988,13 @@
|
||||
"openaiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||
"openaiApiKeyPlaceholder": "输入OpenAI API Key",
|
||||
"modelPlaceholder": "gpt-4",
|
||||
"fetchModels": "获取列表",
|
||||
"modelsListFetching": "正在获取模型列表...",
|
||||
"modelsListSelectPlaceholder": "请选择模型",
|
||||
"modelsListSuccess": "已加载 {count} 个模型,请用右侧下拉框选择,或继续在左侧输入",
|
||||
"modelsListFailed": "获取模型列表失败",
|
||||
"modelsListNeedApiKey": "请先填写 API Key",
|
||||
"modelsListClaudeHint": "Claude 不支持自动获取模型列表,请手动填写",
|
||||
"maxTotalTokens": "最大上下文 Token 数",
|
||||
"maxTotalTokensPlaceholder": "120000",
|
||||
"maxTotalTokensHint": "内存压缩和攻击链构建共用此配置,默认 120000",
|
||||
@@ -2074,14 +2143,35 @@
|
||||
"filterResult": "结果",
|
||||
"pageSize": "每页",
|
||||
"statTotal": "当前筛选",
|
||||
"statSuccess": "成功",
|
||||
"statFailures": "失败",
|
||||
"statRecent7d": "近 7 天",
|
||||
"retentionHint": "审计记录保留 {{days}} 天,超期自动清理。",
|
||||
"disabledHint": "审计功能已关闭,新操作不会写入审计表。",
|
||||
"filterSince": "开始时间",
|
||||
"filterUntil": "结束时间",
|
||||
"filterTimeZone": "时区:{{tz}}(筛选按浏览器本地时间)",
|
||||
"datetimePlaceholder": "选择日期时间",
|
||||
"timePresets": "快捷",
|
||||
"preset15m": "最近15分钟",
|
||||
"preset1h": "最近1小时",
|
||||
"preset24h": "最近24小时",
|
||||
"preset7d": "最近7天",
|
||||
"presetToday": "今天",
|
||||
"pickerHour": "时",
|
||||
"pickerMinute": "分",
|
||||
"pickerClear": "清除",
|
||||
"pickerToday": "今天",
|
||||
"pickerConfirm": "确定",
|
||||
"filterQuery": "关键词",
|
||||
"filterQueryPlaceholder": "消息 / 资源 ID / 操作名",
|
||||
"colTime": "时间",
|
||||
"colMessage": "说明",
|
||||
"colCategory": "类别",
|
||||
"colAction": "操作",
|
||||
"colResult": "结果",
|
||||
"colIp": "IP",
|
||||
"colResource": "资源 ID",
|
||||
"cat": {
|
||||
"auth": "认证",
|
||||
"config": "配置",
|
||||
@@ -2154,6 +2244,93 @@
|
||||
"exportDone": "导出完成",
|
||||
"loading": "加载中...",
|
||||
"empty": "暂无审计记录",
|
||||
"result": {
|
||||
"success": "成功",
|
||||
"failure": "失败"
|
||||
},
|
||||
"msg": {
|
||||
"auth": {
|
||||
"login": "登录成功",
|
||||
"login_failed": "登录失败:密码错误",
|
||||
"logout": "退出登录",
|
||||
"change_password": "登录密码已修改",
|
||||
"change_password_failed": "修改密码失败:当前密码不正确"
|
||||
},
|
||||
"config": {
|
||||
"apply": "配置已应用",
|
||||
"update": "更新内存配置",
|
||||
"apply_fail_kb_init": "应用配置失败:初始化知识库",
|
||||
"apply_fail_kb_reinit": "应用配置失败:重新初始化知识库",
|
||||
"apply_fail_c2": "应用配置失败:C2"
|
||||
},
|
||||
"conversation": {
|
||||
"create": "创建对话",
|
||||
"delete": "删除对话",
|
||||
"delete_turn": "删除对话轮次"
|
||||
},
|
||||
"c2": {
|
||||
"listener_create": "创建 C2 监听器",
|
||||
"listener_delete": "删除 C2 监听器",
|
||||
"listener_start": "启动 C2 监听器",
|
||||
"listener_stop": "停止 C2 监听器",
|
||||
"session_delete": "删除 C2 会话",
|
||||
"task_create": "创建 C2 任务",
|
||||
"task_cancel": "取消 C2 任务",
|
||||
"task_delete": "批量删除 C2 任务"
|
||||
},
|
||||
"webshell": {
|
||||
"connection_create": "创建 WebShell 连接",
|
||||
"connection_delete": "删除 WebShell 连接"
|
||||
},
|
||||
"knowledge": {
|
||||
"item_delete": "删除知识项",
|
||||
"index_rebuild": "重建知识库索引"
|
||||
},
|
||||
"vulnerability": {
|
||||
"create": "创建漏洞记录",
|
||||
"update": "更新漏洞记录",
|
||||
"delete": "删除漏洞记录",
|
||||
"delete_batch": "批量删除漏洞记录"
|
||||
},
|
||||
"external_mcp": {
|
||||
"upsert": "更新外部 MCP 配置",
|
||||
"delete": "删除外部 MCP 配置"
|
||||
},
|
||||
"task": {
|
||||
"create_queue": "创建批量任务队列",
|
||||
"start_queue": "启动批量任务队列",
|
||||
"delete_queue": "删除批量任务队列",
|
||||
"pause_queue": "暂停批量任务队列",
|
||||
"rerun_queue": "重跑批量任务队列",
|
||||
"delete_batch_task": "删除批量子任务"
|
||||
},
|
||||
"tool": {
|
||||
"execution_delete": "删除工具执行记录",
|
||||
"execution_delete_batch": "批量删除工具执行记录"
|
||||
},
|
||||
"file": {
|
||||
"upload": "上传对话附件",
|
||||
"delete": "删除对话附件"
|
||||
},
|
||||
"hitl": {
|
||||
"decision": "HITL 审批决策"
|
||||
},
|
||||
"role": {
|
||||
"create": "创建角色",
|
||||
"update": "更新角色",
|
||||
"delete": "删除角色"
|
||||
},
|
||||
"skill": {
|
||||
"create": "创建 Skill",
|
||||
"update": "更新 Skill",
|
||||
"delete": "删除 Skill"
|
||||
},
|
||||
"agent": {
|
||||
"markdown_create": "创建 Markdown 子代理",
|
||||
"markdown_update": "更新 Markdown 子代理",
|
||||
"markdown_delete": "删除 Markdown 子代理"
|
||||
}
|
||||
},
|
||||
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}} 条",
|
||||
"detailTitle": "审计详情",
|
||||
"detailTime": "时间",
|
||||
@@ -2232,7 +2409,8 @@
|
||||
"copyContent": "复制内容",
|
||||
"correctInfo": "正确信息",
|
||||
"errorInfo": "错误信息",
|
||||
"copyError": "复制错误"
|
||||
"copyError": "复制错误",
|
||||
"contentTruncated": "…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)"
|
||||
},
|
||||
"attackChainModal": {
|
||||
"title": "攻击链可视化",
|
||||
@@ -2562,6 +2740,11 @@
|
||||
},
|
||||
"c2": {
|
||||
"clipboardCopied": "已复制到剪贴板",
|
||||
"common": {
|
||||
"justNow": "刚刚",
|
||||
"minutesAgo": "{{n}} 分钟前",
|
||||
"hoursAgo": "{{n}} 小时前"
|
||||
},
|
||||
"fmt": {
|
||||
"durationMs": "{{n}}ms",
|
||||
"durationSec": "{{n}}秒",
|
||||
@@ -2619,6 +2802,8 @@
|
||||
"bindHintExternal": "使用 0.0.0.0 允许外部访问",
|
||||
"callbackHost": "回连地址(可选)",
|
||||
"callbackHostHint": "公网 IP 或域名,写入配置供 Payload/Beacon 使用;与「绑定地址」分离。不填则生成 Payload 时按绑定地址或自动探测。",
|
||||
"allowLegacyShell": "允许未加密经典反弹 Shell(内网实验)",
|
||||
"allowLegacyShellHint": "默认关闭。开启后 bash/nc 等裸 TCP 连接可登记会话,公网易被扫描器误连;生产环境请使用「生成 Beacon」加密上线。",
|
||||
"malleableProfile": "Malleable Profile",
|
||||
"malleableProfileHint": "可选;用于 HTTP/HTTPS Beacon 服务端响应头等流量伪装。修改后需停止并重新启动监听器才会生效。",
|
||||
"malleableProfileNone": "不使用",
|
||||
@@ -2696,10 +2881,22 @@
|
||||
"infoFirstSeen": "首次上线",
|
||||
"infoLastCheckin": "上次心跳",
|
||||
"infoNote": "备注",
|
||||
"infoNoteEmpty": "暂无备注",
|
||||
"infoSectionIdentity": "身份信息",
|
||||
"infoSectionSystem": "系统环境",
|
||||
"infoSectionNetwork": "网络与信标",
|
||||
"infoSectionTimeline": "时间线",
|
||||
"infoSectionNote": "备注",
|
||||
"adminYes": "是",
|
||||
"adminNo": "否",
|
||||
"promptSleepSeconds": "Sleep 间隔(秒)",
|
||||
"promptJitterPercent": "抖动百分比(0–100)",
|
||||
"sleepModalHint": "保存后将写入服务端并下发 sleep 任务;植入体在下次拉取任务后生效,同时后续心跳会同步该配置。",
|
||||
"sleepModalTitle": "心跳配置",
|
||||
"sleepModalCurrent": "当前 {{sec}} 秒 · 抖动 {{jitter}}%",
|
||||
"sleepModalPreview": "预计间隔 {{min}} – {{max}} 秒",
|
||||
"sleepModalPresets": "快捷",
|
||||
"toastSleepInvalid": "Sleep 间隔至少为 1 秒",
|
||||
"toastSleepUpdated": "Sleep 设置已更新",
|
||||
"confirmExitSession": "向该会话发送退出指令?",
|
||||
"confirmDeleteSession": "从服务器删除此会话及其关联任务与文件记录?(不会向植入体发送退出;若需退出目标进程请使用「终止会话」。)",
|
||||
@@ -2717,7 +2914,25 @@
|
||||
"termWaitFinish": "请等待当前命令执行完成",
|
||||
"termCtrlC": "当前版本暂不支持中断远程命令",
|
||||
"termQueued": "[命令已加入队列,将在当前任务完成后执行]",
|
||||
"clearTerminal": "清屏"
|
||||
"clearTerminal": "清屏",
|
||||
"batchDelete": "批量删除",
|
||||
"deleteFiltered": "删除筛选结果",
|
||||
"selectAll": "全选",
|
||||
"filterAllStatus": "全部状态",
|
||||
"filterAllListeners": "全部监听器",
|
||||
"filterSearchPlaceholder": "搜索主机名 / 用户 / IP",
|
||||
"filterApply": "筛选",
|
||||
"filterReset": "重置",
|
||||
"filterSuspicious": "疑似误报",
|
||||
"filterCount": "共 {{n}} 条,已选 {{selected}}",
|
||||
"emptyFilter": "没有符合筛选条件的会话",
|
||||
"listEmpty": "暂无会话",
|
||||
"selectPromptTitle": "选择会话",
|
||||
"selectPromptHint": "在左侧列表中点击一个会话,查看终端、文件与任务详情。",
|
||||
"confirmBatchDelete": "确定删除选中的 {{n}} 个会话?关联任务与文件记录将一并清除。",
|
||||
"confirmDeleteFiltered": "确定删除当前筛选结果中的全部 {{n}} 个会话?",
|
||||
"toastSelectFirst": "请先勾选要删除的会话",
|
||||
"toastBatchDeleted": "已删除 {{n}} 个会话"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "任务管理",
|
||||
@@ -2740,6 +2955,8 @@
|
||||
"pending": "待处理",
|
||||
"emptyAll": "暂无任务",
|
||||
"emptySession": "该会话暂无任务",
|
||||
"sessionTaskHistory": "任务历史",
|
||||
"sessionTaskCount": "共 {{n}} 条",
|
||||
"colTask": "任务",
|
||||
"colSession": "会话",
|
||||
"colType": "类型",
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Audit log datetime picker — cross-browser, locale-aware (SLS-style calendar + time columns).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var registry = {};
|
||||
var popover = null;
|
||||
var activeFieldId = null;
|
||||
var draft = null;
|
||||
var viewYear = 0;
|
||||
var viewMonth = 0;
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function pickerLocale() {
|
||||
if (typeof auditLocale === 'function') return auditLocale();
|
||||
if (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) return 'zh-CN';
|
||||
return 'en-US';
|
||||
}
|
||||
|
||||
function pickerT(key, fallback) {
|
||||
if (typeof auditT === 'function') return auditT(key, null, fallback);
|
||||
if (typeof t === 'function') {
|
||||
var v = t(key);
|
||||
if (v && v !== key) return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function partsToStorage(p) {
|
||||
if (!p) return '';
|
||||
return p.y + '-' + pad2(p.m) + '-' + pad2(p.d) + 'T' + pad2(p.h) + ':' + pad2(p.mi);
|
||||
}
|
||||
|
||||
function parseStorage(value) {
|
||||
if (!value) return null;
|
||||
var m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(String(value).trim());
|
||||
if (!m) return null;
|
||||
return { y: +m[1], m: +m[2], d: +m[3], h: +m[4], mi: +m[5] };
|
||||
}
|
||||
|
||||
function formatDisplay(parts) {
|
||||
if (!parts) return '';
|
||||
var loc = pickerLocale();
|
||||
try {
|
||||
var d = new Date(parts.y, parts.m - 1, parts.d, parts.h, parts.mi, 0, 0);
|
||||
return d.toLocaleString(loc, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
} catch (_) {
|
||||
return partsToStorage(parts).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
|
||||
function nowParts() {
|
||||
var n = new Date();
|
||||
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate(), h: n.getHours(), mi: n.getMinutes() };
|
||||
}
|
||||
|
||||
function startOfTodayParts() {
|
||||
var n = new Date();
|
||||
return { y: n.getFullYear(), m: n.getMonth() + 1, d: n.getDate(), h: 0, mi: 0 };
|
||||
}
|
||||
|
||||
function monthTitle(year, month) {
|
||||
var loc = pickerLocale();
|
||||
if (loc.startsWith('zh')) {
|
||||
return year + '\u5e74' + pad2(month) + '\u6708';
|
||||
}
|
||||
try {
|
||||
return new Date(year, month - 1, 1).toLocaleString(loc, { month: 'long', year: 'numeric' });
|
||||
} catch (_) {
|
||||
return year + '-' + pad2(month);
|
||||
}
|
||||
}
|
||||
|
||||
function weekdayHeaders() {
|
||||
var loc = pickerLocale();
|
||||
if (loc.startsWith('zh')) {
|
||||
return ['\u65e5', '\u4e00', '\u4e8c', '\u4e09', '\u56db', '\u4e94', '\u516d'];
|
||||
}
|
||||
return ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
}
|
||||
|
||||
function buildMonthGrid(year, month) {
|
||||
var first = new Date(year, month - 1, 1);
|
||||
var start = new Date(first);
|
||||
start.setDate(first.getDate() - first.getDay());
|
||||
var cells = [];
|
||||
var cursor = new Date(start);
|
||||
for (var i = 0; i < 42; i++) {
|
||||
cells.push({
|
||||
y: cursor.getFullYear(),
|
||||
m: cursor.getMonth() + 1,
|
||||
d: cursor.getDate(),
|
||||
inMonth: cursor.getMonth() === month - 1
|
||||
});
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function ensurePopover() {
|
||||
if (popover) return popover;
|
||||
popover = document.createElement('div');
|
||||
popover.className = 'audit-dt-popover';
|
||||
popover.hidden = true;
|
||||
popover.setAttribute('role', 'dialog');
|
||||
popover.innerHTML =
|
||||
'<div class="audit-dt-popover-inner">' +
|
||||
'<div class="audit-dt-head">' +
|
||||
'<button type="button" class="audit-dt-nav" data-nav="prev" aria-label="prev">‹</button>' +
|
||||
'<span class="audit-dt-month-label"></span>' +
|
||||
'<button type="button" class="audit-dt-nav" data-nav="next" aria-label="next">›</button>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-body">' +
|
||||
'<div class="audit-dt-calendar"></div>' +
|
||||
'<div class="audit-dt-time">' +
|
||||
'<div class="audit-dt-time-col" data-part="hour">' +
|
||||
'<span class="audit-dt-time-label audit-dt-hour-label"></span>' +
|
||||
'<div class="audit-dt-time-list"></div>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-time-col" data-part="minute">' +
|
||||
'<span class="audit-dt-time-label audit-dt-minute-label"></span>' +
|
||||
'<div class="audit-dt-time-list"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="audit-dt-footer">' +
|
||||
'<button type="button" class="audit-dt-footer-btn" data-action="clear"></button>' +
|
||||
'<button type="button" class="audit-dt-footer-btn" data-action="today"></button>' +
|
||||
'<button type="button" class="audit-dt-footer-btn audit-dt-footer-btn--primary" data-action="confirm"></button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(popover);
|
||||
|
||||
popover.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
var btn = ev.target.closest('[data-nav]');
|
||||
if (btn) {
|
||||
if (btn.getAttribute('data-nav') === 'prev') {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 1) { viewMonth = 12; viewYear -= 1; }
|
||||
} else {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 12) { viewMonth = 1; viewYear += 1; }
|
||||
}
|
||||
renderPopover();
|
||||
return;
|
||||
}
|
||||
var dayBtn = ev.target.closest('[data-day]');
|
||||
if (dayBtn && draft) {
|
||||
draft.y = +dayBtn.getAttribute('data-y');
|
||||
draft.m = +dayBtn.getAttribute('data-m');
|
||||
draft.d = +dayBtn.getAttribute('data-d');
|
||||
if (draft.y !== viewYear || draft.m !== viewMonth) {
|
||||
viewYear = draft.y;
|
||||
viewMonth = draft.m;
|
||||
renderCalendar();
|
||||
} else {
|
||||
updateDaySelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
var timeBtn = ev.target.closest('[data-time]');
|
||||
if (timeBtn && draft) {
|
||||
var part = timeBtn.getAttribute('data-part');
|
||||
var val = +timeBtn.getAttribute('data-time');
|
||||
if (part === 'hour') draft.h = val;
|
||||
if (part === 'minute') draft.mi = val;
|
||||
updateTimeSelection();
|
||||
return;
|
||||
}
|
||||
var actionBtn = ev.target.closest('[data-action]');
|
||||
if (!actionBtn) return;
|
||||
var action = actionBtn.getAttribute('data-action');
|
||||
if (action === 'clear') {
|
||||
applyValue(activeFieldId, '');
|
||||
closePopover();
|
||||
} else if (action === 'today') {
|
||||
if (draft) {
|
||||
var t = nowParts();
|
||||
draft.y = t.y; draft.m = t.m; draft.d = t.d;
|
||||
viewYear = t.y; viewMonth = t.m;
|
||||
}
|
||||
renderPopover();
|
||||
} else if (action === 'confirm') {
|
||||
applyValue(activeFieldId, partsToStorage(draft));
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
document.addEventListener('languagechange', function () {
|
||||
if (!popover.hidden) renderPopover();
|
||||
refreshAllDisplays();
|
||||
});
|
||||
|
||||
return popover;
|
||||
}
|
||||
|
||||
function onDocumentClick(ev) {
|
||||
if (!popover || popover.hidden) return;
|
||||
if (popover.contains(ev.target)) return;
|
||||
if (activeFieldId && registry[activeFieldId] && registry[activeFieldId].wrap.contains(ev.target)) return;
|
||||
closePopover();
|
||||
}
|
||||
|
||||
function onDocumentKeydown(ev) {
|
||||
if (ev.key === 'Escape' && popover && !popover.hidden) {
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(fieldWrap) {
|
||||
var rect = fieldWrap.getBoundingClientRect();
|
||||
var width = 320;
|
||||
popover.style.width = width + 'px';
|
||||
var left = rect.left;
|
||||
if (left + width > window.innerWidth - 12) {
|
||||
left = Math.max(12, window.innerWidth - width - 12);
|
||||
}
|
||||
popover.style.left = left + 'px';
|
||||
var top = rect.bottom + 6;
|
||||
if (top + 340 > window.innerHeight - 12) {
|
||||
top = Math.max(12, rect.top - 340 - 6);
|
||||
}
|
||||
popover.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelector('.audit-dt-month-label').textContent = monthTitle(viewYear, viewMonth);
|
||||
var cal = popover.querySelector('.audit-dt-calendar');
|
||||
var headers = weekdayHeaders();
|
||||
var html = '<div class="audit-dt-weekdays">';
|
||||
headers.forEach(function (h) { html += '<span>' + h + '</span>'; });
|
||||
html += '</div><div class="audit-dt-days">';
|
||||
buildMonthGrid(viewYear, viewMonth).forEach(function (cell) {
|
||||
var cls = 'audit-dt-day';
|
||||
if (!cell.inMonth) cls += ' is-other-month';
|
||||
if (draft && cell.y === draft.y && cell.m === draft.m && cell.d === draft.d) cls += ' is-selected';
|
||||
html += '<button type="button" class="' + cls + '" data-day="1" data-y="' + cell.y +
|
||||
'" data-m="' + cell.m + '" data-d="' + cell.d + '">' + cell.d + '</button>';
|
||||
});
|
||||
html += '</div>';
|
||||
cal.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderTimeLists() {
|
||||
if (!popover || !draft) return;
|
||||
var hourList = popover.querySelector('[data-part="hour"] .audit-dt-time-list');
|
||||
var minuteList = popover.querySelector('[data-part="minute"] .audit-dt-time-list');
|
||||
var hourHtml = '';
|
||||
var minuteHtml = '';
|
||||
var h;
|
||||
for (h = 0; h < 24; h++) {
|
||||
hourHtml += '<button type="button" class="audit-dt-time-item' + (draft && draft.h === h ? ' is-selected' : '') +
|
||||
'" data-part="hour" data-time="' + h + '">' + pad2(h) + '</button>';
|
||||
}
|
||||
for (h = 0; h < 60; h++) {
|
||||
minuteHtml += '<button type="button" class="audit-dt-time-item' + (draft && draft.mi === h ? ' is-selected' : '') +
|
||||
'" data-part="minute" data-time="' + h + '">' + pad2(h) + '</button>';
|
||||
}
|
||||
hourList.innerHTML = hourHtml;
|
||||
minuteList.innerHTML = minuteHtml;
|
||||
scrollTimeSelection(hourList, draft.h);
|
||||
scrollTimeSelection(minuteList, draft.mi);
|
||||
}
|
||||
|
||||
function updateDaySelection() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelectorAll('.audit-dt-day').forEach(function (btn) {
|
||||
var selected = +btn.getAttribute('data-y') === draft.y &&
|
||||
+btn.getAttribute('data-m') === draft.m &&
|
||||
+btn.getAttribute('data-d') === draft.d;
|
||||
btn.classList.toggle('is-selected', selected);
|
||||
});
|
||||
}
|
||||
|
||||
function updateTimeSelection() {
|
||||
if (!popover || !draft) return;
|
||||
var hourList = popover.querySelector('[data-part="hour"] .audit-dt-time-list');
|
||||
var minuteList = popover.querySelector('[data-part="minute"] .audit-dt-time-list');
|
||||
if (!hourList || !minuteList || !hourList.children.length) {
|
||||
renderTimeLists();
|
||||
return;
|
||||
}
|
||||
hourList.querySelectorAll('.audit-dt-time-item').forEach(function (btn) {
|
||||
btn.classList.toggle('is-selected', +btn.getAttribute('data-time') === draft.h);
|
||||
});
|
||||
minuteList.querySelectorAll('.audit-dt-time-item').forEach(function (btn) {
|
||||
btn.classList.toggle('is-selected', +btn.getAttribute('data-time') === draft.mi);
|
||||
});
|
||||
scrollTimeSelection(hourList, draft.h);
|
||||
scrollTimeSelection(minuteList, draft.mi);
|
||||
}
|
||||
|
||||
function renderPopover() {
|
||||
if (!popover || !draft) return;
|
||||
popover.querySelector('.audit-dt-hour-label').textContent = pickerT('settingsAudit.pickerHour', 'Hour');
|
||||
popover.querySelector('.audit-dt-minute-label').textContent = pickerT('settingsAudit.pickerMinute', 'Min');
|
||||
popover.querySelector('[data-action="clear"]').textContent = pickerT('settingsAudit.pickerClear', 'Clear');
|
||||
popover.querySelector('[data-action="today"]').textContent = pickerT('settingsAudit.pickerToday', 'Today');
|
||||
popover.querySelector('[data-action="confirm"]').textContent = pickerT('settingsAudit.pickerConfirm', 'OK');
|
||||
renderCalendar();
|
||||
renderTimeLists();
|
||||
}
|
||||
|
||||
function scrollTimeSelection(listEl, value) {
|
||||
var sel = listEl.querySelector('.is-selected');
|
||||
if (sel && sel.scrollIntoView) {
|
||||
sel.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function openPopover(fieldId) {
|
||||
ensurePopover();
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
activeFieldId = fieldId;
|
||||
var stored = entry.wrap.dataset.value || '';
|
||||
draft = parseStorage(stored) || nowParts();
|
||||
viewYear = draft.y;
|
||||
viewMonth = draft.m;
|
||||
renderPopover();
|
||||
positionPopover(entry.wrap);
|
||||
popover.hidden = false;
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
if (!popover) return;
|
||||
popover.hidden = true;
|
||||
activeFieldId = null;
|
||||
draft = null;
|
||||
}
|
||||
|
||||
function refreshDisplay(fieldId) {
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
var parts = parseStorage(entry.wrap.dataset.value || '');
|
||||
entry.input.value = parts ? formatDisplay(parts) : '';
|
||||
entry.input.placeholder = pickerT('settingsAudit.datetimePlaceholder', 'Select date & time');
|
||||
entry.clearBtn.hidden = !parts;
|
||||
}
|
||||
|
||||
function refreshAllDisplays() {
|
||||
Object.keys(registry).forEach(refreshDisplay);
|
||||
}
|
||||
|
||||
function applyValue(fieldId, storageValue) {
|
||||
var entry = registry[fieldId];
|
||||
if (!entry) return;
|
||||
entry.wrap.dataset.value = storageValue || '';
|
||||
refreshDisplay(fieldId);
|
||||
}
|
||||
|
||||
function bindField(fieldId) {
|
||||
var wrap = document.getElementById(fieldId);
|
||||
if (!wrap || wrap.dataset.auditDtBound === '1') return;
|
||||
var input = wrap.querySelector('.audit-datetime-input');
|
||||
var openBtn = wrap.querySelector('.audit-datetime-open-btn');
|
||||
var clearBtn = wrap.querySelector('.audit-datetime-clear-btn');
|
||||
if (!input || !openBtn || !clearBtn) return;
|
||||
|
||||
wrap.dataset.auditDtBound = '1';
|
||||
registry[fieldId] = { wrap: wrap, input: input, clearBtn: clearBtn };
|
||||
|
||||
openBtn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (!popover || popover.hidden || activeFieldId !== fieldId) {
|
||||
openPopover(fieldId);
|
||||
} else {
|
||||
closePopover();
|
||||
}
|
||||
});
|
||||
input.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
openPopover(fieldId);
|
||||
});
|
||||
clearBtn.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
applyValue(fieldId, '');
|
||||
});
|
||||
refreshDisplay(fieldId);
|
||||
}
|
||||
|
||||
window.AuditDatetimePicker = {
|
||||
init: function () {
|
||||
bindField('audit-filter-since-field');
|
||||
bindField('audit-filter-until-field');
|
||||
refreshAllDisplays();
|
||||
},
|
||||
getValue: function (inputId) {
|
||||
var fieldId = inputId === 'audit-filter-since' ? 'audit-filter-since-field' : 'audit-filter-until-field';
|
||||
var entry = registry[fieldId];
|
||||
return entry ? (entry.wrap.dataset.value || '') : '';
|
||||
},
|
||||
setValue: function (inputId, dateObj) {
|
||||
if (!dateObj || Number.isNaN(dateObj.getTime())) return;
|
||||
var fieldId = inputId === 'audit-filter-since' ? 'audit-filter-since-field' : 'audit-filter-until-field';
|
||||
var p = {
|
||||
y: dateObj.getFullYear(),
|
||||
m: dateObj.getMonth() + 1,
|
||||
d: dateObj.getDate(),
|
||||
h: dateObj.getHours(),
|
||||
mi: dateObj.getMinutes()
|
||||
};
|
||||
applyValue(fieldId, partsToStorage(p));
|
||||
},
|
||||
clearAll: function () {
|
||||
applyValue('audit-filter-since-field', '');
|
||||
applyValue('audit-filter-until-field', '');
|
||||
closePopover();
|
||||
}
|
||||
};
|
||||
})();
|
||||
+352
-56
@@ -4,6 +4,7 @@
|
||||
let auditLogsPage = 1;
|
||||
let auditLogsPageSize = 20;
|
||||
let auditLogsTotal = 0;
|
||||
let auditLogsCache = [];
|
||||
|
||||
const AUDIT_PAGE_SIZE_KEY = 'cyberstrike_audit_page_size';
|
||||
|
||||
@@ -52,24 +53,113 @@ function auditActionLabel(action) {
|
||||
return auditT('settingsAudit.act.' + action, null, action);
|
||||
}
|
||||
|
||||
/** Stored DB messages that share category+action but need distinct i18n keys. */
|
||||
const AUDIT_MSG_BY_STORED_TEXT = {
|
||||
'登录失败:密码错误': 'settingsAudit.msg.auth.login_failed',
|
||||
'修改密码失败:当前密码不正确': 'settingsAudit.msg.auth.change_password_failed',
|
||||
'应用配置失败:初始化知识库': 'settingsAudit.msg.config.apply_fail_kb_init',
|
||||
'应用配置失败:重新初始化知识库': 'settingsAudit.msg.config.apply_fail_kb_reinit',
|
||||
'应用配置失败:C2': 'settingsAudit.msg.config.apply_fail_c2'
|
||||
};
|
||||
|
||||
function auditMessageLabel(log) {
|
||||
if (!log) return '';
|
||||
const raw = (log.message || '').trim();
|
||||
if (raw && AUDIT_MSG_BY_STORED_TEXT[raw]) {
|
||||
return auditT(AUDIT_MSG_BY_STORED_TEXT[raw], null, raw);
|
||||
}
|
||||
const cat = (log.category || '').trim();
|
||||
const act = (log.action || '').trim();
|
||||
const res = (log.result || '').trim();
|
||||
if (cat && act) {
|
||||
if (cat === 'auth' && act === 'login' && res === 'failure') {
|
||||
return auditT('settingsAudit.msg.auth.login_failed', null, raw);
|
||||
}
|
||||
if (cat === 'auth' && act === 'change_password' && res === 'failure') {
|
||||
return auditT('settingsAudit.msg.auth.change_password_failed', null, raw);
|
||||
}
|
||||
const key = 'settingsAudit.msg.' + cat + '.' + act;
|
||||
const translated = auditT(key, null, null);
|
||||
if (translated && translated !== key) return translated;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function auditResultLabel(result) {
|
||||
if (!result) return '';
|
||||
return auditT('settingsAudit.result.' + result, null, result);
|
||||
}
|
||||
|
||||
function auditLocale() {
|
||||
if (typeof window.__locale === 'string' && window.__locale.length) {
|
||||
return window.__locale.startsWith('zh') ? 'zh-CN' : 'en-US';
|
||||
}
|
||||
return (typeof navigator !== 'undefined' && navigator.language) ? navigator.language : 'en-US';
|
||||
}
|
||||
|
||||
function auditTimezoneShortLabel() {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat(auditLocale(), { timeZoneName: 'short' }).formatToParts(new Date());
|
||||
const tz = parts.find(function (p) { return p.type === 'timeZoneName'; });
|
||||
return tz ? tz.value : '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatAuditTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
return d.toLocaleString(auditLocale(), {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
} catch (_) {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read stored local datetime (YYYY-MM-DDTHH:mm) from custom picker or raw input. */
|
||||
function getAuditFilterDatetimeValue(inputId) {
|
||||
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.getValue === 'function') {
|
||||
return window.AuditDatetimePicker.getValue(inputId) || '';
|
||||
}
|
||||
var el = document.getElementById(inputId);
|
||||
return el ? (el.value || '') : '';
|
||||
}
|
||||
|
||||
/** datetime-local / picker storage -> UTC RFC3339 for API. */
|
||||
function auditDatetimeLocalToRFC3339(value) {
|
||||
if (!value || !value.trim()) return '';
|
||||
const d = new Date(value);
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value.trim());
|
||||
if (!m) return '';
|
||||
const d = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], 0, 0);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
function updateAuditTimezoneHint() {
|
||||
const el = document.getElementById('audit-filter-timezone-hint');
|
||||
if (!el) return;
|
||||
const tz = auditTimezoneShortLabel();
|
||||
if (!tz) {
|
||||
el.hidden = true;
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.textContent = auditT('settingsAudit.filterTimeZone', { tz: tz },
|
||||
'时区:' + tz + '(筛选按浏览器本地时间,API 使用 UTC)');
|
||||
}
|
||||
|
||||
function initAuditPageSizeFromStorage() {
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem(AUDIT_PAGE_SIZE_KEY), 10);
|
||||
@@ -113,6 +203,7 @@ function rebuildAuditActionSelect() {
|
||||
actEl.disabled = true;
|
||||
actEl.value = '';
|
||||
actEl.title = hint;
|
||||
syncAuditCustomSelect('audit-filter-action');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,6 +220,7 @@ function rebuildAuditActionSelect() {
|
||||
if (prev && Array.prototype.some.call(actEl.options, function (o) { return o.value === prev; })) {
|
||||
actEl.value = prev;
|
||||
}
|
||||
syncAuditCustomSelect('audit-filter-action');
|
||||
}
|
||||
|
||||
function onAuditCategoryFilterChange() {
|
||||
@@ -145,43 +237,17 @@ function buildAuditQueryParams(forExport) {
|
||||
const act = document.getElementById('audit-filter-action');
|
||||
const res = document.getElementById('audit-filter-result');
|
||||
const q = document.getElementById('audit-filter-q');
|
||||
const since = document.getElementById('audit-filter-since');
|
||||
const until = document.getElementById('audit-filter-until');
|
||||
if (cat && cat.value) params.set('category', cat.value);
|
||||
if (act && !act.disabled && act.value) params.set('action', act.value);
|
||||
if (res && res.value) params.set('result', res.value);
|
||||
if (q && q.value.trim()) params.set('q', q.value.trim());
|
||||
const sinceISO = since ? auditDatetimeLocalToRFC3339(since.value) : '';
|
||||
const untilISO = until ? auditDatetimeLocalToRFC3339(until.value) : '';
|
||||
const sinceISO = auditDatetimeLocalToRFC3339(getAuditFilterDatetimeValue('audit-filter-since'));
|
||||
const untilISO = auditDatetimeLocalToRFC3339(getAuditFilterDatetimeValue('audit-filter-until'));
|
||||
if (sinceISO) params.set('since', sinceISO);
|
||||
if (untilISO) params.set('until', untilISO);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function loadAuditMeta() {
|
||||
if (typeof apiFetch !== 'function') return;
|
||||
const hint = document.getElementById('audit-retention-hint');
|
||||
try {
|
||||
const r = await apiFetch('/api/audit/meta');
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
if (!hint) return;
|
||||
if (!data.enabled) {
|
||||
hint.hidden = false;
|
||||
hint.textContent = auditT('settingsAudit.disabledHint', null, '审计功能已关闭,新操作不会写入审计表。');
|
||||
return;
|
||||
}
|
||||
const days = data.retention_days;
|
||||
if (days > 0) {
|
||||
hint.hidden = false;
|
||||
hint.textContent = auditT('settingsAudit.retentionHint', { days: days },
|
||||
'审计记录保留 ' + days + ' 天,超期自动清理。');
|
||||
} else {
|
||||
hint.hidden = true;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadAuditSummary() {
|
||||
if (typeof apiFetch !== 'function') return;
|
||||
const wrap = document.getElementById('audit-summary-stats');
|
||||
@@ -191,10 +257,14 @@ async function loadAuditSummary() {
|
||||
const data = await r.json();
|
||||
if (wrap) wrap.hidden = false;
|
||||
const elTotal = document.getElementById('audit-stat-total');
|
||||
const elSuccess = document.getElementById('audit-stat-success');
|
||||
const elFail = document.getElementById('audit-stat-failures');
|
||||
const elRecent = document.getElementById('audit-stat-recent');
|
||||
if (elTotal) elTotal.textContent = String(data.total != null ? data.total : 0);
|
||||
if (elFail) elFail.textContent = String(data.failures != null ? data.failures : 0);
|
||||
const total = data.total != null ? data.total : 0;
|
||||
const failures = data.failures != null ? data.failures : 0;
|
||||
if (elTotal) elTotal.textContent = String(total);
|
||||
if (elSuccess) elSuccess.textContent = String(Math.max(0, total - failures));
|
||||
if (elFail) elFail.textContent = String(failures);
|
||||
if (elRecent) elRecent.textContent = String(data.recent_7d != null ? data.recent_7d : 0);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
@@ -214,7 +284,8 @@ async function loadAuditLogs(page) {
|
||||
throw new Error(err.error || r.statusText);
|
||||
}
|
||||
const data = await r.json();
|
||||
renderAuditLogs(data.logs || []);
|
||||
auditLogsCache = data.logs || [];
|
||||
renderAuditLogs(auditLogsCache);
|
||||
auditLogsTotal = typeof data.total === 'number' ? data.total : 0;
|
||||
const maxPage = Math.max(1, Math.ceil(auditLogsTotal / auditLogsPageSize));
|
||||
if (auditLogsPage > maxPage) {
|
||||
@@ -234,37 +305,57 @@ async function loadAuditLogs(page) {
|
||||
}
|
||||
}
|
||||
|
||||
function auditResultTagClass(result) {
|
||||
return result === 'failure' ? 'audit-tag--fail' : 'audit-tag--ok';
|
||||
}
|
||||
|
||||
function renderAuditLogs(logs) {
|
||||
const listEl = document.getElementById('audit-log-list');
|
||||
if (!listEl) return;
|
||||
const esc = typeof escapeHtml === 'function' ? escapeHtml : function (s) { return String(s || ''); };
|
||||
if (!logs.length) {
|
||||
listEl.innerHTML = '<div class="c2-empty">' + esc(auditT('settingsAudit.empty', null, '暂无审计记录')) + '</div>';
|
||||
listEl.innerHTML = '<div class="audit-log-empty">' + esc(auditT('settingsAudit.empty', null, '暂无审计记录')) + '</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = logs.map(function (log) {
|
||||
const lvl = log.result === 'failure' ? 'warn' : (log.level || 'info');
|
||||
const dash = '<span class="audit-log-cell-muted">—</span>';
|
||||
const head = (
|
||||
'<div class="audit-log-table-wrap">' +
|
||||
'<table class="audit-log-table">' +
|
||||
'<thead><tr>' +
|
||||
'<th data-i18n="settingsAudit.colTime">时间</th>' +
|
||||
'<th data-i18n="settingsAudit.colMessage">说明</th>' +
|
||||
'<th data-i18n="settingsAudit.colCategory">类别</th>' +
|
||||
'<th data-i18n="settingsAudit.colAction">操作</th>' +
|
||||
'<th data-i18n="settingsAudit.colResult">结果</th>' +
|
||||
'<th data-i18n="settingsAudit.colIp">IP</th>' +
|
||||
'<th data-i18n="settingsAudit.colResource">资源 ID</th>' +
|
||||
'</tr></thead><tbody>'
|
||||
);
|
||||
const rows = logs.map(function (log) {
|
||||
const catLabel = esc(auditCategoryLabel(log.category || ''));
|
||||
const actionLabel = esc(auditActionLabel(log.action || ''));
|
||||
const msg = esc(log.message || '');
|
||||
const msg = esc(auditMessageLabel(log));
|
||||
const ip = esc(log.clientIp || '');
|
||||
const when = esc(formatAuditTime(log.createdAt));
|
||||
const res = esc(log.result || '');
|
||||
const rid = log.resourceId || '';
|
||||
const meta = rid ? (' · ' + esc(rid)) : '';
|
||||
const res = esc(auditResultLabel(log.result || ''));
|
||||
const rid = log.resourceId ? esc(log.resourceId) : '';
|
||||
const eid = esc(log.id || '');
|
||||
const resultCls = auditResultTagClass(log.result || '');
|
||||
const rowClick = 'onclick="showAuditLogDetail(\'' + eid + '\')" ' +
|
||||
'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();showAuditLogDetail(\'' + eid + '\')}"';
|
||||
return (
|
||||
'<div class="c2-event-item audit-log-item" role="button" tabindex="0" ' +
|
||||
'onclick="showAuditLogDetail(\'' + eid + '\')" ' +
|
||||
'onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();showAuditLogDetail(\'' + eid + '\')}">' +
|
||||
'<div class="c2-event-level ' + esc(lvl) + '"></div>' +
|
||||
'<div class="c2-event-content">' +
|
||||
'<div class="c2-event-message">' + msg + '</div>' +
|
||||
'<div class="c2-event-meta">' + when + ' · ' + catLabel + '/' + actionLabel + ' · ' + res + meta +
|
||||
(ip ? ' · IP ' + ip : '') +
|
||||
'</div></div></div>'
|
||||
'<tr class="audit-log-row" role="button" tabindex="0" ' + rowClick + '>' +
|
||||
'<td class="audit-log-col-time">' + when + '</td>' +
|
||||
'<td class="audit-log-col-msg" title="' + msg + '">' + (msg || dash) + '</td>' +
|
||||
'<td>' + (catLabel ? '<span class="audit-tag audit-tag--cat">' + catLabel + '</span>' : dash) + '</td>' +
|
||||
'<td>' + (actionLabel ? '<span class="audit-tag audit-tag--act">' + actionLabel + '</span>' : dash) + '</td>' +
|
||||
'<td>' + (res ? '<span class="audit-tag ' + resultCls + '">' + res + '</span>' : dash) + '</td>' +
|
||||
'<td class="audit-log-col-ip">' + (ip || dash) + '</td>' +
|
||||
'<td class="audit-log-col-resource" title="' + rid + '">' + (rid || dash) + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}).join('');
|
||||
listEl.innerHTML = head + rows + '</tbody></table></div>';
|
||||
if (typeof applyTranslations === 'function') {
|
||||
applyTranslations(listEl);
|
||||
}
|
||||
@@ -326,17 +417,58 @@ function resetAuditLogFilters() {
|
||||
const act = document.getElementById('audit-filter-action');
|
||||
const res = document.getElementById('audit-filter-result');
|
||||
const q = document.getElementById('audit-filter-q');
|
||||
const since = document.getElementById('audit-filter-since');
|
||||
const until = document.getElementById('audit-filter-until');
|
||||
if (cat) cat.value = '';
|
||||
if (res) res.value = '';
|
||||
if (q) q.value = '';
|
||||
if (since) since.value = '';
|
||||
if (until) until.value = '';
|
||||
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.clearAll === 'function') {
|
||||
window.AuditDatetimePicker.clearAll();
|
||||
}
|
||||
rebuildAuditActionSelect();
|
||||
syncAuditCustomSelect('audit-filter-category');
|
||||
syncAuditCustomSelect('audit-filter-result');
|
||||
filterAuditLogs();
|
||||
}
|
||||
|
||||
function applyAuditTimePreset(preset) {
|
||||
if (typeof window.AuditDatetimePicker === 'undefined') return;
|
||||
const now = new Date();
|
||||
let since = new Date(now.getTime());
|
||||
let until = new Date(now.getTime());
|
||||
switch (preset) {
|
||||
case '15m':
|
||||
since = new Date(now.getTime() - 15 * 60 * 1000);
|
||||
break;
|
||||
case '1h':
|
||||
since = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case '24h':
|
||||
since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case '7d':
|
||||
since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'today':
|
||||
since = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
window.AuditDatetimePicker.setValue('audit-filter-since', since);
|
||||
window.AuditDatetimePicker.setValue('audit-filter-until', until);
|
||||
filterAuditLogs();
|
||||
}
|
||||
|
||||
function initAuditTimePresets() {
|
||||
const wrap = document.getElementById('audit-time-presets');
|
||||
if (!wrap || wrap.dataset.bound === '1') return;
|
||||
wrap.dataset.bound = '1';
|
||||
wrap.addEventListener('click', function (ev) {
|
||||
const btn = ev.target.closest('[data-preset]');
|
||||
if (!btn) return;
|
||||
applyAuditTimePreset(btn.getAttribute('data-preset'));
|
||||
});
|
||||
}
|
||||
|
||||
/** 资源已被删除/移除的审计操作,不再提供「打开关联资源」 */
|
||||
const AUDIT_ACTIONS_RESOURCE_REMOVED = {
|
||||
delete: true,
|
||||
@@ -565,8 +697,8 @@ async function showAuditLogDetail(id) {
|
||||
'<div class="modal-body audit-detail-body">' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailTime', null, '时间')) + ':</strong> ' + esc(formatAuditTime(log.createdAt)) + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailCategory', null, '类别')) + ':</strong> ' + catAction + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(log.result || '') + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(log.message || '') + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailResult', null, '结果')) + ':</strong> ' + esc(auditResultLabel(log.result || '')) + '</p>' +
|
||||
'<p><strong>' + esc(auditT('settingsAudit.detailMessage', null, '说明')) + ':</strong> ' + esc(auditMessageLabel(log)) + '</p>' +
|
||||
(log.clientIp ? '<p><strong>IP:</strong> ' + esc(log.clientIp) + '</p>' : '') +
|
||||
(log.sessionHint ? '<p><strong>' + esc(auditT('settingsAudit.detailSession', null, '会话')) + ':</strong> ' + esc(log.sessionHint) + '</p>' : '') +
|
||||
(log.userAgent ? '<p><strong>UA:</strong> ' + esc(log.userAgent) + '</p>' : '') +
|
||||
@@ -597,7 +729,171 @@ async function showAuditLogDetail(id) {
|
||||
function initAuditLogsSection() {
|
||||
if (!document.getElementById('audit-log-list')) return;
|
||||
initAuditPageSizeFromStorage();
|
||||
initAuditFilterSelects();
|
||||
rebuildAuditActionSelect();
|
||||
loadAuditMeta();
|
||||
if (typeof window.AuditDatetimePicker !== 'undefined' && typeof window.AuditDatetimePicker.init === 'function') {
|
||||
window.AuditDatetimePicker.init();
|
||||
}
|
||||
initAuditTimePresets();
|
||||
updateAuditTimezoneHint();
|
||||
loadAuditLogs(1);
|
||||
}
|
||||
|
||||
function refreshAuditFilterI18n() {
|
||||
const section = document.getElementById('settings-section-audit');
|
||||
if (section && typeof applyTranslations === 'function') {
|
||||
applyTranslations(section);
|
||||
}
|
||||
rebuildAuditActionSelect();
|
||||
syncAuditCustomSelect('audit-filter-category');
|
||||
syncAuditCustomSelect('audit-filter-action');
|
||||
syncAuditCustomSelect('audit-filter-result');
|
||||
updateAuditTimezoneHint();
|
||||
}
|
||||
|
||||
function refreshAuditLogsI18n() {
|
||||
if (!document.getElementById('audit-log-list')) return;
|
||||
refreshAuditFilterI18n();
|
||||
if (auditLogsCache.length) {
|
||||
renderAuditLogs(auditLogsCache);
|
||||
renderAuditLogsPagination();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
try {
|
||||
refreshAuditLogsI18n();
|
||||
} catch (e) {
|
||||
console.warn('languagechange audit refresh failed', e);
|
||||
}
|
||||
});
|
||||
|
||||
var auditCustomSelectMap = {};
|
||||
var auditFilterSelectsDocListener = false;
|
||||
|
||||
function closeAllAuditCustomSelects() {
|
||||
Object.keys(auditCustomSelectMap).forEach(function (id) {
|
||||
auditCustomSelectMap[id].wrapper.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
function syncAuditCustomSelect(selectId) {
|
||||
var reg = auditCustomSelectMap[selectId];
|
||||
if (!reg) return;
|
||||
var select = reg.select;
|
||||
var dropdown = reg.dropdown;
|
||||
var trigger = reg.trigger;
|
||||
var wrapper = reg.wrapper;
|
||||
var valueSpan = trigger.querySelector('.audit-custom-select-value');
|
||||
|
||||
dropdown.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, function (opt) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'audit-custom-select-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
if (opt.value === select.value) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
var check = document.createElement('span');
|
||||
check.className = 'audit-custom-select-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
var label = document.createElement('span');
|
||||
label.className = 'audit-custom-select-label';
|
||||
label.textContent = opt.textContent;
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
|
||||
var selectedOpt = select.options[select.selectedIndex];
|
||||
if (valueSpan) {
|
||||
valueSpan.textContent = selectedOpt ? selectedOpt.textContent : '';
|
||||
}
|
||||
trigger.disabled = !!select.disabled;
|
||||
wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
}
|
||||
|
||||
function enhanceAuditFilterSelect(selectId) {
|
||||
var select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
if (select.dataset.auditCustom === '1') {
|
||||
syncAuditCustomSelect(selectId);
|
||||
return;
|
||||
}
|
||||
select.dataset.auditCustom = '1';
|
||||
select.classList.add('audit-native-select');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'audit-custom-select';
|
||||
|
||||
var trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'audit-custom-select-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
var valueSpan = document.createElement('span');
|
||||
valueSpan.className = 'audit-custom-select-value';
|
||||
trigger.appendChild(valueSpan);
|
||||
var caret = document.createElement('span');
|
||||
caret.className = 'audit-custom-select-caret';
|
||||
caret.setAttribute('aria-hidden', 'true');
|
||||
caret.textContent = '▾';
|
||||
trigger.appendChild(caret);
|
||||
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'audit-custom-select-dropdown';
|
||||
dropdown.setAttribute('role', 'listbox');
|
||||
|
||||
var parent = select.parentNode;
|
||||
parent.insertBefore(wrapper, select);
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(dropdown);
|
||||
wrapper.appendChild(select);
|
||||
|
||||
auditCustomSelectMap[selectId] = {
|
||||
wrapper: wrapper,
|
||||
trigger: trigger,
|
||||
dropdown: dropdown,
|
||||
select: select
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
var open = wrapper.classList.contains('open');
|
||||
closeAllAuditCustomSelects();
|
||||
if (!open) wrapper.classList.add('open');
|
||||
});
|
||||
|
||||
dropdown.addEventListener('click', function (e) {
|
||||
var opt = e.target.closest('.audit-custom-select-option');
|
||||
if (!opt) return;
|
||||
var val = opt.getAttribute('data-value');
|
||||
if (val === null) val = '';
|
||||
if (select.value !== val) {
|
||||
select.value = val;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
wrapper.classList.remove('open');
|
||||
syncAuditCustomSelect(selectId);
|
||||
});
|
||||
|
||||
syncAuditCustomSelect(selectId);
|
||||
}
|
||||
|
||||
function initAuditFilterSelects() {
|
||||
if (!document.getElementById('audit-filter-category')) return;
|
||||
if (!auditFilterSelectsDocListener) {
|
||||
document.addEventListener('click', function () {
|
||||
closeAllAuditCustomSelects();
|
||||
});
|
||||
auditFilterSelectsDocListener = true;
|
||||
}
|
||||
enhanceAuditFilterSelect('audit-filter-category');
|
||||
enhanceAuditFilterSelect('audit-filter-action');
|
||||
enhanceAuditFilterSelect('audit-filter-result');
|
||||
}
|
||||
|
||||
+743
-138
File diff suppressed because it is too large
Load Diff
+273
-41
@@ -2164,6 +2164,97 @@ function showCopySuccess(button) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Claude extended thinking 内部尾缀(与后端 DisplayReasoningContent 一致,UI 不展示) */
|
||||
const CLAUDE_REASONING_UI_SUFFIX = '\n---CSAI_CLAUDE_THINKING_BLOCKS---\n';
|
||||
|
||||
function normalizeReasoningContentForDisplay(text) {
|
||||
if (text == null) return '';
|
||||
let s = String(text).trim();
|
||||
if (!s) return '';
|
||||
const idx = s.lastIndexOf(CLAUDE_REASONING_UI_SUFFIX);
|
||||
if (idx >= 0) {
|
||||
s = s.slice(0, idx).trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function setMessageReasoningContent(messageIdOrEl, reasoningContent) {
|
||||
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
|
||||
if (!el || !el.dataset) return;
|
||||
const rc = normalizeReasoningContentForDisplay(reasoningContent);
|
||||
if (rc) {
|
||||
el.dataset.reasoningContent = rc;
|
||||
} else {
|
||||
delete el.dataset.reasoningContent;
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageReasoningContent(messageIdOrEl) {
|
||||
const el = typeof messageIdOrEl === 'string' ? document.getElementById(messageIdOrEl) : messageIdOrEl;
|
||||
if (!el || !el.dataset) return '';
|
||||
return normalizeReasoningContentForDisplay(el.dataset.reasoningContent || '');
|
||||
}
|
||||
|
||||
function reasoningTextAlreadyInProcessDetails(processDetails, rc) {
|
||||
if (!rc) return true;
|
||||
const list = Array.isArray(processDetails) ? processDetails : [];
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const d = list[i];
|
||||
if (!d) continue;
|
||||
const et = d.eventType || '';
|
||||
if (et !== 'reasoning_chain' && et !== 'thinking') continue;
|
||||
const msg = normalizeReasoningContentForDisplay(d.message || '');
|
||||
if (!msg) continue;
|
||||
if (msg === rc || msg.includes(rc) || rc.includes(msg)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 合并 messages.reasoningContent 与 process_details 中的 reasoning_chain,两者都读、都展示(去重后) */
|
||||
function mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningContent) {
|
||||
const rc = normalizeReasoningContentForDisplay(reasoningContent);
|
||||
const details = Array.isArray(processDetails) ? processDetails.slice() : [];
|
||||
if (!rc || reasoningTextAlreadyInProcessDetails(details, rc)) {
|
||||
return details;
|
||||
}
|
||||
details.push({
|
||||
eventType: 'reasoning_chain',
|
||||
message: rc,
|
||||
data: { source: 'message.reasoningContent' }
|
||||
});
|
||||
return details;
|
||||
}
|
||||
|
||||
async function syncAssistantReasoningContentFromServer(backendMessageId, domAssistantId) {
|
||||
if (!backendMessageId || !domAssistantId || !currentConversationId || typeof apiFetch !== 'function') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const convRes = await apiFetch(`/api/conversations/${encodeURIComponent(currentConversationId)}?include_process_details=0`);
|
||||
const conv = await convRes.json().catch(() => ({}));
|
||||
if (!convRes.ok || !Array.isArray(conv.messages)) return;
|
||||
const msg = conv.messages.find((m) => m && String(m.id) === String(backendMessageId));
|
||||
if (!msg || !msg.reasoningContent) return;
|
||||
setMessageReasoningContent(domAssistantId, msg.reasoningContent);
|
||||
const pdRes = await apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`);
|
||||
const pdJson = await pdRes.json().catch(() => ({}));
|
||||
const details = pdRes.ok && Array.isArray(pdJson.processDetails) ? pdJson.processDetails : [];
|
||||
if (typeof renderProcessDetails === 'function') {
|
||||
renderProcessDetails(domAssistantId, details);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('syncAssistantReasoningContentFromServer failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
window.normalizeReasoningContentForDisplay = normalizeReasoningContentForDisplay;
|
||||
window.setMessageReasoningContent = setMessageReasoningContent;
|
||||
window.getMessageReasoningContent = getMessageReasoningContent;
|
||||
window.mergeMessageReasoningContentIntoProcessDetails = mergeMessageReasoningContentIntoProcessDetails;
|
||||
window.syncAssistantReasoningContentFromServer = syncAssistantReasoningContentFromServer;
|
||||
|
||||
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
|
||||
function dedupeConsecutiveProcessDetailRows(details) {
|
||||
if (!Array.isArray(details) || details.length < 2) {
|
||||
@@ -2282,20 +2373,27 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
detailsContainer.appendChild(contentDiv);
|
||||
}
|
||||
|
||||
// processDetails === null 表示“尚未加载(懒加载)”
|
||||
// processDetails === null 表示“尚未加载(懒加载)”;messages.reasoningContent 可先展示
|
||||
const isLazyNotLoaded = (processDetails === null);
|
||||
if (isLazyNotLoaded) {
|
||||
const reasoningFromMessage = getMessageReasoningContent(messageElement);
|
||||
if (isLazyNotLoaded && !reasoningFromMessage) {
|
||||
detailsContainer.dataset.lazyNotLoaded = '1';
|
||||
detailsContainer.dataset.loaded = '0';
|
||||
timeline.innerHTML = '<div class="progress-timeline-empty">' +
|
||||
(typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
|
||||
'(点击后加载)</div>';
|
||||
// 默认折叠
|
||||
timeline.classList.remove('expanded');
|
||||
return;
|
||||
}
|
||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||
detailsContainer.dataset.loaded = '1';
|
||||
if (isLazyNotLoaded) {
|
||||
detailsContainer.dataset.lazyNotLoaded = '1';
|
||||
detailsContainer.dataset.loaded = '0';
|
||||
processDetails = [];
|
||||
} else {
|
||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||
detailsContainer.dataset.loaded = '1';
|
||||
}
|
||||
processDetails = mergeMessageReasoningContentIntoProcessDetails(processDetails, reasoningFromMessage);
|
||||
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
|
||||
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
|
||||
@@ -2426,6 +2524,14 @@ function renderProcessDetails(messageId, processDetails) {
|
||||
}
|
||||
addTimelineItem(timeline, eventType, timelineOpts);
|
||||
});
|
||||
|
||||
if (isLazyNotLoaded && reasoningFromMessage) {
|
||||
const lazyHint = document.createElement('div');
|
||||
lazyHint.className = 'progress-timeline-empty progress-timeline-lazy-hint';
|
||||
lazyHint.textContent = (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
|
||||
'(点击后加载完整过程详情)';
|
||||
timeline.appendChild(lazyHint);
|
||||
}
|
||||
|
||||
// 检查是否有错误或取消事件,如果有,确保详情默认折叠(但仍有待审批 HITL 时保持展开,由 restoreHitlInlineForConversation 处理)
|
||||
const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt');
|
||||
@@ -2533,6 +2639,57 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
|
||||
}
|
||||
|
||||
// 显示MCP调用详情
|
||||
const MCP_DETAIL_MAX_CHARS = 120000;
|
||||
|
||||
function extractMCPResultText(result) {
|
||||
if (!result) return '';
|
||||
const content = result.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map(item => (item && typeof item === 'object' && typeof item.text === 'string') ? item.text : '')
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
if (content && typeof content === 'object' && typeof content.text === 'string') {
|
||||
return content.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function truncateMCPDetailText(text, maxChars) {
|
||||
if (text == null) return '';
|
||||
const s = String(text);
|
||||
if (s.length <= maxChars) return s;
|
||||
const hint = typeof window.t === 'function'
|
||||
? window.t('mcpDetailModal.contentTruncated')
|
||||
: '…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)';
|
||||
return s.slice(0, maxChars) + '\n\n' + hint;
|
||||
}
|
||||
|
||||
/** 响应结果区 JSON 展示(过大时截断 content 内 text,避免 stringify 卡死页面) */
|
||||
function formatMCPResultJsonForDisplay(result, maxChars) {
|
||||
if (!result) return '{}';
|
||||
const payload = {
|
||||
content: result.content,
|
||||
isError: !!result.isError
|
||||
};
|
||||
let json = JSON.stringify(payload, null, 2);
|
||||
if (json.length <= maxChars) {
|
||||
return json;
|
||||
}
|
||||
const text = extractMCPResultText(result);
|
||||
const truncatedPayload = {
|
||||
content: [{ type: 'text', text: truncateMCPDetailText(text, Math.min(maxChars - 800, MCP_DETAIL_MAX_CHARS)) }],
|
||||
isError: !!result.isError
|
||||
};
|
||||
json = JSON.stringify(truncatedPayload, null, 2);
|
||||
if (json.length > maxChars) {
|
||||
return json.slice(0, maxChars) + '\n…';
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function showMCPDetail(executionId) {
|
||||
try {
|
||||
openAppModal('mcp-detail-modal', { focus: false });
|
||||
@@ -2594,42 +2751,22 @@ async function showMCPDetail(executionId) {
|
||||
}
|
||||
|
||||
if (exec.result) {
|
||||
const responseData = {
|
||||
content: exec.result.content,
|
||||
isError: exec.result.isError
|
||||
};
|
||||
responseElement.textContent = JSON.stringify(responseData, null, 2);
|
||||
const agentVisibleText = truncateMCPDetailText(extractMCPResultText(exec.result), MCP_DETAIL_MAX_CHARS);
|
||||
const emptyText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
|
||||
|
||||
if (exec.result.isError) {
|
||||
// 错误场景:响应结果标红 + 错误信息区块
|
||||
responseElement.className = 'code-block error';
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
|
||||
if (exec.error && errorSection && errorElement) {
|
||||
errorSection.style.display = 'block';
|
||||
errorElement.textContent = exec.error;
|
||||
}
|
||||
} else {
|
||||
// 成功场景:响应结果保持普通样式,正确信息单独拎出来
|
||||
responseElement.className = 'code-block';
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
|
||||
if (successSection && successElement) {
|
||||
successSection.style.display = 'block';
|
||||
let successText = '';
|
||||
const content = exec.result.content;
|
||||
if (typeof content === 'string') {
|
||||
successText = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const texts = content
|
||||
.map(item => (item && typeof item === 'object' && typeof item.text === 'string') ? item.text : '')
|
||||
.filter(Boolean);
|
||||
if (texts.length > 0) {
|
||||
successText = texts.join('\n\n');
|
||||
}
|
||||
} else if (content && typeof content === 'object' && typeof content.text === 'string') {
|
||||
successText = content.text;
|
||||
}
|
||||
if (!successText) {
|
||||
successText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
|
||||
}
|
||||
successElement.textContent = successText;
|
||||
successElement.textContent = agentVisibleText || emptyText;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -3193,6 +3330,9 @@ async function loadConversation(conversationId) {
|
||||
attachDeleteTurnButton(messageEl);
|
||||
}
|
||||
if (msg.role === 'assistant') {
|
||||
if (messageEl && msg.reasoningContent) {
|
||||
setMessageReasoningContent(messageEl, msg.reasoningContent);
|
||||
}
|
||||
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
|
||||
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
|
||||
if (msg.processDetails && msg.processDetails.length > 0) {
|
||||
@@ -5623,6 +5763,95 @@ let conversationGroupMappingCache = {};
|
||||
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
||||
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
||||
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
|
||||
const CONVERSATIONS_SORT_KEY = 'cyberstrike.conversations_sort_by';
|
||||
|
||||
function getConversationSortBy() {
|
||||
try {
|
||||
const saved = localStorage.getItem(CONVERSATIONS_SORT_KEY);
|
||||
if (saved === 'created_at' || saved === 'updated_at') return saved;
|
||||
} catch (e) { /* ignore */ }
|
||||
return 'updated_at';
|
||||
}
|
||||
|
||||
let conversationSortBy = getConversationSortBy();
|
||||
|
||||
function getConversationSortTime(conv) {
|
||||
const field = conversationSortBy === 'created_at' ? 'createdAt' : 'updatedAt';
|
||||
const raw = conv && conv[field];
|
||||
if (!raw) return new Date(0);
|
||||
const date = new Date(raw);
|
||||
return isNaN(date.getTime()) ? new Date(0) : date;
|
||||
}
|
||||
|
||||
function updateConversationSortMenuUI() {
|
||||
const menu = document.getElementById('conversation-sort-menu');
|
||||
const btn = document.getElementById('conversation-sort-btn');
|
||||
if (!menu) return;
|
||||
menu.querySelectorAll('.conversation-sort-option').forEach((option) => {
|
||||
const selected = option.dataset.sort === conversationSortBy;
|
||||
option.classList.toggle('is-selected', selected);
|
||||
option.setAttribute('aria-checked', selected ? 'true' : 'false');
|
||||
});
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', menu.hidden ? 'false' : 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function closeConversationSortMenu() {
|
||||
const menu = document.getElementById('conversation-sort-menu');
|
||||
const btn = document.getElementById('conversation-sort-btn');
|
||||
if (menu) menu.hidden = true;
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function toggleConversationSortMenu(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
const menu = document.getElementById('conversation-sort-menu');
|
||||
const btn = document.getElementById('conversation-sort-btn');
|
||||
if (!menu || !btn) return;
|
||||
const willOpen = menu.hidden;
|
||||
closeConversationSortMenu();
|
||||
if (willOpen) {
|
||||
menu.hidden = false;
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
updateConversationSortMenuUI();
|
||||
}
|
||||
}
|
||||
|
||||
function setConversationSortBy(sortBy) {
|
||||
const next = sortBy === 'created_at' ? 'created_at' : 'updated_at';
|
||||
if (next === conversationSortBy) {
|
||||
closeConversationSortMenu();
|
||||
return;
|
||||
}
|
||||
conversationSortBy = next;
|
||||
try {
|
||||
localStorage.setItem(CONVERSATIONS_SORT_KEY, next);
|
||||
} catch (e) { /* ignore */ }
|
||||
updateConversationSortMenuUI();
|
||||
closeConversationSortMenu();
|
||||
conversationsPagination.page = 1;
|
||||
loadConversationsWithGroups(conversationsSearchQuery);
|
||||
}
|
||||
|
||||
if (!window.__conversationSortMenuBound) {
|
||||
window.__conversationSortMenuBound = true;
|
||||
document.addEventListener('click', (event) => {
|
||||
const dropdown = document.getElementById('conversation-sort-dropdown');
|
||||
if (!dropdown || dropdown.contains(event.target)) return;
|
||||
closeConversationSortMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeConversationSortMenu();
|
||||
});
|
||||
}
|
||||
|
||||
window.toggleConversationSortMenu = toggleConversationSortMenu;
|
||||
window.setConversationSortBy = setConversationSortBy;
|
||||
window.closeConversationSortMenu = closeConversationSortMenu;
|
||||
|
||||
function getConversationsPageSize() {
|
||||
try {
|
||||
@@ -5885,6 +6114,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
const pageSize = conversationsPagination.pageSize;
|
||||
const offset = (conversationsPagination.page - 1) * pageSize;
|
||||
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||
if (conversationSortBy === 'created_at') {
|
||||
convParams.set('sort_by', 'created_at');
|
||||
}
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
convParams.set('search', searchQuery.trim());
|
||||
} else {
|
||||
@@ -5974,11 +6206,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
});
|
||||
|
||||
// 按时间排序
|
||||
const sortByTime = (a, b) => {
|
||||
const timeA = a.updatedAt ? new Date(a.updatedAt) : new Date(0);
|
||||
const timeB = b.updatedAt ? new Date(b.updatedAt) : new Date(0);
|
||||
return timeB - timeA;
|
||||
};
|
||||
const sortByTime = (a, b) => getConversationSortTime(b) - getConversationSortTime(a);
|
||||
|
||||
pinnedConvs.sort(sortByTime);
|
||||
normalConvs.sort(sortByTime);
|
||||
@@ -6006,8 +6234,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
};
|
||||
|
||||
normalConvs.forEach(conv => {
|
||||
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
|
||||
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
|
||||
const dateObj = getConversationSortTime(conv);
|
||||
const validDate = dateObj.getTime() === 0 ? new Date() : dateObj;
|
||||
const groupKey = getConversationGroup(validDate, todayStart, sevenDaysCutoff, yesterdayStart);
|
||||
groups[groupKey].push({
|
||||
...conv,
|
||||
@@ -6019,8 +6247,8 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
||||
|
||||
if (pinnedConvs.length > 0) {
|
||||
pinnedConvs.forEach(conv => {
|
||||
const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date();
|
||||
const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj;
|
||||
const dateObj = getConversationSortTime(conv);
|
||||
const validDate = dateObj.getTime() === 0 ? new Date() : dateObj;
|
||||
fragment.appendChild(createConversationListItemWithMenu({
|
||||
...conv,
|
||||
_timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart),
|
||||
@@ -7359,8 +7587,11 @@ async function deleteSelectedConversations() {
|
||||
for (const id of ids) {
|
||||
await deleteConversation(id, true); // 跳过内部确认,因为批量删除时已经确认过了
|
||||
}
|
||||
closeBatchManageModal();
|
||||
loadConversationsWithGroups();
|
||||
// 删除后保持弹窗打开,便于继续管理剩余对话
|
||||
const selectAll = document.getElementById('batch-select-all');
|
||||
if (selectAll) {
|
||||
selectAll.checked = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
const failedMsg = typeof window.t === 'function' ? window.t('batchManageModal.deleteFailed') : '删除失败';
|
||||
@@ -8365,6 +8596,7 @@ function clearGroupSearch() {
|
||||
|
||||
// 初始化时加载分组
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
updateConversationSortMenuUI();
|
||||
await loadGroups();
|
||||
await loadConversationsWithGroups();
|
||||
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* 项目事实图渲染(Cytoscape + ELK),供项目管理页使用。
|
||||
* 节点采用 SVG 卡片背景(图标 + 多行文字),避免 Cytoscape 原生 label 定位问题。
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
let _cy = null;
|
||||
let _graphData = null;
|
||||
let _onNodeSelect = null;
|
||||
let _onEdgeSelect = null;
|
||||
|
||||
const EDGE_COLORS = {
|
||||
discovered_on: '#4F46E5',
|
||||
leads_to: '#64748B',
|
||||
enables: '#E11D48',
|
||||
exploits: '#DC2626',
|
||||
depends_on: '#0D9488',
|
||||
contains: '#6366F1',
|
||||
part_of: '#6366F1',
|
||||
supports: '#94A3B8',
|
||||
links_vuln: '#BE123C',
|
||||
};
|
||||
|
||||
const CARD_PAD = 14;
|
||||
const CARD_TEXT_PAD_RIGHT = 12;
|
||||
const CARD_ICON = 36;
|
||||
const CARD_ICON_GAP = 12;
|
||||
const CARD_TEXT_X = CARD_PAD + CARD_ICON + CARD_ICON_GAP;
|
||||
const CARD_MIN_W = 300;
|
||||
const CARD_TARGET_W = 360;
|
||||
const CARD_MIN_H = 88;
|
||||
const CARD_MAX_H = 152;
|
||||
const CARD_HEADER_FS = 11;
|
||||
const CARD_HEADER_LH = 16;
|
||||
const CARD_SUMMARY_FS = 13;
|
||||
const CARD_SUMMARY_LH = 18;
|
||||
const CARD_SECTION_GAP = 6;
|
||||
const CARD_FONT =
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||
|
||||
function nodeTheme(type) {
|
||||
switch (type) {
|
||||
case 'target':
|
||||
return { typeLabel: '目标', typeEn: 'TARGET', accent: '#4F46E5', bgEnd: '#F5F3FF', icon: 'target' };
|
||||
case 'finding':
|
||||
return { typeLabel: '发现', typeEn: 'FINDING', accent: '#E11D48', bgEnd: '#FFF1F2', icon: 'vulnerability' };
|
||||
case 'exploit':
|
||||
return { typeLabel: '利用', typeEn: 'EXPLOIT', accent: '#B45309', bgEnd: '#FFFBEB', icon: 'vulnerability' };
|
||||
case 'vulnerability':
|
||||
return { typeLabel: '漏洞', typeEn: 'VULN', accent: '#BE123C', bgEnd: '#FFF1F2', icon: 'vulnerability' };
|
||||
case 'auth':
|
||||
return { typeLabel: '认证', typeEn: 'AUTH', accent: '#0D9488', bgEnd: '#F0FDFA', icon: 'default' };
|
||||
case 'infra':
|
||||
return { typeLabel: '基础设施', typeEn: 'INFRA', accent: '#64748B', bgEnd: '#F8FAFC', icon: 'default' };
|
||||
case 'chain':
|
||||
return { typeLabel: '攻击链', typeEn: 'CHAIN', accent: '#7C3AED', bgEnd: '#F5F3FF', icon: 'vulnerability' };
|
||||
case 'poc':
|
||||
return { typeLabel: 'POC', typeEn: 'POC', accent: '#C2410C', bgEnd: '#FFEDD5', icon: 'vulnerability' };
|
||||
case 'business':
|
||||
return { typeLabel: '业务', typeEn: 'BUSINESS', accent: '#0369A1', bgEnd: '#F0F9FF', icon: 'default' };
|
||||
case 'missing':
|
||||
return { typeLabel: '缺失', typeEn: 'MISSING', accent: '#CBD5E1', bgEnd: '#F1F5F9', icon: 'default' };
|
||||
default:
|
||||
return { typeLabel: '备注', typeEn: 'NOTE', accent: '#94A3B8', bgEnd: '#F8FAFC', icon: 'default' };
|
||||
}
|
||||
}
|
||||
|
||||
function escapeXml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return escapeXml(str);
|
||||
}
|
||||
|
||||
function buildStatusBadge(confidence) {
|
||||
const conf = (confidence || '').toLowerCase();
|
||||
if (conf === 'tentative') return '待确认';
|
||||
if (conf === 'deprecated') return '已废弃';
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildHeaderText(theme, statusBadge) {
|
||||
const line = (theme.typeEn || '') + ' · ' + (theme.typeLabel || '');
|
||||
return statusBadge ? line + ' · ' + statusBadge : line;
|
||||
}
|
||||
|
||||
function isWideChar(ch) {
|
||||
const code = ch.codePointAt(0) || 0;
|
||||
if (code >= 0x4e00 && code <= 0x9fff) return true;
|
||||
if (code >= 0x3400 && code <= 0x4dbf) return true;
|
||||
if (code >= 0xf900 && code <= 0xfaff) return true;
|
||||
if (code >= 0xff00 && code <= 0xffef) return true;
|
||||
return /[·:,。;!?【】()《》、「」]/.test(ch);
|
||||
}
|
||||
|
||||
function charWidth(ch, fontSize, bold) {
|
||||
const scale = bold ? 1.05 : 1;
|
||||
if (ch === ' ') return fontSize * 0.3 * scale;
|
||||
if (isWideChar(ch)) return fontSize * scale;
|
||||
return fontSize * 0.58 * scale;
|
||||
}
|
||||
|
||||
function lineWidth(text, fontSize, bold) {
|
||||
let width = 0;
|
||||
for (const ch of text) width += charWidth(ch, fontSize, bold);
|
||||
return width;
|
||||
}
|
||||
|
||||
function wrapTextLines(text, maxWidth, fontSize, maxLines, bold) {
|
||||
const raw = String(text || '').replace(/\s+/g, ' ').trim();
|
||||
if (!raw) return ['—'];
|
||||
const safeWidth = Math.max(40, maxWidth - 4);
|
||||
const chars = [...raw];
|
||||
const lines = [];
|
||||
let index = 0;
|
||||
while (index < chars.length && lines.length < maxLines) {
|
||||
let line = '';
|
||||
let width = 0;
|
||||
while (index < chars.length) {
|
||||
const ch = chars[index];
|
||||
const nextWidth = charWidth(ch, fontSize, bold);
|
||||
if (line && width + nextWidth > safeWidth) break;
|
||||
line += ch;
|
||||
width += nextWidth;
|
||||
index += 1;
|
||||
if (width >= safeWidth) break;
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
if (index < chars.length && lines.length) {
|
||||
let last = lines[lines.length - 1];
|
||||
while (last.length > 1 && lineWidth(last + '…', fontSize, bold) > safeWidth) {
|
||||
last = last.slice(0, -1);
|
||||
}
|
||||
lines[lines.length - 1] = last + '…';
|
||||
}
|
||||
return lines.length ? lines : ['—'];
|
||||
}
|
||||
|
||||
function cardTextWidth(nodeWidth) {
|
||||
return nodeWidth - CARD_TEXT_X - CARD_PAD - CARD_TEXT_PAD_RIGHT;
|
||||
}
|
||||
|
||||
function computeNodeLayout(type, summary, statusBadge, theme) {
|
||||
const width = type === 'target' ? CARD_TARGET_W : CARD_MIN_W;
|
||||
const textW = cardTextWidth(width);
|
||||
const t = theme || nodeTheme(type);
|
||||
const headerLines = wrapTextLines(buildHeaderText(t, statusBadge), textW, CARD_HEADER_FS, 2, true);
|
||||
const summaryLines = wrapTextLines(summary, textW, CARD_SUMMARY_FS, 4, true);
|
||||
const height = Math.min(
|
||||
CARD_MAX_H,
|
||||
Math.max(
|
||||
CARD_MIN_H,
|
||||
CARD_PAD +
|
||||
headerLines.length * CARD_HEADER_LH +
|
||||
CARD_SECTION_GAP +
|
||||
summaryLines.length * CARD_SUMMARY_LH +
|
||||
CARD_PAD,
|
||||
),
|
||||
);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
headerLines,
|
||||
summaryLines,
|
||||
searchLabel: headerLines.join(' ') + '\n' + summaryLines.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
function svgIconGroup(kind, color, x, y) {
|
||||
const scale = (CARD_ICON / 24).toFixed(3);
|
||||
if (kind === 'target') {
|
||||
return (
|
||||
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||
`<circle cx="12" cy="12" r="6" fill="none" stroke="${color}" stroke-width="2"/>` +
|
||||
`<circle cx="12" cy="12" r="2.5" fill="${color}"/></g>`
|
||||
);
|
||||
}
|
||||
if (kind === 'vulnerability') {
|
||||
return (
|
||||
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||
`<path d="M12 3l9 16H3z" fill="none" stroke="${color}" stroke-width="2"/>` +
|
||||
`<line x1="12" y1="9" x2="12" y2="13" stroke="${color}" stroke-width="2"/>` +
|
||||
`<circle cx="12" cy="16" r="1" fill="${color}"/></g>`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`<g transform="translate(${x}, ${y}) scale(${scale})">` +
|
||||
`<circle cx="12" cy="12" r="5" fill="${color}" opacity="0.85"/></g>`
|
||||
);
|
||||
}
|
||||
|
||||
function buildNodeCardSvgUrl(theme, layout, confidence) {
|
||||
const { width, height, headerLines, summaryLines } = layout;
|
||||
const accent = theme.accent;
|
||||
const bgEnd = theme.bgEnd;
|
||||
const conf = (confidence || '').toLowerCase();
|
||||
const isTentative = conf === 'tentative';
|
||||
const isDeprecated = conf === 'deprecated';
|
||||
const iconX = CARD_PAD;
|
||||
const iconY = (height - CARD_ICON) / 2;
|
||||
const headerY = CARD_PAD + CARD_HEADER_FS;
|
||||
const summaryY = CARD_PAD + headerLines.length * CARD_HEADER_LH + CARD_SECTION_GAP + CARD_SUMMARY_FS;
|
||||
|
||||
const stroke = isTentative
|
||||
? `stroke="${accent}" stroke-width="1.5" stroke-dasharray="8 5" stroke-opacity="0.9"`
|
||||
: `stroke="${accent}" stroke-width="1.5" stroke-opacity="0.72"`;
|
||||
|
||||
const headerSvg = headerLines
|
||||
.map(
|
||||
(line, i) =>
|
||||
`<text x="${CARD_TEXT_X}" y="${headerY + i * CARD_HEADER_LH}" font-size="${CARD_HEADER_FS}" font-weight="700" fill="${accent}" fill-opacity="0.88" font-family='${CARD_FONT}'>${escapeXml(line)}</text>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const summarySvg = summaryLines
|
||||
.map(
|
||||
(line, i) =>
|
||||
`<text x="${CARD_TEXT_X}" y="${summaryY + i * CARD_SUMMARY_LH}" font-size="${CARD_SUMMARY_FS}" font-weight="600" fill="#0f172a" font-family='${CARD_FONT}'>${escapeXml(line)}</text>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
const textClipW = width - CARD_TEXT_X - CARD_PAD - 2;
|
||||
const textClipH = height - CARD_PAD * 2 + 4;
|
||||
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
|
||||
`<defs><linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">` +
|
||||
`<stop offset="0%" stop-color="#FFFFFF"/><stop offset="100%" stop-color="${bgEnd}"/></linearGradient>` +
|
||||
`<clipPath id="textClip"><rect x="${CARD_TEXT_X}" y="${CARD_PAD - 2}" width="${textClipW}" height="${textClipH}"/></clipPath></defs>` +
|
||||
`<g${isDeprecated ? ' opacity="0.55"' : ''}>` +
|
||||
`<rect x="0.75" y="0.75" width="${width - 1.5}" height="${height - 1.5}" rx="12" fill="url(#bg)" ${stroke}/>` +
|
||||
svgIconGroup(theme.icon, accent, iconX, iconY) +
|
||||
`<g clip-path="url(#textClip)">${headerSvg}${summarySvg}</g>` +
|
||||
`</g></svg>`;
|
||||
|
||||
try {
|
||||
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)));
|
||||
} catch (e) {
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (_cy) {
|
||||
_cy.destroy();
|
||||
_cy = null;
|
||||
}
|
||||
_graphData = null;
|
||||
}
|
||||
|
||||
function centerGraph() {
|
||||
if (!_cy) return;
|
||||
try {
|
||||
_cy.fit(undefined, 56);
|
||||
if (_cy.zoom() < 0.65) {
|
||||
_cy.zoom(0.65);
|
||||
_cy.center();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('centerGraph', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ELK 分层(仅影响节点纵向位置,不修改边的 source/target)
|
||||
function pathGraphNodeLayer(type, factKey) {
|
||||
const key = (factKey || '').toLowerCase();
|
||||
if (key.startsWith('vuln:')) return '4';
|
||||
if (key.startsWith('target/')) return '0';
|
||||
if (key.startsWith('infra/') || key.startsWith('auth/') || key.startsWith('business/')) return '1';
|
||||
if (key.startsWith('exploit/') || key.startsWith('evidence/')) return '3';
|
||||
if (key.startsWith('poc/')) return '3';
|
||||
if (key.startsWith('chain/')) return '2';
|
||||
if (key.startsWith('finding/')) return '2';
|
||||
const t = (type || '').toLowerCase();
|
||||
if (t === 'target') return '0';
|
||||
if (t === 'infra' || t === 'auth') return '1';
|
||||
if (t === 'exploit' || t === 'poc') return '3';
|
||||
if (t === 'chain' || t === 'finding' || t === 'vulnerability') return '2';
|
||||
return '2';
|
||||
}
|
||||
|
||||
function applyElkLayout(validEdges, isComplex) {
|
||||
const layoutOptions = {
|
||||
name: 'breadthfirst',
|
||||
directed: true,
|
||||
spacingFactor: isComplex ? 3.0 : 2.5,
|
||||
padding: 40,
|
||||
};
|
||||
const elkInstance = typeof ELK !== 'undefined' ? new ELK() : null;
|
||||
if (!elkInstance) {
|
||||
const layout = _cy.layout(layoutOptions);
|
||||
layout.one('layoutstop', () => setTimeout(centerGraph, 100));
|
||||
layout.run();
|
||||
return;
|
||||
}
|
||||
const nodeGap = isComplex ? 45 : 60;
|
||||
const layerGap = isComplex ? 70 : 95;
|
||||
const elkGraph = {
|
||||
id: 'root',
|
||||
layoutOptions: {
|
||||
'elk.algorithm': 'layered',
|
||||
'elk.direction': 'DOWN',
|
||||
'elk.spacing.nodeNode': String(nodeGap),
|
||||
'elk.layered.spacing.nodeNodeBetweenLayers': String(layerGap),
|
||||
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
||||
},
|
||||
children: (_graphData.nodes || []).map((node) => {
|
||||
const n = _cy ? _cy.getElementById(node.id) : null;
|
||||
const w = n.length ? n.data('nodeWidth') : node.type === 'target' ? CARD_TARGET_W : CARD_MIN_W;
|
||||
const h = n.length ? n.data('nodeHeight') : CARD_MIN_H;
|
||||
const nodeKey = node.fact_key || node.id;
|
||||
return {
|
||||
id: node.id,
|
||||
width: w,
|
||||
height: h,
|
||||
layoutOptions: {
|
||||
'org.eclipse.elk.layered.layering.layerId': pathGraphNodeLayer(node.type, nodeKey),
|
||||
},
|
||||
};
|
||||
}),
|
||||
edges: validEdges.map((edge) => ({
|
||||
id: edge.id,
|
||||
sources: [edge.source],
|
||||
targets: [edge.target],
|
||||
})),
|
||||
};
|
||||
elkInstance
|
||||
.layout(elkGraph)
|
||||
.then((laidOut) => {
|
||||
(laidOut.children || []).forEach((elkNode) => {
|
||||
const cyNode = _cy.getElementById(elkNode.id);
|
||||
if (cyNode.length && elkNode.x != null) {
|
||||
cyNode.position({
|
||||
x: elkNode.x + (elkNode.width || 0) / 2,
|
||||
y: elkNode.y + (elkNode.height || 0) / 2,
|
||||
});
|
||||
}
|
||||
});
|
||||
setTimeout(centerGraph, 120);
|
||||
})
|
||||
.catch(() => {
|
||||
const layout = _cy.layout(layoutOptions);
|
||||
layout.one('layoutstop', () => setTimeout(centerGraph, 100));
|
||||
layout.run();
|
||||
});
|
||||
}
|
||||
|
||||
function render(container, graphData, options) {
|
||||
if (!container || typeof cytoscape === 'undefined') {
|
||||
if (container) {
|
||||
container.innerHTML = '<div class="error-message">Cytoscape 未加载</div>';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
destroy();
|
||||
_graphData = graphData || { nodes: [], edges: [] };
|
||||
_onNodeSelect = options && options.onNodeSelect;
|
||||
_onEdgeSelect = options && options.onEdgeSelect;
|
||||
|
||||
const nodes = _graphData.nodes || [];
|
||||
const edges = _graphData.edges || [];
|
||||
if (!nodes.length) {
|
||||
const title = (options && options.emptyTitle) || '';
|
||||
const hint = (options && options.emptyText) || '暂无事实关系';
|
||||
const steps = (options && options.emptySteps) || [];
|
||||
const actionLabel = options && options.emptyActionLabel;
|
||||
const stepsHtml = steps.length
|
||||
? '<ol class="project-fact-graph-empty-steps">' +
|
||||
steps.map((s) => '<li>' + escapeHtml(String(s)) + '</li>').join('') +
|
||||
'</ol>'
|
||||
: '';
|
||||
const actionHtml =
|
||||
actionLabel && options.onEmptyAction
|
||||
? '<button type="button" class="btn-primary btn-small project-fact-graph-empty-cta">' +
|
||||
escapeHtml(actionLabel) +
|
||||
'</button>'
|
||||
: '';
|
||||
container.innerHTML =
|
||||
'<div class="project-fact-graph-empty">' +
|
||||
'<div class="project-fact-graph-empty-icon" aria-hidden="true">' +
|
||||
'<svg width="48" height="48" viewBox="0 0 24 24" fill="none"><circle cx="6" cy="6" r="2.5" fill="#4F46E5" opacity="0.9"/><circle cx="18" cy="6" r="2.5" fill="#E11D48" opacity="0.9"/><circle cx="12" cy="18" r="2.5" fill="#0D9488" opacity="0.9"/>' +
|
||||
'<path d="M8 7l4 9M16 7l-4 9M8 7h8" stroke="#CBD5E1" stroke-width="1.5" stroke-linecap="round"/></svg>' +
|
||||
'</div>' +
|
||||
(title ? '<h4 class="project-fact-graph-empty-title">' + escapeHtml(title) + '</h4>' : '') +
|
||||
'<p class="project-fact-graph-empty-hint">' + escapeHtml(hint) + '</p>' +
|
||||
stepsHtml +
|
||||
actionHtml +
|
||||
'</div>';
|
||||
const cta = container.querySelector('.project-fact-graph-empty-cta');
|
||||
if (cta && typeof options.onEmptyAction === 'function') {
|
||||
cta.addEventListener('click', options.onEmptyAction);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
const isComplex = nodes.length > 15 || edges.length > 25;
|
||||
const elements = [];
|
||||
const nodeIds = new Set();
|
||||
|
||||
nodes.forEach((node) => {
|
||||
nodeIds.add(node.id);
|
||||
const theme = nodeTheme(node.type || node.category || 'note');
|
||||
const label = node.label || node.fact_key || node.id;
|
||||
const statusBadge = buildStatusBadge(node.confidence);
|
||||
const layout = computeNodeLayout(node.type || node.category || 'note', label, statusBadge, theme);
|
||||
elements.push({
|
||||
data: {
|
||||
id: node.id,
|
||||
label: layout.searchLabel,
|
||||
factKey: node.fact_key || node.id,
|
||||
type: node.type || 'note',
|
||||
typeLabel: theme.typeLabel,
|
||||
typeEn: theme.typeEn,
|
||||
accentColor: theme.accent,
|
||||
statusBadge: statusBadge,
|
||||
confidence: node.confidence || '',
|
||||
nodeWidth: layout.width,
|
||||
nodeHeight: layout.height,
|
||||
cardSvgUrl: buildNodeCardSvgUrl(theme, layout, node.confidence),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const validEdges = [];
|
||||
edges.forEach((edge, idx) => {
|
||||
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) return;
|
||||
const id = edge.id || 'e-' + idx;
|
||||
validEdges.push({ ...edge, id });
|
||||
elements.push({
|
||||
data: {
|
||||
id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: edge.type || 'leads_to',
|
||||
confidence: edge.confidence || 'confirmed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
_cy = cytoscape({
|
||||
container,
|
||||
elements,
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
style: {
|
||||
label: '',
|
||||
width: (ele) => ele.data('nodeWidth') || CARD_MIN_W,
|
||||
height: (ele) => ele.data('nodeHeight') || CARD_MIN_H,
|
||||
shape: 'round-rectangle',
|
||||
'background-color': '#ffffff',
|
||||
'background-image': (ele) => ele.data('cardSvgUrl') || 'none',
|
||||
'background-width': (ele) => (ele.data('nodeWidth') || CARD_MIN_W) + 'px',
|
||||
'background-height': (ele) => (ele.data('nodeHeight') || CARD_MIN_H) + 'px',
|
||||
'background-position-x': '50%',
|
||||
'background-position-y': '50%',
|
||||
'background-fit': 'none',
|
||||
'border-width': 0,
|
||||
'background-opacity': 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
width: 2.2,
|
||||
'line-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1',
|
||||
'target-arrow-color': (ele) => EDGE_COLORS[ele.data('type')] || '#CBD5E1',
|
||||
'target-arrow-shape': 'triangle',
|
||||
'curve-style': 'bezier',
|
||||
opacity: (ele) => (ele.data('confidence') === 'tentative' ? 0.55 : 0.9),
|
||||
'line-style': (ele) => (ele.data('confidence') === 'tentative' ? 'dashed' : 'solid'),
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'edge:selected',
|
||||
style: {
|
||||
width: 3.5,
|
||||
opacity: 1,
|
||||
'line-color': '#4F46E5',
|
||||
'target-arrow-color': '#4F46E5',
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: 'node:selected',
|
||||
style: {
|
||||
'border-width': 3,
|
||||
'border-color': '#4F46E5',
|
||||
'border-opacity': 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
minZoom: 0.35,
|
||||
maxZoom: 3,
|
||||
});
|
||||
|
||||
_cy.on('tap', 'node', (evt) => {
|
||||
const d = evt.target.data();
|
||||
const key = d.factKey || d.id;
|
||||
if (_connectMode && _connectPick) {
|
||||
_connectPick(key);
|
||||
return;
|
||||
}
|
||||
if (typeof _onNodeSelect === 'function') {
|
||||
_onNodeSelect(key, d);
|
||||
}
|
||||
});
|
||||
|
||||
_cy.on('tap', 'edge', (evt) => {
|
||||
if (_connectMode && _connectPick) return;
|
||||
const d = evt.target.data();
|
||||
if (typeof _onEdgeSelect === 'function') {
|
||||
_onEdgeSelect(d.id, d);
|
||||
}
|
||||
});
|
||||
|
||||
_cy.on('tap', (evt) => {
|
||||
if (evt.target === _cy) {
|
||||
clearEdgeSelection();
|
||||
}
|
||||
});
|
||||
|
||||
applyElkLayout(validEdges, isComplex);
|
||||
return _cy;
|
||||
}
|
||||
|
||||
function filterBySearch(query) {
|
||||
if (!_cy) return;
|
||||
const q = (query || '').trim().toLowerCase();
|
||||
_cy.nodes().forEach((n) => {
|
||||
if (!q) {
|
||||
n.style('opacity', 1);
|
||||
return;
|
||||
}
|
||||
const text = (
|
||||
(n.data('label') || '') +
|
||||
' ' +
|
||||
(n.data('factKey') || '') +
|
||||
' ' +
|
||||
(n.data('typeLabel') || '')
|
||||
).toLowerCase();
|
||||
n.style('opacity', text.includes(q) ? 1 : 0.15);
|
||||
});
|
||||
_cy.edges().forEach((e) => {
|
||||
e.style('opacity', q ? 0.12 : 0.9);
|
||||
});
|
||||
}
|
||||
|
||||
let _connectMode = false;
|
||||
let _connectPick = null;
|
||||
|
||||
function selectEdge(edgeId) {
|
||||
if (!_cy || !edgeId) return;
|
||||
_cy.elements().unselect();
|
||||
const edge = _cy.getElementById(edgeId);
|
||||
if (edge.length) edge.select();
|
||||
}
|
||||
|
||||
function clearEdgeSelection() {
|
||||
if (!_cy) return;
|
||||
_cy.elements().unselect();
|
||||
}
|
||||
|
||||
function setConnectMode(enabled, onPick) {
|
||||
_connectMode = !!enabled;
|
||||
_connectPick = typeof onPick === 'function' ? onPick : null;
|
||||
if (_cy) {
|
||||
_cy.userPanningEnabled(!_connectMode);
|
||||
}
|
||||
}
|
||||
|
||||
/** 与后端 GraphNodeType 一致:优先 fact_key 前缀,再 category/type。 */
|
||||
function resolveGraphNodeType(node) {
|
||||
if (!node) return 'note';
|
||||
const key = String(node.fact_key || node.id || '').toLowerCase();
|
||||
if (key.startsWith('target/')) return 'target';
|
||||
if (key.startsWith('exploit/') || key.startsWith('poc/') || key.startsWith('evidence/')) return 'exploit';
|
||||
if (key.startsWith('chain/')) return 'chain';
|
||||
if (key.startsWith('finding/')) return 'finding';
|
||||
if (key.startsWith('auth/')) return 'auth';
|
||||
if (key.startsWith('infra/')) return 'infra';
|
||||
if (key.startsWith('business/')) return 'business';
|
||||
if (key.startsWith('vuln:')) return 'vulnerability';
|
||||
const t = String(node.type || node.category || 'note').toLowerCase();
|
||||
if (t === 'vuln') return 'vulnerability';
|
||||
return t || 'note';
|
||||
}
|
||||
|
||||
global.ProjectFactGraph = {
|
||||
render,
|
||||
destroy,
|
||||
center: centerGraph,
|
||||
filterBySearch,
|
||||
setConnectMode,
|
||||
selectEdge,
|
||||
clearEdgeSelection,
|
||||
nodeTheme,
|
||||
resolveGraphNodeType,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+74
-47
@@ -172,6 +172,59 @@ function einoMainStreamPlanningTitle(responseData) {
|
||||
return prefix + '📝 ' + plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
||||
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||
*/
|
||||
function finalizeMainResponseStreamItem(streamState, finalMessage, responseData) {
|
||||
if (!streamState || !streamState.itemId) return false;
|
||||
const item = document.getElementById(streamState.itemId);
|
||||
if (!item || !item.parentNode) return false;
|
||||
|
||||
const fullText = (finalMessage != null && String(finalMessage).trim() !== '')
|
||||
? String(finalMessage)
|
||||
: (streamState.buffer || '');
|
||||
if (!String(fullText).trim()) {
|
||||
item.parentNode.removeChild(item);
|
||||
return false;
|
||||
}
|
||||
|
||||
const meta = Object.assign({}, streamState.streamMeta || {}, responseData || {});
|
||||
|
||||
item.classList.remove('timeline-item-thinking');
|
||||
item.classList.add('timeline-item-planning');
|
||||
item.dataset.timelineType = 'planning';
|
||||
delete item.dataset.responseStreamPlaceholder;
|
||||
if (meta.orchestration != null && String(meta.orchestration).trim() !== '') {
|
||||
item.dataset.orchestration = String(meta.orchestration).trim();
|
||||
}
|
||||
if (meta.einoAgent != null && String(meta.einoAgent).trim() !== '') {
|
||||
item.dataset.einoAgent = String(meta.einoAgent).trim();
|
||||
}
|
||||
|
||||
const titleEl = item.querySelector('.timeline-item-title');
|
||||
if (titleEl && typeof einoMainStreamPlanningTitle === 'function') {
|
||||
titleEl.textContent = einoMainStreamPlanningTitle(meta);
|
||||
}
|
||||
|
||||
let contentEl = item.querySelector('.timeline-item-content');
|
||||
if (!contentEl) {
|
||||
contentEl = document.createElement('div');
|
||||
contentEl.className = 'timeline-item-content';
|
||||
item.appendChild(contentEl);
|
||||
}
|
||||
flushStreamPlainTextUpdate(contentEl);
|
||||
const body = typeof formatTimelineStreamBody === 'function'
|
||||
? formatTimelineStreamBody(fullText, meta)
|
||||
: fullText;
|
||||
if (typeof formatMarkdown === 'function') {
|
||||
setTimelineItemContentStreamRich(contentEl, formatMarkdown(body, timelineMarkdownOpts));
|
||||
} else {
|
||||
setTimelineItemContentStreamPlain(contentEl, body);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function translateProgressMessage(message, data) {
|
||||
if (!message || typeof message !== 'string') return message;
|
||||
if (typeof window.t !== 'function') return message;
|
||||
@@ -224,6 +277,7 @@ if (typeof window !== 'undefined') {
|
||||
window.translateProgressMessage = translateProgressMessage;
|
||||
window.translatePlanExecuteAgentName = translatePlanExecuteAgentName;
|
||||
window.einoMainStreamPlanningTitle = einoMainStreamPlanningTitle;
|
||||
window.finalizeMainResponseStreamItem = finalizeMainResponseStreamItem;
|
||||
window.formatTimelineStreamBody = formatTimelineStreamBody;
|
||||
}
|
||||
|
||||
@@ -2005,45 +2059,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result_delta': {
|
||||
const deltaInfo = event.data || {};
|
||||
const toolCallId = deltaInfo.toolCallId || null;
|
||||
if (!toolCallId) break;
|
||||
|
||||
const key = toolResultStreamKey(progressId, toolCallId);
|
||||
let state = toolResultStreamStateByKey.get(key);
|
||||
const deltaText = event.message || '';
|
||||
if (!deltaText) break;
|
||||
|
||||
if (!state) {
|
||||
const mapping = getToolCallMapping(progressId, toolCallId);
|
||||
let callItemId = mapping && mapping.itemId ? mapping.itemId : null;
|
||||
if (callItemId) {
|
||||
const callItem = document.getElementById(callItemId);
|
||||
if (callItem) {
|
||||
ensureToolCallResultSlot(callItem);
|
||||
const section = callItem.querySelector('.tool-result-section');
|
||||
if (section) {
|
||||
section.classList.remove('pending');
|
||||
section.className = 'tool-result-section success';
|
||||
}
|
||||
}
|
||||
}
|
||||
state = { itemId: callItemId, buffer: '', onCallItem: !!callItemId };
|
||||
toolResultStreamStateByKey.set(key, state);
|
||||
}
|
||||
|
||||
state.buffer += deltaText;
|
||||
const item = state.itemId ? document.getElementById(state.itemId) : null;
|
||||
if (item) {
|
||||
const pre = item.querySelector('pre.tool-result');
|
||||
if (pre) {
|
||||
pre.classList.remove('tool-result-pending');
|
||||
scheduleStreamPlainTextUpdate(pre, state.buffer);
|
||||
}
|
||||
}
|
||||
case 'tool_result_delta':
|
||||
// 工具执行过程不流式展示,仅等 tool_result 展示最终结果。
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_result':
|
||||
const resultInfo = event.data || {};
|
||||
@@ -2401,14 +2419,18 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
||||
}
|
||||
|
||||
// 移除 response_start/response_delta 阶段创建的「规划中」占位条目。
|
||||
// 该条目属于 UI-only 的流式展示,不应被拷贝到最终的过程详情里;
|
||||
// 否则会出现“不刷新页面仍显示规划中,刷新后消失”的不一致。
|
||||
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||
if (streamState && streamState.itemId) {
|
||||
const planningItem = document.getElementById(streamState.itemId);
|
||||
if (planningItem && planningItem.parentNode) {
|
||||
planningItem.parentNode.removeChild(planningItem);
|
||||
}
|
||||
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
||||
} else if (event.message && String(event.message).trim()) {
|
||||
addTimelineItem(timeline, 'planning', {
|
||||
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||
? einoMainStreamPlanningTitle(responseData)
|
||||
: ('📝 ' + (typeof window.t === 'function' ? window.t('chat.planning') : '规划中')),
|
||||
message: event.message,
|
||||
data: responseData,
|
||||
expanded: false
|
||||
});
|
||||
}
|
||||
|
||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||
@@ -2429,6 +2451,11 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const respMid = responseData.messageId;
|
||||
if (respMid) {
|
||||
applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid);
|
||||
if (typeof window.syncAssistantReasoningContentFromServer === 'function') {
|
||||
setTimeout(function () {
|
||||
window.syncAssistantReasoningContentFromServer(respMid, assistantIdFinal);
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -3824,7 +3851,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
|
||||
const isPeak = c.i === peakIdx && (c.p.total || 0) > 0;
|
||||
const dotClass = 'mcp-stats-timeline-dot' + (isPeak ? ' mcp-stats-timeline-dot--peak' : '');
|
||||
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 3 : 2.5}"
|
||||
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 2 : 1.5}"
|
||||
data-time="${escapeHtml(tipTime)}"
|
||||
data-total="${c.p.total || 0}"
|
||||
data-failed="${c.p.failed || 0}" />`;
|
||||
@@ -3832,7 +3859,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
|
||||
const peakC = coords[peakIdx];
|
||||
const peakMarker = (peakC.p.total || 0) > 0
|
||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="7" />`
|
||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="5" />`
|
||||
: '';
|
||||
|
||||
return `<svg class="mcp-stats-timeline__chart" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
|
||||
|
||||
+334
-6
@@ -64,6 +64,8 @@ Host: ...
|
||||
## 关联
|
||||
- related_vulnerability_id: <可选>
|
||||
- 依赖事实: <fact_key,如 auth/session_cookie>
|
||||
- 结构化关系边(自动同步;links 文本格式 type: source_fact_key):
|
||||
- discovered_on: target/primary_domain
|
||||
|
||||
## 备注与不确定性
|
||||
<待验证假设、环境差异、绕过尝试记录>`;
|
||||
@@ -730,20 +732,288 @@ async function selectProject(id) {
|
||||
|
||||
function switchProjectTab(tab) {
|
||||
currentProjectTab = tab;
|
||||
['facts', 'conversations', 'vulns', 'settings'].forEach((t) => {
|
||||
['facts', 'graph', 'conversations', 'vulns', 'settings'].forEach((t) => {
|
||||
const btn = document.getElementById(`project-tab-${t}`);
|
||||
const panel = document.getElementById(`project-panel-${t}`);
|
||||
if (btn) btn.classList.toggle('is-active', t === tab);
|
||||
if (panel) panel.hidden = t !== tab;
|
||||
});
|
||||
if (tab === 'facts') loadProjectFacts();
|
||||
if (tab === 'graph') loadProjectFactGraph();
|
||||
if (tab === 'conversations') loadProjectConversations();
|
||||
if (tab === 'vulns') loadProjectVulnerabilities();
|
||||
}
|
||||
|
||||
let _selectedGraphFactKey = null;
|
||||
let _selectedGraphEdgeId = null;
|
||||
let _currentGraphData = null;
|
||||
let _graphConnectMode = false;
|
||||
let _graphConnectSource = null;
|
||||
|
||||
function toggleProjectFactGraphConnectMode() {
|
||||
_graphConnectMode = !_graphConnectMode;
|
||||
_graphConnectSource = null;
|
||||
const btn = document.getElementById('project-graph-connect-btn');
|
||||
if (btn) {
|
||||
btn.classList.toggle('is-active', _graphConnectMode);
|
||||
btn.textContent = _graphConnectMode ? tp('projects.graphConnectActive') : tp('projects.graphConnect');
|
||||
btn.classList.toggle('projects-graph-action-btn--connect-active', _graphConnectMode);
|
||||
}
|
||||
if (typeof ProjectFactGraph !== 'undefined') {
|
||||
ProjectFactGraph.setConnectMode(_graphConnectMode, handleGraphConnectNodePick);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGraphConnectNodePick(factKey) {
|
||||
if (!factKey || String(factKey).startsWith('vuln:')) return;
|
||||
if (!_graphConnectSource) {
|
||||
_graphConnectSource = factKey;
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(tpFmt('projects.graphConnectPickTarget', `已选源节点 ${factKey},请点击目标节点`, { source: factKey }), 'info');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_graphConnectSource === factKey) return;
|
||||
const edgeType = window.prompt(tp('projects.graphEdgeTypePrompt'), 'leads_to');
|
||||
if (!edgeType) {
|
||||
_graphConnectSource = null;
|
||||
return;
|
||||
}
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source_fact_key: _graphConnectSource,
|
||||
target_fact_key: factKey,
|
||||
edge_type: edgeType.trim(),
|
||||
}),
|
||||
});
|
||||
_graphConnectSource = null;
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return alert(err.error || tp('projects.graphConnectFailed'));
|
||||
}
|
||||
if (typeof showNotification === 'function') showNotification(tp('projects.graphConnectSuccess'), 'success');
|
||||
loadProjectFactGraph();
|
||||
loadProjectFacts();
|
||||
}
|
||||
|
||||
function formatIncomingLinksForModal(links) {
|
||||
if (!links || !links.length) return '';
|
||||
return links
|
||||
.map((e) => `${e.edge_type || e.type}: ${e.source_fact_key || e.from}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
|
||||
async function loadProjectFactGraph() {
|
||||
const container = document.getElementById('project-fact-graph-container');
|
||||
const statsEl = document.getElementById('project-fact-graph-stats');
|
||||
if (!container || !currentProjectId) return;
|
||||
container.innerHTML = `<div class="loading-spinner">${escapeHtml(tp('common.loading'))}</div>`;
|
||||
closeProjectFactGraphSidebar();
|
||||
const view = document.getElementById('project-graph-view')?.value || 'path';
|
||||
const hideDeprecated = document.getElementById('project-facts-filter-hide-deprecated')?.checked !== false;
|
||||
const params = new URLSearchParams({ view });
|
||||
if (!hideDeprecated) params.set('exclude_deprecated', '0');
|
||||
try {
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-graph?${params}`);
|
||||
if (!res.ok) throw new Error(tp('common.loadFailed'));
|
||||
const data = await res.json();
|
||||
_currentGraphData = data;
|
||||
if (typeof ProjectFactGraph !== 'undefined') {
|
||||
ProjectFactGraph.render(container, data, {
|
||||
emptyText: tp('projects.graphEmpty'),
|
||||
emptyTitle: tp('projects.graphEmptyTitle'),
|
||||
emptySteps: [
|
||||
tp('projects.graphEmptyStep1'),
|
||||
tp('projects.graphEmptyStep2'),
|
||||
tp('projects.graphEmptyStep3'),
|
||||
],
|
||||
emptyActionLabel: tp('projects.graphEmptyCta'),
|
||||
onEmptyAction: () => showAddFactModal(),
|
||||
onNodeSelect: (factKey) => showProjectFactGraphNode(factKey, _currentGraphData),
|
||||
onEdgeSelect: (edgeId) => showProjectFactGraphEdge(edgeId, _currentGraphData),
|
||||
});
|
||||
}
|
||||
const nodeCount = (data.nodes || []).length;
|
||||
const edgeCount = (data.edges || []).length;
|
||||
if (statsEl) {
|
||||
statsEl.innerHTML =
|
||||
`<span class="projects-graph-stat-badge"><strong>${nodeCount}</strong> ${escapeHtml(tp('projects.graphStatsNodes'))}</span>` +
|
||||
`<span class="projects-graph-stat-badge"><strong>${edgeCount}</strong> ${escapeHtml(tp('projects.graphStatsEdges'))}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="error-message">${escapeHtml(e.message || tp('common.loadFailed'))}</div>`;
|
||||
if (statsEl) statsEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function filterProjectFactGraph() {
|
||||
const q = document.getElementById('project-graph-search')?.value || '';
|
||||
if (typeof ProjectFactGraph !== 'undefined') {
|
||||
ProjectFactGraph.filterBySearch(q);
|
||||
}
|
||||
}
|
||||
|
||||
function centerProjectFactGraph() {
|
||||
if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.center();
|
||||
}
|
||||
|
||||
function closeProjectFactGraphSidebar() {
|
||||
_selectedGraphFactKey = null;
|
||||
_selectedGraphEdgeId = null;
|
||||
if (typeof ProjectFactGraph !== 'undefined') ProjectFactGraph.clearEdgeSelection();
|
||||
const sidebar = document.getElementById('project-fact-graph-sidebar');
|
||||
if (sidebar) sidebar.hidden = true;
|
||||
}
|
||||
|
||||
function isSyntheticGraphEdge(edge) {
|
||||
if (!edge) return true;
|
||||
const id = String(edge.id || '');
|
||||
const type = String(edge.type || '');
|
||||
return id.startsWith('vuln-link:') || type === 'links_vuln';
|
||||
}
|
||||
|
||||
function getGraphEdgesForFact(factKey, graphData) {
|
||||
if (!factKey || !graphData?.edges) return [];
|
||||
return graphData.edges.filter((e) => e.source === factKey || e.target === factKey);
|
||||
}
|
||||
|
||||
function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) {
|
||||
const edges = getGraphEdgesForFact(factKey, graphData);
|
||||
if (!edges.length) {
|
||||
return `<p class="project-fact-graph-edges-empty">${escapeHtml(tp('projects.graphEdgesEmpty'))}</p>`;
|
||||
}
|
||||
return edges
|
||||
.map((e) => {
|
||||
const isOut = e.source === factKey;
|
||||
const dirLabel = isOut ? tp('projects.graphEdgeFromSelf') : tp('projects.graphEdgeToSelf');
|
||||
const src = e.source || '';
|
||||
const tgt = e.target || '';
|
||||
const selected = e.id === selectedEdgeId ? ' is-selected' : '';
|
||||
const synthetic = isSyntheticGraphEdge(e);
|
||||
const deleteBtn = synthetic
|
||||
? `<span class="project-fact-graph-edge-synthetic" title="${escapeHtml(tp('projects.graphEdgeSynthetic'))}">—</span>`
|
||||
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeHtml(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeHtml(tp('projects.graphDeleteEdge'))}">×</button>`;
|
||||
return `<div class="project-fact-graph-edge-item${selected}" data-edge-id="${escapeHtml(e.id)}" onclick="focusProjectFactGraphEdge(${JSON.stringify(e.id)})">
|
||||
<span class="project-fact-graph-edge-dir">${escapeHtml(dirLabel)}</span>
|
||||
<span class="project-fact-graph-edge-type">${escapeHtml(e.type || '')}</span>
|
||||
<span class="project-fact-graph-edge-peer" title="${escapeHtml(src + ' → ' + tgt)}">${escapeHtml(src)} → ${escapeHtml(tgt)}</span>
|
||||
${deleteBtn}
|
||||
</div>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function renderProjectFactGraphEdges(factKey, graphData, selectedEdgeId) {
|
||||
const wrap = document.getElementById('project-fact-graph-edges-wrap');
|
||||
const list = document.getElementById('project-fact-graph-edges-list');
|
||||
if (!wrap || !list) return;
|
||||
const edges = getGraphEdgesForFact(factKey, graphData);
|
||||
wrap.hidden = false;
|
||||
list.innerHTML = renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId);
|
||||
if (selectedEdgeId) {
|
||||
const selectedEl = list.querySelector('[data-edge-id="' + String(selectedEdgeId).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"]');
|
||||
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
if (!edges.length) wrap.hidden = false;
|
||||
}
|
||||
|
||||
function showProjectFactGraphNode(factKey, graphData, selectedEdgeId) {
|
||||
if (!factKey || String(factKey).startsWith('vuln:')) {
|
||||
closeProjectFactGraphSidebar();
|
||||
return;
|
||||
}
|
||||
_selectedGraphFactKey = factKey;
|
||||
_selectedGraphEdgeId = selectedEdgeId || null;
|
||||
const node = (graphData?.nodes || []).find((n) => n.fact_key === factKey || n.id === factKey);
|
||||
const sidebar = document.getElementById('project-fact-graph-sidebar');
|
||||
const titleEl = document.getElementById('project-fact-graph-node-title');
|
||||
const metaEl = document.getElementById('project-fact-graph-node-meta');
|
||||
const categoryEl = document.getElementById('project-fact-graph-node-category');
|
||||
if (!sidebar || !titleEl || !metaEl) return;
|
||||
titleEl.textContent = factKey;
|
||||
if (categoryEl) {
|
||||
const visualType =
|
||||
typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.resolveGraphNodeType
|
||||
? ProjectFactGraph.resolveGraphNodeType(node)
|
||||
: node?.type || node?.category || 'note';
|
||||
const theme =
|
||||
typeof ProjectFactGraph !== 'undefined' && ProjectFactGraph.nodeTheme
|
||||
? ProjectFactGraph.nodeTheme(visualType)
|
||||
: { typeEn: String(visualType).toUpperCase(), typeLabel: visualType };
|
||||
categoryEl.textContent = theme.typeEn || String(visualType).toUpperCase();
|
||||
categoryEl.hidden = false;
|
||||
categoryEl.className = 'project-fact-graph-node-category project-fact-graph-node-category--' + visualType;
|
||||
categoryEl.title = theme.typeLabel || visualType;
|
||||
}
|
||||
const conf = node?.confidence || '';
|
||||
const summary = (node?.summary || node?.label || '').trim();
|
||||
if (summary || conf) {
|
||||
const parts = [];
|
||||
if (summary) {
|
||||
parts.push(`<span class="project-fact-graph-node-summary">${escapeHtml(summary)}</span>`);
|
||||
}
|
||||
if (conf) {
|
||||
parts.push(formatConfidenceBadge(conf));
|
||||
}
|
||||
metaEl.innerHTML = parts.join('');
|
||||
} else {
|
||||
metaEl.textContent = '';
|
||||
}
|
||||
renderProjectFactGraphEdges(factKey, graphData, _selectedGraphEdgeId);
|
||||
if (_selectedGraphEdgeId && typeof ProjectFactGraph !== 'undefined') {
|
||||
ProjectFactGraph.selectEdge(_selectedGraphEdgeId);
|
||||
} else if (typeof ProjectFactGraph !== 'undefined') {
|
||||
ProjectFactGraph.clearEdgeSelection();
|
||||
}
|
||||
sidebar.hidden = false;
|
||||
}
|
||||
|
||||
function showProjectFactGraphEdge(edgeId, graphData) {
|
||||
const edge = (graphData?.edges || []).find((e) => e.id === edgeId);
|
||||
if (!edge) return;
|
||||
const anchorKey = edge.source && !String(edge.source).startsWith('vuln:') ? edge.source : edge.target;
|
||||
showProjectFactGraphNode(anchorKey, graphData, edgeId);
|
||||
}
|
||||
|
||||
function focusProjectFactGraphEdge(edgeId) {
|
||||
if (!edgeId || !_currentGraphData) return;
|
||||
showProjectFactGraphEdge(edgeId, _currentGraphData);
|
||||
}
|
||||
|
||||
async function deleteProjectFactEdge(edgeId) {
|
||||
if (!edgeId || !currentProjectId) return;
|
||||
const edge = (_currentGraphData?.edges || []).find((e) => e.id === edgeId);
|
||||
if (isSyntheticGraphEdge(edge)) return;
|
||||
if (!confirm(tp('projects.confirmDeleteGraphEdge'))) return;
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/fact-edges/${encodeURIComponent(edgeId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return alert(err.error || tp('projects.graphEdgeDeleteFailed'));
|
||||
}
|
||||
if (typeof showNotification === 'function') showNotification(tp('projects.graphEdgeDeleteSuccess'), 'success');
|
||||
const keepKey = _selectedGraphFactKey;
|
||||
await loadProjectFactGraph();
|
||||
if (keepKey) showProjectFactGraphNode(keepKey, _currentGraphData);
|
||||
loadProjectFacts();
|
||||
}
|
||||
|
||||
function openSelectedGraphFactDetail() {
|
||||
if (_selectedGraphFactKey) viewProjectFactBody(_selectedGraphFactKey);
|
||||
}
|
||||
|
||||
function editSelectedGraphFact() {
|
||||
if (_selectedGraphFactKey) showEditFactModal(_selectedGraphFactKey);
|
||||
}
|
||||
|
||||
function buildProjectFactsQueryParams() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '200');
|
||||
params.set('include_link_counts', 'true');
|
||||
const search = document.getElementById('project-facts-search')?.value?.trim();
|
||||
const category = document.getElementById('project-facts-filter-category')?.value?.trim();
|
||||
const confidence = document.getElementById('project-facts-filter-confidence')?.value?.trim();
|
||||
@@ -768,11 +1038,11 @@ function debouncedLoadProjectFacts() {
|
||||
async function loadProjectFacts() {
|
||||
const tbody = document.getElementById('project-facts-tbody');
|
||||
if (!tbody || !currentProjectId) return;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loading'))}</td></tr>`;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loading'))}</td></tr>`;
|
||||
const qs = buildProjectFactsQueryParams().toString();
|
||||
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?${qs}`);
|
||||
if (!res.ok) {
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${escapeHtml(tp('common.loadFailed'))}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const facts = await res.json();
|
||||
@@ -782,7 +1052,7 @@ async function loadProjectFacts() {
|
||||
document.getElementById('project-facts-filter-category')?.value ||
|
||||
document.getElementById('project-facts-filter-confidence')?.value ||
|
||||
document.getElementById('project-facts-filter-sparse')?.checked;
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="7">${
|
||||
tbody.innerHTML = `<tr class="is-empty-row"><td colspan="8">${
|
||||
hasFilter ? tp('projects.noMatchingFacts') : tp('projects.noFacts')
|
||||
}</td></tr>`;
|
||||
refreshProjectHeaderStats();
|
||||
@@ -797,10 +1067,16 @@ async function loadProjectFacts() {
|
||||
const pinBadge = f.pinned
|
||||
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
|
||||
: '';
|
||||
const lc = f.link_counts || {};
|
||||
const linkBadge =
|
||||
lc.outgoing || lc.incoming
|
||||
? `<span class="projects-fact-link-badge" title="${escapeHtml(tp('projects.linkCountsTitle'))}">↑${lc.outgoing || 0} ↓${lc.incoming || 0}</span>`
|
||||
: '<span class="projects-fact-link-badge projects-fact-link-badge--empty">—</span>';
|
||||
return `<tr>
|
||||
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
|
||||
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
|
||||
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
||||
<td class="cell-fact-links">${linkBadge}</td>
|
||||
<td>${formatFactBodyBadge(f)}</td>
|
||||
<td>${formatConfidenceBadge(f.confidence)}</td>
|
||||
<td>${formatProjectTime(f.updated_at, f.created_at)}</td>
|
||||
@@ -849,6 +1125,7 @@ async function loadProjectConversations() {
|
||||
<td class="col-actions">
|
||||
<div class="projects-table-actions">
|
||||
<button type="button" class="projects-action-btn projects-action-btn--view" data-conv-id="${idEsc}" onclick="openProjectConversation(this.dataset.convId)">${escapeHtml(tp('projects.open'))}</button>
|
||||
<button type="button" class="projects-action-btn" data-conv-id="${idEsc}" onclick="promoteConversationAttackChain(this.dataset.convId)" title="${escapeHtml(tp('projects.promoteAttackChainTitle'))}">${escapeHtml(tp('projects.promoteAttackChain'))}</button>
|
||||
<button type="button" class="projects-action-btn projects-action-btn--mute" data-conv-id="${idEsc}" onclick="unbindConversationFromProject(this.dataset.convId)" title="${escapeHtml(tp('projects.unbindProjectTitle'))}">${escapeHtml(tp('projects.unbind'))}</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -869,6 +1146,32 @@ function openProjectConversation(conversationId) {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function promoteConversationAttackChain(conversationId) {
|
||||
if (!currentProjectId || !conversationId) return;
|
||||
if (!confirm(tp('projects.confirmPromoteAttackChain'))) return;
|
||||
const res = await apiFetch(
|
||||
`/api/projects/${currentProjectId}/promote-attack-chain/${encodeURIComponent(conversationId)}`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return alert(err.error || tp('projects.promoteAttackChainFailed'));
|
||||
}
|
||||
const data = await res.json();
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(
|
||||
tpFmt(
|
||||
'projects.promoteAttackChainSuccess',
|
||||
`已沉淀 ${data.facts_created || 0} 新 / ${data.facts_updated || 0} 更新 / ${data.edges_created || 0} 边`,
|
||||
data,
|
||||
),
|
||||
'success',
|
||||
);
|
||||
}
|
||||
loadProjectFacts();
|
||||
if (currentProjectTab === 'graph') loadProjectFactGraph();
|
||||
}
|
||||
|
||||
async function unbindConversationFromProject(conversationId) {
|
||||
if (!conversationId || !confirm(tp('projects.confirmUnbindConversation'))) return;
|
||||
const res = await apiFetch(`/api/conversations/${encodeURIComponent(conversationId)}/project`, {
|
||||
@@ -1270,12 +1573,18 @@ async function saveProjectModal() {
|
||||
return;
|
||||
}
|
||||
const fromChat = !!window._projectModalFromChat;
|
||||
const fromWebshellConnId = window._projectModalFromWebshellConnId || '';
|
||||
window._projectModalFromChat = false;
|
||||
window._projectModalFromWebshellConnId = '';
|
||||
closeProjectModal();
|
||||
const saved = await res.json();
|
||||
await loadProjectsList();
|
||||
if (saved.id) {
|
||||
if (fromChat && !editId) {
|
||||
if (fromWebshellConnId && !editId) {
|
||||
if (typeof applyWebshellAiProjectSelection === 'function') {
|
||||
await applyWebshellAiProjectSelection(saved.id);
|
||||
}
|
||||
} else if (fromChat && !editId) {
|
||||
await applyChatProjectSelection(saved.id);
|
||||
} else {
|
||||
await selectProject(saved.id);
|
||||
@@ -1503,6 +1812,10 @@ function resetFactModalForm() {
|
||||
if (pinEl) pinEl.checked = false;
|
||||
const rel = document.getElementById('fact-modal-related-vuln');
|
||||
if (rel) rel.value = '';
|
||||
const linksEl = document.getElementById('fact-modal-links');
|
||||
if (linksEl) linksEl.value = '';
|
||||
const incomingWrap = document.getElementById('fact-modal-incoming-links-wrap');
|
||||
if (incomingWrap) incomingWrap.hidden = true;
|
||||
updateFactFormHints();
|
||||
}
|
||||
|
||||
@@ -1534,6 +1847,8 @@ function fillFactModalForm(f) {
|
||||
}
|
||||
const rel = document.getElementById('fact-modal-related-vuln');
|
||||
if (rel) rel.value = f.related_vulnerability_id || '';
|
||||
const linksEl = document.getElementById('fact-modal-links');
|
||||
if (linksEl) linksEl.value = formatIncomingLinksForModal(f.incoming_links);
|
||||
const pinEl = document.getElementById('fact-modal-pinned');
|
||||
if (pinEl) pinEl.checked = !!f.pinned;
|
||||
updateFactFormHints();
|
||||
@@ -1550,7 +1865,7 @@ async function showEditFactModal(factKey) {
|
||||
resetFactModalForm();
|
||||
openProjectsOverlay('fact-modal', { focus: false });
|
||||
const res = await apiFetch(
|
||||
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`,
|
||||
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}&include_links=true`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
closeFactModal();
|
||||
@@ -1588,6 +1903,7 @@ async function saveFactModal() {
|
||||
confidence: document.getElementById('fact-modal-confidence').value,
|
||||
pinned: !!document.getElementById('fact-modal-pinned')?.checked,
|
||||
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
|
||||
links_text: document.getElementById('fact-modal-links')?.value || '',
|
||||
};
|
||||
const editId = window._factModalEditId;
|
||||
const res = editId
|
||||
@@ -1607,12 +1923,14 @@ async function saveFactModal() {
|
||||
}
|
||||
closeFactModal();
|
||||
loadProjectFacts();
|
||||
if (currentProjectTab === 'graph') loadProjectFactGraph();
|
||||
}
|
||||
|
||||
async function deleteProjectFact(id) {
|
||||
if (!confirm(tp('projects.confirmDeleteFact'))) return;
|
||||
await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' });
|
||||
loadProjectFacts();
|
||||
if (currentProjectTab === 'graph') loadProjectFactGraph();
|
||||
}
|
||||
|
||||
function parseProjectDate(t) {
|
||||
@@ -1968,5 +2286,15 @@ window.viewFactsForVulnerability = viewFactsForVulnerability;
|
||||
window.openProjectConversation = openProjectConversation;
|
||||
window.unbindConversationFromProject = unbindConversationFromProject;
|
||||
window.loadProjectConversations = loadProjectConversations;
|
||||
window.loadProjectFactGraph = loadProjectFactGraph;
|
||||
window.filterProjectFactGraph = filterProjectFactGraph;
|
||||
window.centerProjectFactGraph = centerProjectFactGraph;
|
||||
window.closeProjectFactGraphSidebar = closeProjectFactGraphSidebar;
|
||||
window.openSelectedGraphFactDetail = openSelectedGraphFactDetail;
|
||||
window.editSelectedGraphFact = editSelectedGraphFact;
|
||||
window.promoteConversationAttackChain = promoteConversationAttackChain;
|
||||
window.deleteProjectFactEdge = deleteProjectFactEdge;
|
||||
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
||||
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||
window.projectNameById = projectNameById;
|
||||
|
||||
+390
-1
@@ -299,6 +299,7 @@ async function loadConfig(loadTools = true) {
|
||||
}
|
||||
|
||||
fillVisionConfigFromCurrent(currentConfig.vision || {});
|
||||
initModelListControls();
|
||||
|
||||
// 填充FOFA配置
|
||||
const fofa = currentConfig.fofa || {};
|
||||
@@ -1569,9 +1570,397 @@ function syncVisionFormEnabled() {
|
||||
if (panel) {
|
||||
panel.style.opacity = enabled ? '1' : '0.55';
|
||||
panel.querySelectorAll('input, select, textarea, a').forEach(el => {
|
||||
if (el.id === 'test-vision-btn') return;
|
||||
if (el.id === 'test-vision-btn' || el.id === 'fetch-vision-models-btn' || el.id === 'vision-model-select') return;
|
||||
el.disabled = !enabled;
|
||||
});
|
||||
syncModelListFetchButtons();
|
||||
}
|
||||
}
|
||||
|
||||
const modelPickSelectMap = {};
|
||||
let modelPickSelectDocListener = false;
|
||||
|
||||
function modelPickT(key) {
|
||||
return typeof window.t === 'function' ? window.t(key) : key;
|
||||
}
|
||||
|
||||
function closeAllModelPickDropdowns() {
|
||||
Object.keys(modelPickSelectMap).forEach(function (id) {
|
||||
modelPickSelectMap[id].wrapper.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
function syncModelPickDropdown(selectId) {
|
||||
const reg = modelPickSelectMap[selectId];
|
||||
if (!reg) return;
|
||||
const { select, dropdown, trigger, wrapper, menuList, countBadge } = reg;
|
||||
const placeholder = modelPickT('settingsBasic.modelsListSelectPlaceholder');
|
||||
|
||||
menuList.innerHTML = '';
|
||||
let optionCount = 0;
|
||||
Array.prototype.forEach.call(select.options, function (opt) {
|
||||
if (!opt.value) return;
|
||||
optionCount += 1;
|
||||
const item = document.createElement('div');
|
||||
item.className = 'model-pick-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
if (opt.value === select.value) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'model-pick-option-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'model-pick-option-label';
|
||||
label.textContent = opt.textContent;
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
menuList.appendChild(item);
|
||||
});
|
||||
|
||||
const selectedOpt = select.selectedIndex >= 0 ? select.options[select.selectedIndex] : null;
|
||||
const labelEl = trigger.querySelector('.model-pick-trigger-label');
|
||||
if (labelEl) {
|
||||
labelEl.textContent = (selectedOpt && selectedOpt.value) ? selectedOpt.textContent : placeholder;
|
||||
}
|
||||
if (countBadge) {
|
||||
countBadge.textContent = String(optionCount);
|
||||
countBadge.style.display = optionCount > 0 ? '' : 'none';
|
||||
}
|
||||
const header = wrapper.querySelector('.model-pick-menu-header');
|
||||
if (header) {
|
||||
header.textContent = optionCount > 0
|
||||
? placeholder + ' · ' + optionCount
|
||||
: placeholder;
|
||||
}
|
||||
|
||||
trigger.disabled = !!select.disabled;
|
||||
wrapper.classList.toggle('is-disabled', !!select.disabled);
|
||||
wrapper.style.display = optionCount > 0 ? '' : 'none';
|
||||
select.style.display = 'none';
|
||||
}
|
||||
|
||||
function enhanceModelPickSelect(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
if (select.dataset.modelPickEnhanced === '1') {
|
||||
syncModelPickDropdown(selectId);
|
||||
return;
|
||||
}
|
||||
select.dataset.modelPickEnhanced = '1';
|
||||
select.classList.add('model-pick-native');
|
||||
select.tabIndex = -1;
|
||||
select.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'model-pick-dropdown';
|
||||
wrapper.style.display = 'none';
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'model-pick-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'listbox');
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'model-pick-trigger-label';
|
||||
labelSpan.textContent = modelPickT('settingsBasic.modelsListSelectPlaceholder');
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'model-pick-trigger-meta';
|
||||
|
||||
const countBadge = document.createElement('span');
|
||||
countBadge.className = 'model-pick-count';
|
||||
countBadge.style.display = 'none';
|
||||
|
||||
const caret = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
caret.setAttribute('class', 'model-pick-caret');
|
||||
caret.setAttribute('viewBox', '0 0 16 16');
|
||||
caret.setAttribute('aria-hidden', 'true');
|
||||
caret.innerHTML = '<path fill="currentColor" d="M4.47 6.47a.75.75 0 0 1 1.06 0L8 8.94l2.47-2.47a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 0 1 0-1.06z"/>';
|
||||
|
||||
meta.appendChild(countBadge);
|
||||
meta.appendChild(caret);
|
||||
trigger.appendChild(labelSpan);
|
||||
trigger.appendChild(meta);
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'model-pick-menu';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'model-pick-menu-header';
|
||||
menu.appendChild(header);
|
||||
|
||||
const menuList = document.createElement('div');
|
||||
menuList.className = 'model-pick-menu-list';
|
||||
menuList.setAttribute('role', 'listbox');
|
||||
menu.appendChild(menuList);
|
||||
|
||||
const parent = select.parentNode;
|
||||
const fetchLink = parent.querySelector('.model-pick-fetch-link');
|
||||
if (fetchLink) {
|
||||
parent.insertBefore(wrapper, fetchLink);
|
||||
} else {
|
||||
parent.appendChild(wrapper);
|
||||
}
|
||||
wrapper.appendChild(trigger);
|
||||
wrapper.appendChild(menu);
|
||||
wrapper.appendChild(select);
|
||||
|
||||
modelPickSelectMap[selectId] = {
|
||||
wrapper,
|
||||
trigger,
|
||||
menu,
|
||||
menuList,
|
||||
countBadge,
|
||||
select
|
||||
};
|
||||
|
||||
if (!modelPickSelectDocListener) {
|
||||
document.addEventListener('click', closeAllModelPickDropdowns);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeAllModelPickDropdowns();
|
||||
});
|
||||
modelPickSelectDocListener = true;
|
||||
}
|
||||
|
||||
trigger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (select.disabled) return;
|
||||
const open = wrapper.classList.contains('open');
|
||||
closeAllModelPickDropdowns();
|
||||
if (!open) wrapper.classList.add('open');
|
||||
});
|
||||
|
||||
menuList.addEventListener('click', function (e) {
|
||||
const opt = e.target.closest('.model-pick-option');
|
||||
if (!opt) return;
|
||||
const val = opt.getAttribute('data-value');
|
||||
if (val === null || val === '') return;
|
||||
if (select.value !== val) {
|
||||
select.value = val;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
wrapper.classList.remove('open');
|
||||
syncModelPickDropdown(selectId);
|
||||
});
|
||||
|
||||
syncModelPickDropdown(selectId);
|
||||
}
|
||||
|
||||
function initModelListControls() {
|
||||
const providerEl = document.getElementById('openai-provider');
|
||||
if (providerEl && !providerEl.dataset.modelListBound) {
|
||||
providerEl.dataset.modelListBound = '1';
|
||||
providerEl.addEventListener('change', syncModelListFetchButtons);
|
||||
}
|
||||
const visionProv = document.getElementById('vision-provider');
|
||||
if (visionProv && !visionProv.dataset.modelListBound) {
|
||||
visionProv.dataset.modelListBound = '1';
|
||||
visionProv.addEventListener('change', syncModelListFetchButtons);
|
||||
}
|
||||
bindModelSelect('openai');
|
||||
bindModelSelect('vision');
|
||||
syncModelListFetchButtons();
|
||||
}
|
||||
|
||||
function modelSelectIds(scope) {
|
||||
if (scope === 'vision') {
|
||||
return { selectId: 'vision-model-select', inputId: 'vision-model' };
|
||||
}
|
||||
return { selectId: 'openai-model-select', inputId: 'openai-model' };
|
||||
}
|
||||
|
||||
function bindModelSelect(scope) {
|
||||
const { selectId, inputId } = modelSelectIds(scope);
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select || select.dataset.bound) return;
|
||||
select.dataset.bound = '1';
|
||||
enhanceModelPickSelect(selectId);
|
||||
select.addEventListener('change', function () {
|
||||
if (!select.value) return;
|
||||
const input = document.getElementById(inputId);
|
||||
if (input) input.value = select.value;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveModelListCredentials(scope) {
|
||||
if (scope === 'vision') {
|
||||
const vp = (document.getElementById('vision-provider')?.value || '').trim();
|
||||
const provider = vp || document.getElementById('openai-provider')?.value || 'openai';
|
||||
const baseUrl = (document.getElementById('vision-base-url')?.value || '').trim()
|
||||
|| (document.getElementById('openai-base-url')?.value || '').trim();
|
||||
const apiKey = (document.getElementById('vision-api-key')?.value || '').trim()
|
||||
|| (document.getElementById('openai-api-key')?.value || '').trim();
|
||||
return { provider, base_url: baseUrl, api_key: apiKey };
|
||||
}
|
||||
return {
|
||||
provider: document.getElementById('openai-provider')?.value || 'openai',
|
||||
base_url: (document.getElementById('openai-base-url')?.value || '').trim(),
|
||||
api_key: (document.getElementById('openai-api-key')?.value || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
function syncModelListFetchButtons() {
|
||||
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
const openaiProv = document.getElementById('openai-provider')?.value || 'openai';
|
||||
const openaiBtn = document.getElementById('fetch-openai-models-btn');
|
||||
const openaiHint = document.getElementById('fetch-openai-models-hint');
|
||||
const openaiSelect = document.getElementById('openai-model-select');
|
||||
const isClaudeOpenai = openaiProv === 'claude';
|
||||
if (openaiBtn) {
|
||||
openaiBtn.style.display = isClaudeOpenai ? 'none' : '';
|
||||
}
|
||||
if (openaiSelect && isClaudeOpenai) {
|
||||
openaiSelect.style.display = 'none';
|
||||
const openaiWrap = modelPickSelectMap['openai-model-select'];
|
||||
if (openaiWrap) openaiWrap.wrapper.style.display = 'none';
|
||||
} else if (openaiSelect && !isClaudeOpenai) {
|
||||
syncModelPickDropdown('openai-model-select');
|
||||
}
|
||||
if (openaiHint) {
|
||||
if (isClaudeOpenai) {
|
||||
openaiHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||
openaiHint.style.display = '';
|
||||
} else {
|
||||
openaiHint.textContent = '';
|
||||
openaiHint.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const vp = (document.getElementById('vision-provider')?.value || '').trim();
|
||||
const visionEffectiveProv = vp || openaiProv;
|
||||
const visionBtn = document.getElementById('fetch-vision-models-btn');
|
||||
const visionHint = document.getElementById('fetch-vision-models-hint');
|
||||
const visionSelect = document.getElementById('vision-model-select');
|
||||
const isClaudeVision = visionEffectiveProv === 'claude';
|
||||
if (visionBtn) {
|
||||
visionBtn.style.display = isClaudeVision ? 'none' : '';
|
||||
}
|
||||
if (visionSelect && isClaudeVision) {
|
||||
visionSelect.style.display = 'none';
|
||||
const visionWrap = modelPickSelectMap['vision-model-select'];
|
||||
if (visionWrap) visionWrap.wrapper.style.display = 'none';
|
||||
} else if (visionSelect && !isClaudeVision) {
|
||||
syncModelPickDropdown('vision-model-select');
|
||||
}
|
||||
if (visionHint) {
|
||||
if (isClaudeVision) {
|
||||
visionHint.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||
visionHint.style.display = '';
|
||||
} else {
|
||||
visionHint.textContent = '';
|
||||
visionHint.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function populateModelSelect(scope, models, currentValue) {
|
||||
const { selectId, inputId } = modelSelectIds(scope);
|
||||
const select = document.getElementById(selectId);
|
||||
const input = document.getElementById(inputId);
|
||||
if (!select) return;
|
||||
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
select.innerHTML = '';
|
||||
const placeholder = document.createElement('option');
|
||||
placeholder.value = '';
|
||||
placeholder.disabled = true;
|
||||
placeholder.textContent = tFn('settingsBasic.modelsListSelectPlaceholder');
|
||||
select.appendChild(placeholder);
|
||||
|
||||
const seen = new Set();
|
||||
const addOption = (id) => {
|
||||
const val = (id || '').trim();
|
||||
if (!val || seen.has(val)) return;
|
||||
seen.add(val);
|
||||
const opt = document.createElement('option');
|
||||
opt.value = val;
|
||||
opt.textContent = val;
|
||||
select.appendChild(opt);
|
||||
};
|
||||
(models || []).forEach(addOption);
|
||||
const cur = (currentValue || (input && input.value) || '').trim();
|
||||
if (cur && seen.has(cur)) {
|
||||
select.value = cur;
|
||||
} else {
|
||||
select.value = '';
|
||||
}
|
||||
enhanceModelPickSelect(selectId);
|
||||
syncModelPickDropdown(selectId);
|
||||
}
|
||||
|
||||
async function fetchModelList(scope) {
|
||||
const tFn = typeof window.t === 'function' ? window.t : (k) => k;
|
||||
const creds = resolveModelListCredentials(scope);
|
||||
const btnId = scope === 'vision' ? 'fetch-vision-models-btn' : 'fetch-openai-models-btn';
|
||||
const resultId = scope === 'vision' ? 'fetch-vision-models-result' : 'fetch-openai-models-result';
|
||||
const inputId = scope === 'vision' ? 'vision-model' : 'openai-model';
|
||||
const btn = document.getElementById(btnId);
|
||||
const resultEl = document.getElementById(resultId);
|
||||
const inputEl = document.getElementById(inputId);
|
||||
|
||||
if (creds.provider === 'claude') {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = tFn('settingsBasic.modelsListClaudeHint');
|
||||
resultEl.style.color = 'var(--text-muted, #718096)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!creds.api_key) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = tFn('settingsBasic.modelsListNeedApiKey');
|
||||
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) {
|
||||
btn.style.pointerEvents = 'none';
|
||||
btn.style.opacity = '0.5';
|
||||
}
|
||||
if (resultEl) {
|
||||
resultEl.textContent = tFn('settingsBasic.modelsListFetching');
|
||||
resultEl.style.color = 'var(--text-muted, #718096)';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/config/list-models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(creds)
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '请求失败');
|
||||
}
|
||||
if (!result.success) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = (result.supported === false
|
||||
? tFn('settingsBasic.modelsListClaudeHint')
|
||||
: tFn('settingsBasic.modelsListFailed')) + ': ' + (result.error || '');
|
||||
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentValue = inputEl ? inputEl.value.trim() : '';
|
||||
populateModelSelect(scope, result.models || [], currentValue);
|
||||
if (resultEl) {
|
||||
const count = result.count != null ? result.count : (result.models || []).length;
|
||||
resultEl.textContent = tFn('settingsBasic.modelsListSuccess').replace('{count}', String(count));
|
||||
resultEl.style.color = 'var(--success-color, #38a169)';
|
||||
}
|
||||
} catch (error) {
|
||||
if (resultEl) {
|
||||
resultEl.textContent = tFn('settingsBasic.modelsListFailed') + ': ' + error.message;
|
||||
resultEl.style.color = 'var(--error-color, #e53e3e)';
|
||||
}
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.style.pointerEvents = '';
|
||||
btn.style.opacity = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-26
@@ -83,6 +83,21 @@ function batchQueueAllowsSubtaskMutation(queue) {
|
||||
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
|
||||
}
|
||||
|
||||
/** 是否允许对指定子任务发起单条执行(与后端 queueAllowsSingleTaskRunLocked 对齐) */
|
||||
function batchQueueCanRunSingleTask(queue, task) {
|
||||
if (!queue || !task) return false;
|
||||
if (task.status === 'running') return false;
|
||||
if (queue.status === 'running') return false;
|
||||
return queue.status === 'pending' || queue.status === 'paused' || queue.status === 'completed' || queue.status === 'cancelled';
|
||||
}
|
||||
|
||||
function batchQueueRunSingleTaskDisabledReason(queue, task) {
|
||||
if (!queue || !task) return _t('tasks.runSingleTaskUnavailable');
|
||||
if (task.status === 'running') return _t('tasks.runSingleTaskUnavailableSelf');
|
||||
if (queue.status === 'running') return _t('tasks.runSingleTaskUnavailableQueue');
|
||||
return _t('tasks.runSingleTaskUnavailable');
|
||||
}
|
||||
|
||||
// HTML转义函数(如果未定义)
|
||||
if (typeof escapeHtml === 'undefined') {
|
||||
function escapeHtml(text) {
|
||||
@@ -1497,6 +1512,8 @@ async function showBatchQueueDetail(queueId) {
|
||||
${queue.tasks.map((task, index) => {
|
||||
const taskStatus = taskStatusMap[task.status] || { text: task.status, class: 'batch-task-status-unknown' };
|
||||
const canEdit = allowSubtaskMutation && task.status !== 'running';
|
||||
const canRunSingle = batchQueueCanRunSingleTask(queue, task);
|
||||
const runSingleUnavailableTitle = escapeHtml(batchQueueRunSingleTaskDisabledReason(queue, task));
|
||||
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "'").replace(/"/g, """).replace(/\n/g, "\\n");
|
||||
return `
|
||||
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
|
||||
@@ -1504,10 +1521,10 @@ async function showBatchQueueDetail(queueId) {
|
||||
<span class="batch-task-index">#${index + 1}</span>
|
||||
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
|
||||
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
|
||||
<button class="btn-secondary btn-small batch-task-run-btn" ${canRunSingle ? `onclick="runSingleBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();"` : `disabled title="${runSingleUnavailableTitle}"`}>` + _t('tasks.runSingleTask') + `</button>
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
|
||||
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
|
||||
${allowSubtaskMutation && task.status === 'failed' ? `<button class="btn-secondary btn-small" onclick="retryBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();">` + _t('tasks.retryTask') + `</button>` : ''}
|
||||
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
|
||||
</div>
|
||||
${task.startedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.startLabel') + `: ${new Date(task.startedAt).toLocaleString()}</div>` : ''}
|
||||
${task.completedAt ? `<div class="batch-task-time">` + _t('batchQueueDetailModal.completeLabel') + `: ${new Date(task.completedAt).toLocaleString()}</div>` : ''}
|
||||
@@ -2270,38 +2287,25 @@ async function saveInlineAgentMode() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 重试失败任务 ---
|
||||
async function retryBatchTask(queueId, taskId) {
|
||||
// --- 单条执行 ---
|
||||
async function runSingleBatchTask(queueId, taskId) {
|
||||
if (!queueId || !taskId) return;
|
||||
if (!confirm(_t('tasks.confirmRunSingleTask'))) return;
|
||||
try {
|
||||
// 获取任务消息
|
||||
const detailResp = await apiFetch(`/api/batch-tasks/${queueId}`);
|
||||
if (!detailResp.ok) throw new Error(_t('tasks.getQueueDetailFailed'));
|
||||
const detail = await detailResp.json();
|
||||
const task = detail.queue.tasks.find(t => t.id === taskId);
|
||||
if (!task) throw new Error(_t('tasks.taskNotFound') || 'Task not found');
|
||||
const message = task.message;
|
||||
|
||||
// 先添加新任务(pending),再删除旧任务 — 避免先删后加失败导致任务丢失
|
||||
const addResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks`, {
|
||||
const response = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
if (!addResp.ok) {
|
||||
const r = await addResp.json().catch(() => ({}));
|
||||
throw new Error(r.error || _t('tasks.addTaskFailed'));
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || _t('tasks.runSingleTaskFailed'));
|
||||
}
|
||||
// 新任务添加成功后才删除旧任务
|
||||
const delResp = await apiFetch(`/api/batch-tasks/${queueId}/tasks/${taskId}`, { method: 'DELETE' });
|
||||
if (!delResp.ok) {
|
||||
// 删除失败不阻塞(新任务已添加,旧任务保留也不影响)
|
||||
console.warn('删除旧任务失败,但新任务已添加');
|
||||
if (result.autoStarted === false && result.message) {
|
||||
alert(result.message);
|
||||
}
|
||||
showBatchQueueDetail(queueId);
|
||||
refreshBatchQueues();
|
||||
} catch (e) {
|
||||
console.error('重试任务失败:', e);
|
||||
console.error('单条执行失败:', e);
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
@@ -2437,7 +2441,7 @@ window.startInlineEditRole = startInlineEditRole;
|
||||
window.saveInlineRole = saveInlineRole;
|
||||
window.startInlineEditAgentMode = startInlineEditAgentMode;
|
||||
window.saveInlineAgentMode = saveInlineAgentMode;
|
||||
window.retryBatchTask = retryBatchTask;
|
||||
window.runSingleBatchTask = runSingleBatchTask;
|
||||
window.startInlineEditSchedule = startInlineEditSchedule;
|
||||
window.toggleInlineScheduleCron = toggleInlineScheduleCron;
|
||||
window.saveInlineSchedule = saveInlineSchedule;
|
||||
|
||||
+263
-30
@@ -27,6 +27,9 @@ const WEBSHELL_HISTORY_MAX = 100;
|
||||
let webshellClearInProgress = false;
|
||||
// AI 助手:按连接 ID 保存对话 ID,便于多轮对话
|
||||
let webshellAiConvMap = {};
|
||||
// AI 助手:项目绑定(已有对话按 convId,新对话按 connId 草稿)
|
||||
let webshellAiProjectByConvId = {};
|
||||
let webshellAiDraftProjectByConn = {};
|
||||
let webshellAiSending = false;
|
||||
let webshellAiAbortController = null; // AbortController for current AI stream
|
||||
let webshellAiStreamReader = null; // Current ReadableStreamDefaultReader
|
||||
@@ -266,6 +269,7 @@ function wsToggleRolePanel() {
|
||||
var isOpen = panel.style.display === 'flex';
|
||||
if (isOpen) { wsCloseRolePanel(); return; }
|
||||
wsCloseAgentModePanel();
|
||||
wsCloseProjectPanel();
|
||||
panel.style.display = 'flex';
|
||||
}
|
||||
function wsCloseRolePanel() {
|
||||
@@ -340,6 +344,7 @@ function wsToggleAgentModePanel() {
|
||||
var isOpen = panel.style.display === 'flex';
|
||||
if (isOpen) { wsCloseAgentModePanel(); return; }
|
||||
wsCloseRolePanel();
|
||||
wsCloseProjectPanel();
|
||||
panel.style.display = 'flex';
|
||||
}
|
||||
function wsCloseAgentModePanel() {
|
||||
@@ -347,10 +352,204 @@ function wsCloseAgentModePanel() {
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
// ─── WebShell AI 项目选择器(与主「对话」页对齐) ───
|
||||
|
||||
function wsProjectT(key, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
var v = window.t(key);
|
||||
if (v && v !== key) return v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getWebshellAiConvId(conn) {
|
||||
if (!conn || !conn.id) return '';
|
||||
return webshellAiConvMap[conn.id] || '';
|
||||
}
|
||||
|
||||
function getWebshellAiProjectSelection(conn) {
|
||||
if (!conn || !conn.id) return '';
|
||||
var convId = getWebshellAiConvId(conn);
|
||||
if (convId) return webshellAiProjectByConvId[convId] || '';
|
||||
return webshellAiDraftProjectByConn[conn.id] || '';
|
||||
}
|
||||
|
||||
function wsSetWebshellAiProject(conn, projectId) {
|
||||
if (!conn || !conn.id) return;
|
||||
var pid = projectId || '';
|
||||
var convId = getWebshellAiConvId(conn);
|
||||
if (convId) {
|
||||
if (pid) webshellAiProjectByConvId[convId] = pid;
|
||||
else delete webshellAiProjectByConvId[convId];
|
||||
} else if (pid) {
|
||||
webshellAiDraftProjectByConn[conn.id] = pid;
|
||||
} else {
|
||||
delete webshellAiDraftProjectByConn[conn.id];
|
||||
}
|
||||
wsUpdateProjectButtonLabel();
|
||||
}
|
||||
|
||||
function wsIsActiveProjectId(id) {
|
||||
if (!id) return false;
|
||||
var map = window.projectNameById || {};
|
||||
return !!map[id];
|
||||
}
|
||||
|
||||
function wsResolveWebshellAiProjectSelection(conn) {
|
||||
var raw = getWebshellAiProjectSelection(conn);
|
||||
if (!raw) return '';
|
||||
return wsIsActiveProjectId(raw) ? raw : '';
|
||||
}
|
||||
|
||||
function wsUpdateProjectButtonLabel() {
|
||||
var textEl = document.getElementById('ws-project-text');
|
||||
if (!textEl || !webshellCurrentConn) return;
|
||||
var id = wsResolveWebshellAiProjectSelection(webshellCurrentConn);
|
||||
var nameMap = window.projectNameById || {};
|
||||
textEl.textContent = id && nameMap[id] ? nameMap[id] : wsProjectT('projects.noProject', '无项目');
|
||||
}
|
||||
|
||||
async function wsRenderProjectPanelList() {
|
||||
var list = document.getElementById('ws-project-list');
|
||||
if (!list || !webshellCurrentConn) return;
|
||||
var conn = webshellCurrentConn;
|
||||
var selected = wsResolveWebshellAiProjectSelection(conn);
|
||||
var projects = [];
|
||||
try {
|
||||
if (typeof window.fetchAllProjects === 'function') {
|
||||
projects = await window.fetchAllProjects(false);
|
||||
}
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="chat-project-panel-empty">' + escapeHtml(wsProjectT('projects.loadFailedRetry', '加载失败,请重试')) + '</div>';
|
||||
return;
|
||||
}
|
||||
if (typeof window.rebuildProjectNameMap === 'function') {
|
||||
window.rebuildProjectNameMap(projects);
|
||||
}
|
||||
var activeProjects = projects.filter(function (p) { return p.status !== 'archived'; });
|
||||
var items = [{ id: '', name: wsProjectT('projects.noProject', '无项目'), description: wsProjectT('projects.noProjectDescription', '不绑定项目') }].concat(activeProjects);
|
||||
list.innerHTML = '';
|
||||
items.forEach(function (p) {
|
||||
var isNone = !p.id;
|
||||
var isSelected = isNone ? !selected : selected === p.id;
|
||||
var desc = isNone
|
||||
? (p.description || '')
|
||||
: ((p.description || '').trim().slice(0, 80) || wsProjectT('projects.sharedFactBoard', '共享事实黑板'));
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||
btn.setAttribute('role', 'option');
|
||||
btn.onclick = function () { wsSelectProject(p.id || ''); };
|
||||
btn.innerHTML = '<div class="role-selection-item-icon-main">' + (isNone ? '—' : '📁') + '</div>' +
|
||||
'<div class="role-selection-item-content-main">' +
|
||||
'<div class="role-selection-item-name-main">' + escapeHtml(p.name || '未命名') + '</div>' +
|
||||
'<div class="role-selection-item-description-main">' + escapeHtml(desc) + '</div></div>' +
|
||||
(isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : '');
|
||||
list.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
async function wsRenderProjectPanel() {
|
||||
var list = document.getElementById('ws-project-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = '<div class="chat-project-panel-loading">' + escapeHtml(wsProjectT('common.loading', '加载中...')) + '</div>';
|
||||
await wsRenderProjectPanelList();
|
||||
}
|
||||
|
||||
function wsCloseProjectPanel() {
|
||||
var panel = document.getElementById('ws-project-panel');
|
||||
var btn = document.getElementById('ws-project-btn');
|
||||
if (panel) panel.style.display = 'none';
|
||||
if (btn) {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
async function wsToggleProjectPanel() {
|
||||
var panel = document.getElementById('ws-project-panel');
|
||||
var btn = document.getElementById('ws-project-btn');
|
||||
if (!panel) return;
|
||||
var isHidden = panel.style.display === 'none' || !panel.style.display;
|
||||
if (!isHidden) {
|
||||
wsCloseProjectPanel();
|
||||
return;
|
||||
}
|
||||
wsCloseRolePanel();
|
||||
wsCloseAgentModePanel();
|
||||
panel.style.display = 'flex';
|
||||
if (btn) {
|
||||
btn.classList.add('active');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
await wsRenderProjectPanel();
|
||||
}
|
||||
|
||||
async function wsSelectProject(projectId) {
|
||||
wsCloseProjectPanel();
|
||||
await applyWebshellAiProjectSelection(projectId || '');
|
||||
}
|
||||
|
||||
async function applyWebshellAiProjectSelection(projectId) {
|
||||
var conn = webshellCurrentConn;
|
||||
if (!conn || !conn.id) return;
|
||||
var prev = getWebshellAiProjectSelection(conn);
|
||||
if (projectId === prev) {
|
||||
wsUpdateProjectButtonLabel();
|
||||
return;
|
||||
}
|
||||
var convId = getWebshellAiConvId(conn);
|
||||
if (convId) {
|
||||
try {
|
||||
var res = await apiFetch('/api/conversations/' + encodeURIComponent(convId) + '/project', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: projectId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
var err = await res.json().catch(function () { return {}; });
|
||||
throw new Error(err.error || res.statusText);
|
||||
}
|
||||
wsSetWebshellAiProject(conn, projectId);
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(
|
||||
projectId ? wsProjectT('projects.projectBound', '已绑定项目') : wsProjectT('projects.projectUnbound', '已解除项目绑定'),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(wsProjectT('projects.updateProjectBindingFailed', '更新项目绑定失败') + ': ' + (e.message || e));
|
||||
wsUpdateProjectButtonLabel();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
wsSetWebshellAiProject(conn, projectId);
|
||||
}
|
||||
wsUpdateProjectButtonLabel();
|
||||
}
|
||||
|
||||
function showNewProjectModalFromWebshellAi() {
|
||||
wsCloseProjectPanel();
|
||||
if (webshellCurrentConn && webshellCurrentConn.id) {
|
||||
window._projectModalFromWebshellConnId = webshellCurrentConn.id;
|
||||
}
|
||||
window._projectModalFromChat = false;
|
||||
if (typeof showNewProjectModal === 'function') showNewProjectModal();
|
||||
}
|
||||
|
||||
window.applyWebshellAiProjectSelection = applyWebshellAiProjectSelection;
|
||||
window.showNewProjectModalFromWebshellAi = showNewProjectModalFromWebshellAi;
|
||||
window.wsToggleProjectPanel = wsToggleProjectPanel;
|
||||
window.wsCloseProjectPanel = wsCloseProjectPanel;
|
||||
|
||||
// ─── end WebShell AI 项目选择器 ───
|
||||
|
||||
/** 当 WebShell AI Tab 可见时刷新选择器显示(同步主页可能的更改) */
|
||||
function wsRefreshSelectors() {
|
||||
wsUpdateRoleSelectorDisplay();
|
||||
wsRenderRoleList();
|
||||
wsUpdateProjectButtonLabel();
|
||||
var stored = localStorage.getItem('cyberstrike-chat-agent-mode') || 'eino_single';
|
||||
if (stored !== 'eino_single' && stored !== 'deep' && stored !== 'plan_execute' && stored !== 'supervisor') {
|
||||
stored = 'eino_single';
|
||||
@@ -370,6 +569,11 @@ document.addEventListener('click', function (e) {
|
||||
if (modePanel && modePanel.style.display !== 'none' && modeBtn && !modePanel.contains(e.target) && !modeBtn.contains(e.target)) {
|
||||
wsCloseAgentModePanel();
|
||||
}
|
||||
var projectPanel = document.getElementById('ws-project-panel');
|
||||
var projectBtn = document.getElementById('ws-project-btn');
|
||||
if (projectPanel && projectPanel.style.display !== 'none' && projectBtn && !projectPanel.contains(e.target) && !projectBtn.contains(e.target)) {
|
||||
wsCloseProjectPanel();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── end WebShell AI 选择器 ───
|
||||
@@ -1873,6 +2077,7 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
|
||||
apiFetch('/api/conversations/' + encodeURIComponent(convId) + '?include_process_details=1', { method: 'GET' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
wsSetWebshellAiProject(conn, data.projectId || data.project_id || '');
|
||||
messagesContainer.innerHTML = '';
|
||||
var list = data.messages || [];
|
||||
list.forEach(function (msg) {
|
||||
@@ -1893,9 +2098,14 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
|
||||
}
|
||||
}
|
||||
messagesContainer.appendChild(div);
|
||||
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
if (role === 'assistant') {
|
||||
var wsMergedDetails = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
|
||||
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
|
||||
: (msg.processDetails || []);
|
||||
if (wsMergedDetails.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(wsMergedDetails, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (list.length === 0) {
|
||||
@@ -2003,6 +2213,25 @@ function selectWebshell(id, stateReady) {
|
||||
'<div id="webshell-ai-messages" class="webshell-ai-messages"></div>' +
|
||||
'<div class="webshell-ai-input-area">' +
|
||||
'<div class="webshell-ai-selectors-row">' +
|
||||
'<div class="ws-project-selector-wrapper project-selector-wrapper">' +
|
||||
'<button type="button" id="ws-project-btn" class="role-selector-btn" onclick="wsToggleProjectPanel()" aria-label="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '选择项目')) + '" aria-haspopup="listbox" aria-expanded="false" title="' + escapeHtml(wsProjectT('projects.chatSelectorButton', '绑定项目后共享事实黑板(跨对话)')) + '">' +
|
||||
'<span class="role-selector-icon" aria-hidden="true">📁</span>' +
|
||||
'<span id="ws-project-text" class="role-selector-text">' + escapeHtml(wsProjectT('projects.noProject', '无项目')) + '</span>' +
|
||||
'<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' +
|
||||
'</button>' +
|
||||
'<div id="ws-project-panel" class="role-selection-panel chat-project-panel" style="display:none;" role="listbox">' +
|
||||
'<div class="role-selection-panel-header">' +
|
||||
'<h3 class="role-selection-panel-title">' + escapeHtml(wsProjectT('projects.selectProject', '选择项目')) + '</h3>' +
|
||||
'<button type="button" class="role-selection-panel-close" onclick="wsCloseProjectPanel()" title="' + escapeHtml(wsProjectT('common.close', '关闭')) + '" aria-label="' + escapeHtml(wsProjectT('common.close', '关闭')) + '">' +
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
|
||||
'</div>' +
|
||||
'<div class="chat-project-panel-body">' +
|
||||
'<div id="ws-project-list" class="role-selection-list-main"></div>' +
|
||||
'<div class="chat-project-panel-footer">' +
|
||||
'<button type="button" class="role-selection-item-main chat-project-panel-create-btn" onclick="showNewProjectModalFromWebshellAi()">' +
|
||||
'<span class="chat-project-panel-create-icon" aria-hidden="true">+</span>' +
|
||||
'<span class="chat-project-panel-create-label">' + escapeHtml(wsProjectT('projects.newProject', '新建项目')) + '</span>' +
|
||||
'</button></div></div></div></div>' +
|
||||
'<div class="ws-role-selector-wrapper">' +
|
||||
'<button type="button" class="role-selector-btn ws-role-selector-btn" id="ws-role-selector-btn" onclick="wsToggleRolePanel()">' +
|
||||
'<span id="ws-role-selector-icon" class="role-selector-icon">\ud83d\udd35</span>' +
|
||||
@@ -2174,9 +2403,11 @@ function selectWebshell(id, stateReady) {
|
||||
var aiNewConvBtn = document.getElementById('webshell-ai-new-conv');
|
||||
var aiConvListEl = document.getElementById('webshell-ai-conv-list');
|
||||
|
||||
// 初始化角色 + 模式选择器
|
||||
// 初始化角色 + 模式 + 项目选择器
|
||||
wsLoadRoles();
|
||||
wsInitAgentMode();
|
||||
if (typeof prefetchProjectsForChat === 'function') prefetchProjectsForChat();
|
||||
wsUpdateProjectButtonLabel();
|
||||
var aiMemoInput = document.getElementById('webshell-ai-memo-input');
|
||||
var aiMemoStatus = document.getElementById('webshell-ai-memo-status');
|
||||
var aiMemoClearBtn = document.getElementById('webshell-ai-memo-clear');
|
||||
@@ -2225,6 +2456,8 @@ function selectWebshell(id, stateReady) {
|
||||
if (aiNewConvBtn) {
|
||||
aiNewConvBtn.addEventListener('click', function () {
|
||||
delete webshellAiConvMap[conn.id];
|
||||
delete webshellAiDraftProjectByConn[conn.id];
|
||||
wsUpdateProjectButtonLabel();
|
||||
if (aiMessages) {
|
||||
aiMessages.innerHTML = '';
|
||||
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
@@ -2767,7 +3000,15 @@ function loadWebshellAiHistory(conn, messagesContainer) {
|
||||
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-history', { method: 'GET' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (data.conversationId) webshellAiConvMap[conn.id] = data.conversationId;
|
||||
if (data.conversationId) {
|
||||
webshellAiConvMap[conn.id] = data.conversationId;
|
||||
apiFetch('/api/conversations/' + encodeURIComponent(data.conversationId), { method: 'GET' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (conv) {
|
||||
if (conv) wsSetWebshellAiProject(conn, conv.projectId || conv.project_id || '');
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
var list = Array.isArray(data.messages) ? data.messages : [];
|
||||
list.forEach(function (msg) {
|
||||
var role = (msg.role || '').toLowerCase();
|
||||
@@ -2787,9 +3028,14 @@ function loadWebshellAiHistory(conn, messagesContainer) {
|
||||
}
|
||||
}
|
||||
messagesContainer.appendChild(div);
|
||||
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
if (role === 'assistant') {
|
||||
var wsHistMerged = (typeof window.mergeMessageReasoningContentIntoProcessDetails === 'function')
|
||||
? window.mergeMessageReasoningContentIntoProcessDetails(msg.processDetails || [], msg.reasoningContent)
|
||||
: (msg.processDetails || []);
|
||||
if (wsHistMerged.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(wsHistMerged, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (list.length === 0) {
|
||||
@@ -2922,6 +3168,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
conversationId: convId,
|
||||
role: wsRole
|
||||
};
|
||||
if (!convId) {
|
||||
var wsPid = getWebshellAiProjectSelection(conn);
|
||||
if (wsPid) body.projectId = wsPid;
|
||||
}
|
||||
|
||||
// 流式输出:支持 progress 实时更新、response 打字机效果;若后端发送多段 response 则追加
|
||||
var streamingTarget = ''; // 当前要打字显示的目标全文(用于打字机效果)
|
||||
@@ -2970,6 +3220,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
|
||||
if (_et === 'conversation' && _ed.conversationId) {
|
||||
var convId = _ed.conversationId;
|
||||
var prevDraft = webshellAiDraftProjectByConn[conn.id];
|
||||
if (prevDraft) {
|
||||
webshellAiProjectByConvId[convId] = prevDraft;
|
||||
delete webshellAiDraftProjectByConn[conn.id];
|
||||
}
|
||||
webshellAiConvMap[conn.id] = convId;
|
||||
var listEl = document.getElementById('webshell-ai-conv-list');
|
||||
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
|
||||
@@ -3136,28 +3391,6 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
}
|
||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||
|
||||
// ─── Tool result delta (streaming output) ───
|
||||
} else if (_et === 'tool_result_delta' && _ed.toolCallId) {
|
||||
var trdKey = _ed.toolCallId;
|
||||
var trdDelta = _em || '';
|
||||
if (trdDelta) {
|
||||
var trdState = wsToolResultStreams.get(trdKey);
|
||||
if (!trdState) {
|
||||
var callEl = wsToolCallItems.get(trdKey);
|
||||
trdState = { el: callEl || null, buf: '', onCall: !!callEl };
|
||||
wsToolResultStreams.set(trdKey, trdState);
|
||||
}
|
||||
trdState.buf += trdDelta;
|
||||
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 = '…';
|
||||
|
||||
// ─── Tool result (final) ───
|
||||
} else if (_et === 'tool_result' && _ed) {
|
||||
var success = _ed.success !== false;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
let wechatBindSessionKey = null;
|
||||
let wechatBindPollTimer = null;
|
||||
let wechatBindFlashTimer = null;
|
||||
|
||||
function wechatT(key, fallback) {
|
||||
return typeof t === 'function' ? t(key) : fallback;
|
||||
@@ -88,13 +89,50 @@ function stopWechatBindPoll() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 已绑定:仅展示成功状态,不显示二维码/配对码 */
|
||||
function clearWechatBindSuccessNotice() {
|
||||
if (wechatBindFlashTimer) {
|
||||
clearTimeout(wechatBindFlashTimer);
|
||||
wechatBindFlashTimer = null;
|
||||
}
|
||||
const flash = document.getElementById('robot-wechat-bound-flash');
|
||||
if (flash) {
|
||||
flash.classList.remove('is-visible');
|
||||
flash.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定成功后的内联提示(约 4.5 秒后自动淡出) */
|
||||
function showWechatBindSuccessNotice(message) {
|
||||
const text = message || wechatT('settings.robots.wechat.boundSuccess', '绑定成功,微信机器人已启用。');
|
||||
const flash = document.getElementById('robot-wechat-bound-flash');
|
||||
const flashText = document.getElementById('robot-wechat-bound-flash-text');
|
||||
|
||||
if (flash) {
|
||||
if (flashText) flashText.textContent = text;
|
||||
flash.hidden = false;
|
||||
requestAnimationFrame(() => flash.classList.add('is-visible'));
|
||||
if (wechatBindFlashTimer) clearTimeout(wechatBindFlashTimer);
|
||||
wechatBindFlashTimer = setTimeout(() => {
|
||||
flash.classList.remove('is-visible');
|
||||
wechatBindFlashTimer = setTimeout(() => {
|
||||
flash.hidden = true;
|
||||
wechatBindFlashTimer = null;
|
||||
}, 300);
|
||||
}, 4500);
|
||||
}
|
||||
|
||||
if (typeof window.showChatToast === 'function') {
|
||||
window.showChatToast(text, 'success');
|
||||
}
|
||||
}
|
||||
|
||||
/** 已绑定:收起二维码区,仅展示紧凑摘要 */
|
||||
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 summary = document.getElementById('robot-wechat-bound-summary');
|
||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||
|
||||
stopWechatBindPoll();
|
||||
@@ -102,8 +140,8 @@ function showWechatBoundUI(wechat) {
|
||||
setWechatBadge('bound');
|
||||
setWechatCardBound(true);
|
||||
|
||||
if (wrap) wrap.hidden = false;
|
||||
if (boundPanel) boundPanel.hidden = false;
|
||||
if (wrap) wrap.hidden = true;
|
||||
if (boundPanel) boundPanel.hidden = true;
|
||||
if (scanPanel) scanPanel.hidden = true;
|
||||
|
||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||
@@ -117,14 +155,15 @@ function showWechatBoundUI(wechat) {
|
||||
}
|
||||
if (ph) ph.hidden = false;
|
||||
|
||||
if (boundId) {
|
||||
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
|
||||
const id = wc.ilink_bot_id || document.getElementById('robot-wechat-ilink-bot-id')?.value?.trim() || '';
|
||||
if (summary) {
|
||||
if (id) {
|
||||
boundId.textContent = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID:') + id;
|
||||
boundId.hidden = false;
|
||||
const prefix = wechatT('settings.robots.wechat.boundBotId', '已绑定 Bot ID:');
|
||||
summary.innerHTML = `${prefix}<code>${escapeHtml(id)}</code>`;
|
||||
summary.hidden = false;
|
||||
} else {
|
||||
boundId.textContent = '';
|
||||
boundId.hidden = true;
|
||||
summary.textContent = '';
|
||||
summary.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,21 +172,32 @@ function showWechatBoundUI(wechat) {
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 扫码绑定进行中 */
|
||||
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 summary = document.getElementById('robot-wechat-bound-summary');
|
||||
const btn = document.getElementById('robot-wechat-bind-btn');
|
||||
|
||||
setWechatBadge('scanning');
|
||||
setWechatCardBound(false);
|
||||
clearWechatBindSuccessNotice();
|
||||
ensureWechatSteps();
|
||||
updateWechatSteps('generate');
|
||||
|
||||
if (wrap) wrap.hidden = false;
|
||||
if (boundPanel) boundPanel.hidden = true;
|
||||
if (scanPanel) scanPanel.hidden = false;
|
||||
if (summary) summary.hidden = true;
|
||||
|
||||
const verifyWrap = document.getElementById('robot-wechat-verify-wrap');
|
||||
if (verifyWrap) verifyWrap.hidden = true;
|
||||
@@ -163,7 +213,10 @@ function showWechatScanUI() {
|
||||
/** 未绑定且未在扫码:隐藏面板 */
|
||||
function hideWechatQrWrap() {
|
||||
const wrap = document.getElementById('robot-wechat-qr-wrap');
|
||||
const summary = document.getElementById('robot-wechat-bound-summary');
|
||||
if (wrap) wrap.hidden = true;
|
||||
if (summary) summary.hidden = true;
|
||||
clearWechatBindSuccessNotice();
|
||||
setWechatBadge('idle');
|
||||
setWechatCardBound(false);
|
||||
}
|
||||
@@ -278,6 +331,9 @@ async function pollWechatBindStatus() {
|
||||
const idEl = document.getElementById('robot-wechat-ilink-bot-id');
|
||||
if (idEl) idEl.value = data.ilink_bot_id;
|
||||
}
|
||||
showWechatBindSuccessNotice(
|
||||
data.message || wechatT('settings.robots.wechat.boundSuccess', '绑定成功,微信机器人已启用。')
|
||||
);
|
||||
if (typeof loadConfig === 'function') {
|
||||
await loadConfig(false);
|
||||
} else {
|
||||
@@ -299,6 +355,9 @@ async function pollWechatBindStatus() {
|
||||
break;
|
||||
case 'binded_redirect':
|
||||
stopWechatBindPoll();
|
||||
showWechatBindSuccessNotice(
|
||||
data.message || wechatT('settings.robots.wechat.alreadyBound', '该微信已绑定过,无需重复绑定。')
|
||||
);
|
||||
showWechatBoundUI({ bound: true });
|
||||
return;
|
||||
case 'expired':
|
||||
|
||||
Vendored
+32
File diff suppressed because one or more lines are too long
Vendored
+6696
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user