Compare commits

..

23 Commits

Author SHA1 Message Date
公明 52d03dc849 Update config.yaml 2026-05-26 14:45:38 +08:00
公明 9de72d9ad5 Update config.yaml 2026-05-26 14:42:20 +08:00
公明 d95275ffae Add files via upload 2026-05-26 14:37:24 +08:00
公明 6cef93dbb7 Add files via upload 2026-05-26 14:36:40 +08:00
公明 dd3b1ae219 Add files via upload 2026-05-26 14:34:21 +08:00
公明 f42209682a Add files via upload 2026-05-26 14:31:59 +08:00
公明 1b1aed1699 Add files via upload 2026-05-26 14:27:44 +08:00
公明 44ced98863 Add files via upload 2026-05-26 14:24:32 +08:00
公明 97834c162e Update config.yaml 2026-05-23 19:53:40 +08:00
公明 9276f2f144 Add files via upload 2026-05-23 19:49:50 +08:00
公明 a454cada6a Add files via upload 2026-05-23 19:39:03 +08:00
公明 99b53d4fbc Add files via upload 2026-05-23 19:35:30 +08:00
公明 a43a9deaea Add files via upload 2026-05-23 19:33:23 +08:00
公明 ce88da84c9 Add files via upload 2026-05-23 19:31:40 +08:00
公明 15855c7073 Add files via upload 2026-05-23 19:29:49 +08:00
公明 43eb3e546b Add files via upload 2026-05-22 17:23:01 +08:00
公明 2d52c9b6ac Update config.yaml 2026-05-22 17:18:48 +08:00
公明 d5401b8b4c Update config.yaml 2026-05-22 17:17:48 +08:00
公明 5fd4393a2e Add files via upload 2026-05-22 17:14:33 +08:00
公明 a049f6b5c2 Add files via upload 2026-05-22 17:13:55 +08:00
公明 acba8e5a39 Add files via upload 2026-05-22 17:11:34 +08:00
公明 f826b91362 Add files via upload 2026-05-22 17:09:54 +08:00
公明 98c2de2a60 Add files via upload 2026-05-22 17:08:05 +08:00
59 changed files with 5548 additions and 339 deletions
+1
View File
@@ -113,6 +113,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`, …)
- 🛡️ 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
+1
View File
@@ -112,6 +112,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 🔒 Web 登录保护、审计日志、SQLite 持久化
- 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项)
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
- 📂 **项目管理**:按项目归类对话与漏洞;**共享事实**(项目黑板)在多会话间沉淀目标/环境/认证等认知,自动注入 Agent 上下文,支持 MCP 工具读写(`upsert_project_fact``get_project_fact` 等)
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
+5 -1
View File
@@ -127,7 +127,11 @@ description: 多代理模式下的 Deep 编排者:在已授权安全场景中
## 工具与 MCP
- **工具调用失败时**:1) 仔细分析错误信息,理解失败的具体原因;2) 如果工具不存在或未启用,尝试使用其他替代工具完成相同目标;3) 如果参数错误,根据错误提示修正参数后重试;4) 如果工具执行失败但输出了有用信息,可以基于这些信息继续分析;5) 如果确实无法使用某个工具,向用户说明问题,并建议替代方案或手动操作;6) 不要因为单个工具失败就停止整个测试流程,尝试其他方法继续完成任务。工具返回的错误信息会包含在工具响应中,请仔细阅读并做出合理决策。
- **漏洞记录**:发现**有效漏洞**时,必须使用 **`record_vulnerability`** 记录(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度使用 critical / high / medium / low / info。记录后可在授权范围内继续测试。
- **项目黑板(事实)与漏洞记录(分离)**:当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 `fact_key` + 摘要)。**摘要不足时必须调用 `get_project_fact(fact_key)` 获取 body,禁止凭摘要臆造细节。**
- **环境/目标/认证等认知**(非正式漏洞):使用 **`upsert_project_fact`**`fact_key` 建议 `category/slug`(如 `target/primary_domain`),同 key 覆盖更新。
- **可交付漏洞**:使用 **`record_vulnerability`**(标题、描述、严重程度、类型、目标、证明 POC、影响、修复建议)。严重程度 critical / high / medium / low / info。
- 同一发现可能需**各记一次**(事实记上下文,漏洞记正式 findings)。误报用 **`deprecate_project_fact`** 或漏洞状态 false_positive。
- 事实多时用 **`list_project_facts`** / **`search_project_facts`** 检索。
- **编排进度(待办)**:当你的任务包含 3 个或以上步骤,或你准备委派多个子目标并行/串行推进时,优先使用 `write_todos` 来向用户展示“当前在做什么/接下来做什么”。维护约束:同一时刻最多一个条目处于 `in_progress`;完成后立刻标记 `completed`;遇到阻塞就保留为 `in_progress` 并继续推进。
- **强触发建议(提升多 agent 使用率)**:如果你将要进行任何“证据收集/枚举/扫描/验证/复现/整理报告”这类实质执行动作,且不只是单步查询,请优先在第一个工具调用前就用 `write_todos` 建立计划;随后用 `task` 委派至少一个子代理获取结构化证据,而不是自己把全部步骤做完。
- **技能库(Skills)与知识库**:技能包位于服务器 `skills/` 目录(各子目录 `SKILL.md`,遵循 agentskills.io);知识库用于向量检索片段,Skills 为可执行工作流指令。多代理本会话通过内置 **`skill`** 工具渐进加载;子代理同样挂载 skill + 可选本机文件工具时,可在委派说明中提示按需加载。若当前无 skill 工具,需要完整 Skill 工作流时请使用多代理模式或切换为 Eino 编排会话。
+16 -3
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.20"
version: "v1.6.23"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -61,7 +61,7 @@ openai:
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinkingextended thinking),mode: off 关闭
reasoning:
mode: on # auto | on | offoff 时不附加任何推理扩展字段
effort: high # low | medium | high | max;空表示不指定openai_compat 下 auto 且无强度时不发请求扩展)
effort: high # low | medium | high | max | xhigh(最高档:OpenAI 常用 xhigh,部分网关用 max,原样下发);空表示不指定
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
# extra_request_fields: {} # 可选:管理员自定义根级 JSON 片段(高级)
@@ -116,7 +116,7 @@ multi_agent:
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
tool_search_min_tools: 20 # 达到该数量才启用 tool_search(避免工具很少时多此一举);与 always_visible 配合使用
tool_search_always_visible: 12 # 始终直接暴露给模型的工具个数(顺序与角色工具列表一致);其余工具进入动态池,需 tool_search 解锁
tool_search_always_visible_tools: [read_file, glob, grep, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test] # 后端内置常驻工具白名单(优先于 always_visible 数量策略)
tool_search_always_visible_tools: [read_file, glob, grep, write_file, edit_file, execute, task, transfer_to_agent, exit, write_todos, skill, tool_search, TaskCreate, TaskGet, TaskUpdate, TaskList, record_vulnerability, list_vulnerabilities, get_vulnerability, list_knowledge_risk_types, search_knowledge_base, webshell_exec, webshell_file_list, webshell_file_read, webshell_file_write, manage_webshell_list, manage_webshell_add, manage_webshell_update, manage_webshell_delete, manage_webshell_test, batch_task_list, batch_task_get, batch_task_start, batch_task_rerun, batch_task_pause, batch_task_update_metadata, batch_task_update_schedule, batch_task_schedule_enabled, batch_task_update_task, batch_task_remove_task, batch_task_delete, batch_task_create, batch_task_add_task, http-framework-test] # 后端内置常驻工具白名单(优先于 always_visible 数量策略)
plantask_enable: false # true:主代理(Deep / Supervisor 主)挂载 TaskCreate/Get/Update/List;需 eino_skills 可用且 skills_dir 存在,否则仅打日志并跳过
plantask_rel_dir: .eino/plantask # 结构化任务文件相对 skills_dir 的子目录,其下再按会话 ID 分子目录存放
reduction_enable: true # true:大工具输出截断/落盘以控上下文;依赖与 plantask 相同的 eino local 写盘后端,无后端时不挂载
@@ -133,6 +133,8 @@ multi_agent:
plan_execute_max_step_result_runes: 4000 # plan_execute 每步结果最大字符数(超出截断)
plan_execute_keep_last_steps: 8 # plan_execute 仅保留最近 N 步正文,早期步骤折叠为标题
checkpoint_dir: "" # 非空:为 adk.NewRunner 启用按会话子目录的文件型 CheckPointStore,便于中断恢复持久化;Resume 的 HTTP/前端流程需另行对接
run_retry_max_attempts: 0 # >0429/5xx/网络抖动时 ADK 运行循环指数退避续跑次数;0=默认 10
run_retry_max_backoff_sec: 0 # 单次退避上限秒数;0=默认 30
deep_output_key: "" # 非空:将最终助手输出写入 adk session 的键名(Deep 与 Supervisor 主代理);空表示不写入
deep_model_retry_max_retries: 0 # >0ChatModel 调用失败时的框架级最大重试次数(Deep 与 Supervisor 主);0:不重试
task_tool_description_prefix: "" # 非空:仅 Deep 的 task 工具使用自定义描述前缀,运行时会拼接子代理名称;空则走 Eino 默认生成逻辑
@@ -290,3 +292,14 @@ agents_dir: agents
# 系统会从该目录加载所有 .yaml 格式的角色配置文件
# 每个角色应创建独立的配置文件,例如:roles/CTF.yaml, roles/默认.yaml 等
roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录)
# ============================================
# 项目管理与事实黑板
# ============================================
project:
enabled: true
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
fact_index_max_runes: 3500
fact_summary_max_runes: 120
default_inject_deprecated: false
+1 -1
View File
@@ -27,6 +27,7 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/pkoukk/tiktoken-go v0.1.8
github.com/robfig/cron/v3 v3.0.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
go.opentelemetry.io/otel v1.34.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0
@@ -75,7 +76,6 @@ require (
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
+5 -3
View File
@@ -17,6 +17,7 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/project"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/security"
"cyberstrike-ai/internal/storage"
@@ -365,12 +366,12 @@ type ProgressCallback func(eventType, message string, data interface{})
// AgentLoop 执行Agent循环
func (a *Agent) AgentLoop(ctx context.Context, userInput string, historyMessages []ChatMessage) (*AgentLoopResult, error) {
return a.AgentLoopWithProgress(ctx, userInput, historyMessages, "", nil, nil)
return a.AgentLoopWithProgress(ctx, userInput, historyMessages, "", nil, nil, "")
}
// AgentLoopWithConversationID 执行Agent循环(带对话ID
func (a *Agent) AgentLoopWithConversationID(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string) (*AgentLoopResult, error) {
return a.AgentLoopWithProgress(ctx, userInput, historyMessages, conversationID, nil, nil)
return a.AgentLoopWithProgress(ctx, userInput, historyMessages, conversationID, nil, nil, "")
}
// EinoSingleAgentSystemInstruction 供 Eino adk.ChatModelAgent.Instruction 使用,与 AgentLoopWithProgress 首条 system 对齐(含 system_prompt_path)。
@@ -396,7 +397,7 @@ func (a *Agent) EinoSingleAgentSystemInstruction() string {
}
// AgentLoopWithProgress 执行Agent循环(带进度回调和对话ID)
func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string, callback ProgressCallback, roleTools []string) (*AgentLoopResult, error) {
func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, historyMessages []ChatMessage, conversationID string, callback ProgressCallback, roleTools []string, systemPromptExtra string) (*AgentLoopResult, error) {
ctx = withAgentConversationID(ctx, conversationID)
// 设置当前对话ID(兼容未走 context 的旧路径;并发会话应以 context 为准)
a.mu.Lock()
@@ -426,6 +427,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
}
}
}
systemPrompt = project.AppendSystemPromptBlock(systemPrompt, systemPromptExtra)
messages := []ChatMessage{
{
@@ -105,11 +105,15 @@ func DefaultSingleAgentSystemPrompt() string {
- 若最近一步得到 404/空结果/无效响应,不得直接结束;至少再进行一次“同目标不同策略”的验证(如变更路径、参数、请求方法、上下文来源)。
- 避免无效空转:同一工具+同类参数连续失败 3 次后,必须切换策略(改工具、改入口、改假设)并说明切换原因。
## 漏洞记录
## 项目黑板(事实)与漏洞记录(分离)
发现有效漏洞时,必须使` + builtin.ToolRecordVulnerability + ` 记录:标题、描述、严重程度、类型、目标、证明(POC)、影响、修复建议。
当前对话若已绑定项目,系统会自动注入「项目黑板索引」(仅 fact_key + 摘要)。**摘要不足时必须` + builtin.ToolGetProjectFact + `(fact_key) 获取 body,禁止凭摘要臆造细节。**
严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。记录后可在授权范围内继续测试
- **环境/目标/认证等认知**(非正式漏洞条目):使用 ` + builtin.ToolUpsertProjectFact + `fact_key 建议 ` + "`category/slug`" + `(如 target/primary_domain),同 key 覆盖更新
- **可交付漏洞**:使用 ` + builtin.ToolRecordVulnerability + `,含标题、严重程度、类型、目标、证明(POC)、影响、修复建议。记前可先 ` + builtin.ToolListVulnerabilities + ` 查重,详情用 ` + builtin.ToolGetVulnerability + `(id)(默认仅当前项目/会话)。
- 同一发现可能需**各记一次**(事实记上下文,漏洞记正式 findings)。误报用 ` + builtin.ToolDeprecateProjectFact + ` 或漏洞状态 false_positive。
严重程度:critical / high / medium / low / info。证明须含足够证据(请求响应、截图、命令输出等)。
## 技能库(Skills)与知识库
+20 -191
View File
@@ -111,7 +111,8 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
executor.RegisterTools(mcpServer)
// 注册漏洞记录工具
registerVulnerabilityTool(mcpServer, db, log.Logger)
registerVulnerabilityTools(mcpServer, db, log.Logger)
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
if cfg.Auth.GeneratedPassword != "" {
config.PrintGeneratedPasswordWarning(cfg.Auth.GeneratedPassword, cfg.Auth.GeneratedPasswordPersisted, cfg.Auth.GeneratedPasswordPersistErr)
@@ -346,6 +347,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
authHandler.SetAudit(auditSvc)
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
projectHandler := handler.NewProjectHandler(db, log.Logger)
vulnerabilityHandler.SetAudit(auditSvc)
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
webshellHandler.SetAudit(auditSvc)
@@ -414,7 +416,8 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
// 设置漏洞工具注册器(内置工具,必须设置)
vulnerabilityRegistrar := func() error {
registerVulnerabilityTool(mcpServer, db, log.Logger)
registerVulnerabilityTools(mcpServer, db, log.Logger)
registerProjectFactTools(mcpServer, db, cfg, log.Logger)
return nil
}
configHandler.SetVulnerabilityToolRegistrar(vulnerabilityRegistrar)
@@ -502,6 +505,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
attackChainHandler,
app, // 传递 App 实例以便动态获取 knowledgeHandler
vulnerabilityHandler,
projectHandler,
webshellHandler,
chatUploadsHandler,
roleHandler,
@@ -747,6 +751,7 @@ func setupRoutes(
attackChainHandler *handler.AttackChainHandler,
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
vulnerabilityHandler *handler.VulnerabilityHandler,
projectHandler *handler.ProjectHandler,
webshellHandler *handler.WebShellHandler,
chatUploadsHandler *handler.ChatUploadsHandler,
roleHandler *handler.RoleHandler,
@@ -851,6 +856,7 @@ func setupRoutes(
protected.GET("/conversations/:id", conversationHandler.GetConversation)
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
protected.PUT("/conversations/:id/project", conversationHandler.SetConversationProject)
protected.DELETE("/conversations/:id", conversationHandler.DeleteConversation)
protected.POST("/conversations/:id/delete-turn", conversationHandler.DeleteConversationTurn)
protected.PUT("/conversations/:id/pinned", groupHandler.UpdateConversationPinned)
@@ -1067,6 +1073,18 @@ func setupRoutes(
protected.PUT("/vulnerabilities/:id", vulnerabilityHandler.UpdateVulnerability)
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
// 项目管理与事实黑板
protected.GET("/projects", projectHandler.ListProjects)
protected.POST("/projects", projectHandler.CreateProject)
protected.GET("/projects/:id", projectHandler.GetProject)
protected.PUT("/projects/:id", projectHandler.UpdateProject)
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
protected.POST("/projects/:id/facts", projectHandler.CreateFact)
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
protected.DELETE("/projects/:id/facts/:factId", projectHandler.DeleteFact)
protected.POST("/projects/:id/facts/deprecate", projectHandler.DeprecateFact)
// WebShell 管理(代理执行 + 连接配置存 SQLite)
protected.GET("/webshell/connections", webshellHandler.ListConnections)
protected.POST("/webshell/connections", webshellHandler.CreateConnection)
@@ -1187,195 +1205,6 @@ func setupRoutes(
})
}
// registerVulnerabilityTool 注册漏洞记录工具到MCP服务器
func registerVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
tool := mcp.Tool{
Name: builtin.ToolRecordVulnerability,
Description: "记录发现的漏洞详情到漏洞管理系统。当发现有效漏洞时,使用此工具记录漏洞信息,包括标题、描述、严重程度、类型、目标、证明、影响和建议等。",
ShortDescription: "记录发现的漏洞详情到漏洞管理系统",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"title": map[string]interface{}{
"type": "string",
"description": "漏洞标题(必需)",
},
"description": map[string]interface{}{
"type": "string",
"description": "漏洞详细描述",
},
"severity": map[string]interface{}{
"type": "string",
"description": "漏洞严重程度:critical(严重)、high(高)、medium(中)、low(低)、info(信息)",
"enum": []string{"critical", "high", "medium", "low", "info"},
},
"vulnerability_type": map[string]interface{}{
"type": "string",
"description": "漏洞类型,如:SQL注入、XSS、CSRF、命令注入等",
},
"target": map[string]interface{}{
"type": "string",
"description": "受影响的目标(URL、IP地址、服务等)",
},
"proof": map[string]interface{}{
"type": "string",
"description": "漏洞证明(POC、截图、请求/响应等)",
},
"impact": map[string]interface{}{
"type": "string",
"description": "漏洞影响说明",
},
"recommendation": map[string]interface{}{
"type": "string",
"description": "修复建议",
},
},
"required": []string{"title", "severity"},
},
}
handler := func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
// 从参数中获取conversation_id(由Agent自动添加)
conversationID, _ := args["conversation_id"].(string)
if conversationID == "" {
return &mcp.ToolResult{
Content: []mcp.Content{
{
Type: "text",
Text: "错误: conversation_id 未设置。这是系统错误,请重试。",
},
},
IsError: true,
}, nil
}
title, ok := args["title"].(string)
if !ok || title == "" {
return &mcp.ToolResult{
Content: []mcp.Content{
{
Type: "text",
Text: "错误: title 参数必需且不能为空",
},
},
IsError: true,
}, nil
}
severity, ok := args["severity"].(string)
if !ok || severity == "" {
return &mcp.ToolResult{
Content: []mcp.Content{
{
Type: "text",
Text: "错误: severity 参数必需且不能为空",
},
},
IsError: true,
}, nil
}
// 验证严重程度
validSeverities := map[string]bool{
"critical": true,
"high": true,
"medium": true,
"low": true,
"info": true,
}
if !validSeverities[severity] {
return &mcp.ToolResult{
Content: []mcp.Content{
{
Type: "text",
Text: fmt.Sprintf("错误: severity 必须是 critical、high、medium、low 或 info 之一,当前值: %s", severity),
},
},
IsError: true,
}, nil
}
// 获取可选参数
description := ""
if d, ok := args["description"].(string); ok {
description = d
}
vulnType := ""
if t, ok := args["vulnerability_type"].(string); ok {
vulnType = t
}
target := ""
if t, ok := args["target"].(string); ok {
target = t
}
proof := ""
if p, ok := args["proof"].(string); ok {
proof = p
}
impact := ""
if i, ok := args["impact"].(string); ok {
impact = i
}
recommendation := ""
if r, ok := args["recommendation"].(string); ok {
recommendation = r
}
// 创建漏洞记录
vuln := &database.Vulnerability{
ConversationID: conversationID,
Title: title,
Description: description,
Severity: severity,
Status: "open",
Type: vulnType,
Target: target,
Proof: proof,
Impact: impact,
Recommendation: recommendation,
}
created, err := db.CreateVulnerability(vuln)
if err != nil {
logger.Error("记录漏洞失败", zap.Error(err))
return &mcp.ToolResult{
Content: []mcp.Content{
{
Type: "text",
Text: fmt.Sprintf("记录漏洞失败: %v", err),
},
},
IsError: true,
}, nil
}
logger.Info("漏洞记录成功",
zap.String("id", created.ID),
zap.String("title", created.Title),
zap.String("severity", created.Severity),
zap.String("conversation_id", conversationID),
)
return &mcp.ToolResult{
Content: []mcp.Content{
{
Type: "text",
Text: fmt.Sprintf("漏洞已成功记录!\n\n漏洞ID: %s\n标题: %s\n严重程度: %s\n状态: %s\n\n你可以在漏洞管理页面查看和管理此漏洞。", created.ID, created.Title, created.Severity, created.Status),
},
},
IsError: false,
}, nil
}
mcpServer.RegisterTool(tool, handler)
logger.Info("漏洞记录工具注册成功")
}
// registerWebshellTools 注册 WebShell 相关 MCP 工具,供 AI 助手在指定连接上执行命令与文件操作
func registerWebshellTools(mcpServer *mcp.Server, db *database.DB, webshellHandler *handler.WebShellHandler, logger *zap.Logger) {
if db == nil || webshellHandler == nil {
+278
View File
@@ -0,0 +1,278 @@
package app
import (
"context"
"fmt"
"strings"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"go.uber.org/zap"
)
func projectIDFromConversation(db *database.DB, ctx context.Context) (string, error) {
convID := agent.ConversationIDFromContext(ctx)
if convID == "" {
return "", fmt.Errorf("无法确定当前对话,请在对话上下文中使用项目事实工具")
}
pid, err := db.GetConversationProjectID(convID)
if err != nil {
return "", err
}
if strings.TrimSpace(pid) == "" {
return "", fmt.Errorf("当前对话未绑定项目,请先在对话中选择项目或创建带项目的对话")
}
return pid, nil
}
func textResult(msg string, isErr bool) *mcp.ToolResult {
return &mcp.ToolResult{
Content: []mcp.Content{{Type: "text", Text: msg}},
IsError: isErr,
}
}
// registerProjectFactTools 注册项目黑板 MCP 工具。
func registerProjectFactTools(mcpServer *mcp.Server, db *database.DB, cfg *config.Config, logger *zap.Logger) {
if db == nil || cfg == nil || !cfg.Project.Enabled {
if logger != nil {
logger.Info("项目黑板工具未注册(未启用)")
}
return
}
upsertTool := mcp.Tool{
Name: builtin.ToolUpsertProjectFact,
Description: "写入或更新项目黑板事实。用于记录环境认知、目标信息、认证特征等(非正式漏洞条目)。同 fact_key 会覆盖更新。需要当前对话已绑定项目。",
ShortDescription: "写入/更新项目事实",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"fact_key": map[string]interface{}{
"type": "string",
"description": "项目内唯一 key,建议格式 category/slug,如 target/primary_domain",
},
"category": map[string]interface{}{
"type": "string",
"description": "分类:target、auth、infra、business、note 等",
},
"summary": map[string]interface{}{
"type": "string",
"description": "单行摘要(会注入到后续对话索引)",
},
"body": map[string]interface{}{
"type": "string",
"description": "完整详情(POC、长文本等,仅 get_project_fact 返回)",
},
"confidence": map[string]interface{}{
"type": "string",
"description": "confirmed | tentative | deprecated",
"enum": []string{"confirmed", "tentative", "deprecated"},
},
"pinned": map[string]interface{}{
"type": "boolean",
"description": "是否优先出现在黑板索引",
},
"related_vulnerability_id": map[string]interface{}{
"type": "string",
"description": "可选:关联的漏洞记录 ID",
},
},
"required": []string{"fact_key", "summary"},
},
}
mcpServer.RegisterTool(upsertTool, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
projectID, err := projectIDFromConversation(db, ctx)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
factKey, _ := args["fact_key"].(string)
summary, _ := args["summary"].(string)
if strings.TrimSpace(factKey) == "" || strings.TrimSpace(summary) == "" {
return textResult("错误: fact_key 与 summary 必填", true), nil
}
if len([]rune(summary)) > cfg.Project.FactSummaryMaxRunesEffective() {
return textResult(fmt.Sprintf("错误: summary 过长(最多 %d 字)", cfg.Project.FactSummaryMaxRunesEffective()), true), nil
}
f := &database.ProjectFact{
ProjectID: projectID,
FactKey: factKey,
Category: strArg(args, "category"),
Summary: summary,
Body: strArg(args, "body"),
Confidence: strArg(args, "confidence"),
Pinned: boolArg(args, "pinned"),
RelatedVulnerabilityID: strArg(args, "related_vulnerability_id"),
}
if convID := agent.ConversationIDFromContext(ctx); convID != "" {
f.SourceConversationID = convID
}
created, err := db.UpsertProjectFact(f)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
return textResult(fmt.Sprintf("事实已保存。\nfact_key: %s\nid: %s\nconfidence: %s", created.FactKey, created.ID, created.Confidence), false), nil
})
getTool := mcp.Tool{
Name: builtin.ToolGetProjectFact,
Description: "按 fact_key 获取项目事实完整 body 与元数据。摘要不足时必须调用本工具,禁止臆造细节。",
ShortDescription: "按 key 获取事实详情",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"fact_key": map[string]interface{}{"type": "string", "description": "事实 key"},
},
"required": []string{"fact_key"},
},
}
mcpServer.RegisterTool(getTool, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
projectID, err := projectIDFromConversation(db, ctx)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
key := strings.TrimSpace(strArg(args, "fact_key"))
if key == "" {
return textResult("错误: fact_key 必填", true), nil
}
f, err := db.GetProjectFactByKey(projectID, key)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
msg := fmt.Sprintf("fact_key: %s\ncategory: %s\nconfidence: %s\nsummary: %s\nupdated_at: %s\n\n--- body ---\n%s",
f.FactKey, f.Category, f.Confidence, f.Summary, f.UpdatedAt.Format("2006-01-02 15:04:05"), f.Body)
return textResult(msg, false), nil
})
listTool := mcp.Tool{
Name: builtin.ToolListProjectFacts,
Description: "列出当前项目的事实(分页)。",
ShortDescription: "列出项目事实",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"category": map[string]interface{}{"type": "string"},
"confidence": map[string]interface{}{"type": "string"},
"limit": map[string]interface{}{"type": "integer"},
"offset": map[string]interface{}{"type": "integer"},
},
},
}
mcpServer.RegisterTool(listTool, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
projectID, err := projectIDFromConversation(db, ctx)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
limit := intArg(args, "limit", 50)
offset := intArg(args, "offset", 0)
filter := database.ProjectFactListFilter{
Category: strArg(args, "category"),
Confidence: strArg(args, "confidence"),
}
list, err := db.ListProjectFacts(projectID, filter, limit, offset)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
var b strings.Builder
b.WriteString(fmt.Sprintf("共 %d 条(limit=%d offset=%d:\n", len(list), limit, offset))
for _, f := range list {
b.WriteString(fmt.Sprintf("- [%s] %s — %s (%s)\n", f.FactKey, f.Category, f.Summary, f.Confidence))
}
return textResult(b.String(), false), nil
})
searchTool := mcp.Tool{
Name: builtin.ToolSearchProjectFacts,
Description: "按关键词搜索项目事实(summary/body/fact_key)。",
ShortDescription: "搜索项目事实",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{"type": "string"},
"limit": map[string]interface{}{"type": "integer"},
"offset": map[string]interface{}{"type": "integer"},
},
"required": []string{"query"},
},
}
mcpServer.RegisterTool(searchTool, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
projectID, err := projectIDFromConversation(db, ctx)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
q := strings.TrimSpace(strArg(args, "query"))
if q == "" {
return textResult("错误: query 必填", true), nil
}
list, err := db.ListProjectFacts(projectID, database.ProjectFactListFilter{Search: q}, intArg(args, "limit", 30), intArg(args, "offset", 0))
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
var b strings.Builder
b.WriteString(fmt.Sprintf("搜索 \"%s\" 命中 %d 条:\n", q, len(list)))
for _, f := range list {
b.WriteString(fmt.Sprintf("- [%s] %s — %s\n", f.FactKey, f.Category, f.Summary))
}
return textResult(b.String(), false), nil
})
deprecateTool := mcp.Tool{
Name: builtin.ToolDeprecateProjectFact,
Description: "将事实标记为 deprecated,从黑板索引中排除。",
ShortDescription: "废弃项目事实",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"fact_key": map[string]interface{}{"type": "string"},
},
"required": []string{"fact_key"},
},
}
mcpServer.RegisterTool(deprecateTool, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
projectID, err := projectIDFromConversation(db, ctx)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
key := strings.TrimSpace(strArg(args, "fact_key"))
if err := db.DeprecateProjectFact(projectID, key); err != nil {
return textResult("错误: "+err.Error(), true), nil
}
return textResult("事实已标记为 deprecated: "+key, false), nil
})
if logger != nil {
logger.Info("项目黑板 MCP 工具注册成功")
}
}
func strArg(args map[string]interface{}, key string) string {
if v, ok := args[key].(string); ok {
return v
}
return ""
}
func boolArg(args map[string]interface{}, key string) bool {
if v, ok := args[key].(bool); ok {
return v
}
return false
}
func intArg(args map[string]interface{}, key string, def int) int {
switch v := args[key].(type) {
case float64:
return int(v)
case int:
return v
case int64:
return int(v)
default:
return def
}
}
+405
View File
@@ -0,0 +1,405 @@
package app
import (
"context"
"fmt"
"strings"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"go.uber.org/zap"
)
func conversationIDFromToolCtx(ctx context.Context) string {
if id := agent.ConversationIDFromContext(ctx); id != "" {
return id
}
return mcp.MCPConversationIDFromContext(ctx)
}
// canAccessVulnerability 校验当前对话是否有权查看该漏洞(默认项目隔离,未绑项目则仅本会话)。
func canAccessVulnerability(vuln *database.Vulnerability, convID, projectID string) bool {
if vuln == nil || convID == "" {
return false
}
if projectID != "" {
if strings.TrimSpace(vuln.ProjectID) == projectID {
return true
}
// 历史记录:写入时尚未绑定 project_id,但属于同一会话
if strings.TrimSpace(vuln.ProjectID) == "" && vuln.ConversationID == convID {
return true
}
return false
}
return vuln.ConversationID == convID
}
func buildVulnerabilityListFilter(db *database.DB, ctx context.Context, args map[string]interface{}) (database.VulnerabilityListFilter, string, error) {
convID := conversationIDFromToolCtx(ctx)
if convID == "" {
return database.VulnerabilityListFilter{}, "", fmt.Errorf("无法确定当前对话,请在对话上下文中使用漏洞查询工具")
}
projectID := ""
if pid, err := db.GetConversationProjectID(convID); err == nil {
projectID = strings.TrimSpace(pid)
}
scope := strings.TrimSpace(strArg(args, "scope"))
if scope == "" {
if projectID != "" {
scope = "project"
} else {
scope = "conversation"
}
}
filter := database.VulnerabilityListFilter{
Severity: strings.TrimSpace(strArg(args, "severity")),
Status: strings.TrimSpace(strArg(args, "status")),
}
if q := strings.TrimSpace(strArg(args, "q")); q != "" {
filter.Search = q
} else {
filter.Search = strings.TrimSpace(strArg(args, "search"))
}
var scopeLabel string
switch scope {
case "project":
if projectID == "" {
return filter, "", fmt.Errorf("当前对话未绑定项目,无法按项目列出漏洞;请使用 scope=conversation,或先在对话中绑定项目")
}
filter.ProjectID = projectID
scopeLabel = fmt.Sprintf("项目 %s", projectID)
case "conversation":
filter.ConversationID = convID
scopeLabel = fmt.Sprintf("会话 %s", convID)
default:
return filter, "", fmt.Errorf("scope 仅支持 project 或 conversation,当前值: %s", scope)
}
return filter, scopeLabel, nil
}
func formatVulnerabilityListItem(v *database.Vulnerability) string {
line := fmt.Sprintf("- id=%s | %s | %s | %s", v.ID, v.Severity, v.Status, v.Title)
if v.Type != "" {
line += fmt.Sprintf(" | type=%s", v.Type)
}
if v.Target != "" {
line += fmt.Sprintf(" | target=%s", truncateRunes(v.Target, 80))
}
return line
}
func formatVulnerabilityDetail(v *database.Vulnerability) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("漏洞ID: %s\n", v.ID))
b.WriteString(fmt.Sprintf("标题: %s\n", v.Title))
b.WriteString(fmt.Sprintf("严重程度: %s\n", v.Severity))
b.WriteString(fmt.Sprintf("状态: %s\n", v.Status))
if v.Type != "" {
b.WriteString(fmt.Sprintf("类型: %s\n", v.Type))
}
if v.Target != "" {
b.WriteString(fmt.Sprintf("目标: %s\n", v.Target))
}
if v.ProjectID != "" {
b.WriteString(fmt.Sprintf("项目ID: %s\n", v.ProjectID))
}
b.WriteString(fmt.Sprintf("会话ID: %s\n", v.ConversationID))
if !v.CreatedAt.IsZero() {
b.WriteString(fmt.Sprintf("创建时间: %s\n", v.CreatedAt.Format("2006-01-02 15:04:05")))
}
if v.Description != "" {
b.WriteString("\n--- 描述 ---\n")
b.WriteString(v.Description)
b.WriteString("\n")
}
if v.Proof != "" {
b.WriteString("\n--- 证明(POC ---\n")
b.WriteString(v.Proof)
b.WriteString("\n")
}
if v.Impact != "" {
b.WriteString("\n--- 影响 ---\n")
b.WriteString(v.Impact)
b.WriteString("\n")
}
if v.Recommendation != "" {
b.WriteString("\n--- 修复建议 ---\n")
b.WriteString(v.Recommendation)
b.WriteString("\n")
}
return b.String()
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}
// registerVulnerabilityTools 注册漏洞记录与查询 MCP 工具。
func registerVulnerabilityTools(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
registerRecordVulnerabilityTool(mcpServer, db, logger)
registerListVulnerabilitiesTool(mcpServer, db, logger)
registerGetVulnerabilityTool(mcpServer, db, logger)
if logger != nil {
logger.Info("漏洞 MCP 工具注册成功", zap.Strings("tools", []string{
builtin.ToolRecordVulnerability,
builtin.ToolListVulnerabilities,
builtin.ToolGetVulnerability,
}))
}
}
func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
tool := mcp.Tool{
Name: builtin.ToolRecordVulnerability,
Description: "记录发现的漏洞详情到漏洞管理系统。当发现有效漏洞时,使用此工具记录漏洞信息,包括标题、描述、严重程度、类型、目标、证明、影响和建议等。记录前可先 list_vulnerabilities 避免重复。",
ShortDescription: "记录发现的漏洞详情到漏洞管理系统",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"title": map[string]interface{}{
"type": "string",
"description": "漏洞标题(必需)",
},
"description": map[string]interface{}{
"type": "string",
"description": "漏洞详细描述",
},
"severity": map[string]interface{}{
"type": "string",
"description": "漏洞严重程度:critical(严重)、high(高)、medium(中)、low(低)、info(信息)",
"enum": []string{"critical", "high", "medium", "low", "info"},
},
"vulnerability_type": map[string]interface{}{
"type": "string",
"description": "漏洞类型,如:SQL注入、XSS、CSRF、命令注入等",
},
"target": map[string]interface{}{
"type": "string",
"description": "受影响的目标(URL、IP地址、服务等)",
},
"proof": map[string]interface{}{
"type": "string",
"description": "漏洞证明(POC、截图、请求/响应等)",
},
"impact": map[string]interface{}{
"type": "string",
"description": "漏洞影响说明",
},
"recommendation": map[string]interface{}{
"type": "string",
"description": "修复建议",
},
},
"required": []string{"title", "severity"},
},
}
mcpServer.RegisterTool(tool, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
conversationID := strings.TrimSpace(strArg(args, "conversation_id"))
if conversationID == "" {
conversationID = conversationIDFromToolCtx(ctx)
}
if conversationID == "" {
return textResult("错误: conversation_id 未设置。这是系统错误,请重试。", true), nil
}
title := strings.TrimSpace(strArg(args, "title"))
if title == "" {
return textResult("错误: title 参数必需且不能为空", true), nil
}
severity := strings.TrimSpace(strArg(args, "severity"))
if severity == "" {
return textResult("错误: severity 参数必需且不能为空", true), nil
}
validSeverities := map[string]bool{
"critical": true, "high": true, "medium": true, "low": true, "info": true,
}
if !validSeverities[severity] {
return textResult(fmt.Sprintf("错误: severity 必须是 critical、high、medium、low 或 info 之一,当前值: %s", severity), true), nil
}
projectID := ""
if pid, perr := db.GetConversationProjectID(conversationID); perr == nil {
projectID = strings.TrimSpace(pid)
}
vuln := &database.Vulnerability{
ConversationID: conversationID,
ProjectID: projectID,
Title: title,
Description: strArg(args, "description"),
Severity: severity,
Status: "open",
Type: strArg(args, "vulnerability_type"),
Target: strArg(args, "target"),
Proof: strArg(args, "proof"),
Impact: strArg(args, "impact"),
Recommendation: strArg(args, "recommendation"),
}
created, err := db.CreateVulnerability(vuln)
if err != nil {
if logger != nil {
logger.Error("记录漏洞失败", zap.Error(err))
}
return textResult(fmt.Sprintf("记录漏洞失败: %v", err), true), nil
}
if logger != nil {
logger.Info("漏洞记录成功",
zap.String("id", created.ID),
zap.String("title", created.Title),
zap.String("severity", created.Severity),
zap.String("conversation_id", conversationID),
)
}
return textResult(fmt.Sprintf("漏洞已成功记录!\n\n漏洞ID: %s\n标题: %s\n严重程度: %s\n状态: %s\n\n可使用 get_vulnerability(id) 查看详情,或 list_vulnerabilities 查看列表。",
created.ID, created.Title, created.Severity, created.Status), false), nil
})
}
func registerListVulnerabilitiesTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
tool := mcp.Tool{
Name: builtin.ToolListVulnerabilities,
Description: "列出当前授权范围内的漏洞(摘要)。默认:对话已绑定项目时列出该项目下全部漏洞;未绑项目时仅列出当前会话漏洞。可用 scope=conversation 仅看本会话。记录新漏洞前建议先调用以避免重复。",
ShortDescription: "列出漏洞(默认当前项目)",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"scope": map[string]interface{}{
"type": "string",
"description": "范围:project(默认,需绑定项目)| conversation(仅当前会话)",
"enum": []string{"project", "conversation"},
},
"severity": map[string]interface{}{
"type": "string",
"description": "按严重程度筛选:critical、high、medium、low、info",
"enum": []string{"critical", "high", "medium", "low", "info"},
},
"status": map[string]interface{}{
"type": "string",
"description": "按状态筛选:open、confirmed、fixed、false_positive",
"enum": []string{"open", "confirmed", "fixed", "false_positive"},
},
"q": map[string]interface{}{
"type": "string",
"description": "关键词搜索(标题、描述、类型、目标等)",
},
"limit": map[string]interface{}{
"type": "integer",
"description": "返回条数上限,默认 30,最大 100",
},
"offset": map[string]interface{}{
"type": "integer",
"description": "分页偏移,默认 0",
},
},
},
}
mcpServer.RegisterTool(tool, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
filter, scopeLabel, err := buildVulnerabilityListFilter(db, ctx, args)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
limit := intArg(args, "limit", 30)
if limit <= 0 || limit > 100 {
limit = 30
}
offset := intArg(args, "offset", 0)
if offset < 0 {
offset = 0
}
total, err := db.CountVulnerabilities(filter)
if err != nil {
if logger != nil {
logger.Warn("统计漏洞失败", zap.Error(err))
}
total = 0
}
list, err := db.ListVulnerabilities(limit, offset, filter)
if err != nil {
return textResult("错误: "+err.Error(), true), nil
}
var b strings.Builder
b.WriteString(fmt.Sprintf("范围: %s\n总计: %d | 本页: %d 条 (limit=%d offset=%d)\n\n", scopeLabel, total, len(list), limit, offset))
if len(list) == 0 {
b.WriteString("(暂无漏洞记录)\n")
} else {
for _, v := range list {
b.WriteString(formatVulnerabilityListItem(v))
b.WriteString("\n")
}
if total > offset+len(list) {
b.WriteString(fmt.Sprintf("\n(还有更多,可增大 offset 或使用 q/severity/status 筛选)\n"))
}
}
b.WriteString("\n需要 POC 与完整字段请对具体 id 调用 get_vulnerability。")
return textResult(b.String(), false), nil
})
}
func registerGetVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
tool := mcp.Tool{
Name: builtin.ToolGetVulnerability,
Description: "按漏洞 ID 获取完整详情(含 POC、影响、修复建议)。仅能访问当前项目或当前会话下的漏洞(与 list_vulnerabilities 授权范围一致)。",
ShortDescription: "按 ID 获取漏洞详情",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"id": map[string]interface{}{
"type": "string",
"description": "漏洞 IDlist_vulnerabilities 返回的 id",
},
},
"required": []string{"id"},
},
}
mcpServer.RegisterTool(tool, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
convID := conversationIDFromToolCtx(ctx)
if convID == "" {
return textResult("错误: 无法确定当前对话,请在对话上下文中使用本工具", true), nil
}
id := strings.TrimSpace(strArg(args, "id"))
if id == "" {
return textResult("错误: id 必填", true), nil
}
vuln, err := db.GetVulnerability(id)
if err != nil {
return textResult("错误: 漏洞不存在或查询失败", true), nil
}
projectID := ""
if pid, perr := db.GetConversationProjectID(convID); perr == nil {
projectID = strings.TrimSpace(pid)
}
if !canAccessVulnerability(vuln, convID, projectID) {
return textResult("错误: 无权访问该漏洞(仅可查看当前项目或当前会话下的记录)", true), nil
}
return textResult(formatVulnerabilityDetail(vuln), false), nil
})
}
+31 -1
View File
@@ -36,6 +36,32 @@ type Config struct {
SkillsDir string `yaml:"skills_dir,omitempty" json:"skills_dir,omitempty"` // Skills配置文件目录
AgentsDir string `yaml:"agents_dir,omitempty" json:"agents_dir,omitempty"` // 多代理子 Agent Markdown 定义目录(*.mdYAML front matter
MultiAgent MultiAgentConfig `yaml:"multi_agent,omitempty" json:"multi_agent,omitempty"`
Project ProjectConfig `yaml:"project,omitempty" json:"project,omitempty"`
}
// ProjectConfig 项目黑板(跨对话共享事实)配置。
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"`
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"`
}
// FactIndexMaxRunesEffective 自动注入黑板索引的最大 rune 数。
func (c ProjectConfig) FactIndexMaxRunesEffective() int {
if c.FactIndexMaxRunes <= 0 {
return 3500
}
return c.FactIndexMaxRunes
}
// FactSummaryMaxRunesEffective upsert 时 summary 最大 rune 数。
func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
if c.FactSummaryMaxRunes <= 0 {
return 120
}
return c.FactSummaryMaxRunes
}
// MultiAgentConfig 基于 CloudWeGo Eino adk/prebuilt 的多代理编排(deep | plan_execute | supervisor,与单 Agent /agent-loop 并存)。
@@ -228,6 +254,10 @@ type MultiAgentEinoMiddlewareConfig struct {
DeepOutputKey string `yaml:"deep_output_key,omitempty" json:"deep_output_key,omitempty"`
// DeepModelRetryMaxRetries > 0 enables deep.Config ModelRetryConfig (framework-level chat model retries).
DeepModelRetryMaxRetries int `yaml:"deep_model_retry_max_retries,omitempty" json:"deep_model_retry_max_retries,omitempty"`
// RunRetryMaxAttempts > 0429/5xx/网络抖动时 handler 分段续跑次数;0=默认 10。
RunRetryMaxAttempts int `yaml:"run_retry_max_attempts,omitempty" json:"run_retry_max_attempts,omitempty"`
// RunRetryMaxBackoffSec 单次退避上限秒数;0=默认 30。
RunRetryMaxBackoffSec int `yaml:"run_retry_max_backoff_sec,omitempty" json:"run_retry_max_backoff_sec,omitempty"`
// TaskToolDescriptionPrefix when non-empty sets deep.Config TaskToolDescriptionGenerator (sub-agent names appended).
TaskToolDescriptionPrefix string `yaml:"task_tool_description_prefix,omitempty" json:"task_tool_description_prefix,omitempty"`
}
@@ -510,7 +540,7 @@ type OpenAIConfig struct {
type OpenAIReasoningConfig struct {
// Mode: auto(默认)| on | off | default(与 auto 相同)。off 时不向模型附加推理扩展字段。
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
// Effort: low | medium | high | max;空表示不单独指定强度(各 profile 行为见 internal/reasoning
// Effort: low | medium | high | max | xhighmax/xhigh 为不同网关最高档命名,原样下发、不互转。空表示不单独指定强度
Effort string `yaml:"effort,omitempty" json:"effort,omitempty"`
// AllowClientReasoning 为 false 时忽略请求体 reasoningnil 或未设置等同于 true。
AllowClientReasoning *bool `yaml:"allow_client_reasoning,omitempty" json:"allow_client_reasoning,omitempty"`
+14 -8
View File
@@ -22,6 +22,7 @@ type BatchTaskQueueRow struct {
LastScheduleTriggerAt sql.NullTime
LastScheduleError sql.NullString
LastRunError sql.NullString
ProjectID sql.NullString
Status string
CreatedAt time.Time
StartedAt sql.NullTime
@@ -51,6 +52,7 @@ func (db *DB) CreateBatchQueue(
scheduleMode string,
cronExpr string,
nextRunAt *time.Time,
projectID string,
tasks []map[string]interface{},
) error {
tx, err := db.Begin()
@@ -65,9 +67,13 @@ func (db *DB) CreateBatchQueue(
nextRunAtValue = *nextRunAt
}
var projectIDVal interface{}
if strings.TrimSpace(projectID) != "" {
projectIDVal = strings.TrimSpace(projectID)
}
_, err = tx.Exec(
"INSERT INTO batch_task_queues (id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, status, created_at, current_index) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
queueID, title, role, agentMode, scheduleMode, cronExpr, nextRunAtValue, 1, "pending", now, 0,
"INSERT INTO batch_task_queues (id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, project_id, status, created_at, current_index) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
queueID, title, role, agentMode, scheduleMode, cronExpr, nextRunAtValue, 1, projectIDVal, "pending", now, 0,
)
if err != nil {
return fmt.Errorf("创建批量任务队列失败: %w", err)
@@ -101,9 +107,9 @@ func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
var row BatchTaskQueueRow
var createdAt string
err := db.QueryRow(
"SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE id = ?",
"SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, project_id, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE id = ?",
queueID,
).Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex)
).Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.ProjectID, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -127,7 +133,7 @@ func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
// GetAllBatchQueues 获取所有批量任务队列
func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
rows, err := db.Query(
"SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, status, created_at, started_at, completed_at, current_index FROM batch_task_queues ORDER BY created_at DESC",
"SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, project_id, status, created_at, started_at, completed_at, current_index FROM batch_task_queues ORDER BY created_at DESC",
)
if err != nil {
return nil, fmt.Errorf("查询批量任务队列列表失败: %w", err)
@@ -138,7 +144,7 @@ func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
for rows.Next() {
var row BatchTaskQueueRow
var createdAt string
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.ProjectID, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
}
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
@@ -158,7 +164,7 @@ func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
// ListBatchQueues 列出批量任务队列(支持筛选和分页)
func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*BatchTaskQueueRow, error) {
query := "SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE 1=1"
query := "SELECT id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, project_id, status, created_at, started_at, completed_at, current_index FROM batch_task_queues WHERE 1=1"
args := []interface{}{}
// 状态筛选
@@ -186,7 +192,7 @@ func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*Bat
for rows.Next() {
var row BatchTaskQueueRow
var createdAt string
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.ProjectID, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
}
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
+45 -12
View File
@@ -17,6 +17,7 @@ import (
type Conversation struct {
ID string `json:"id"`
Title string `json:"title"`
ProjectID string `json:"projectId,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -46,13 +47,32 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
id := uuid.New().String()
now := time.Now()
projectID := strings.TrimSpace(meta.ProjectID)
if projectID != "" {
if _, err := db.GetProject(projectID); err != nil {
return nil, err
}
}
var err error
if webshellConnectionID != "" {
wsID := strings.TrimSpace(webshellConnectionID)
switch {
case wsID != "" && projectID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id) VALUES (?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, projectID,
)
case wsID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id) VALUES (?, ?, ?, ?, ?)",
id, title, now, now, webshellConnectionID,
id, title, now, now, wsID,
)
} else {
case projectID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, project_id) VALUES (?, ?, ?, ?, ?)",
id, title, now, now, projectID,
)
default:
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
id, title, now, now,
@@ -65,11 +85,12 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
conv := &Conversation{
ID: id,
Title: title,
ProjectID: projectID,
CreatedAt: now,
UpdatedAt: now,
}
if webshellConnectionID != "" {
meta.WebShellConnectionID = webshellConnectionID
if wsID != "" {
meta.WebShellConnectionID = wsID
}
notifyConversationCreated(conv, meta)
return conv, nil
@@ -210,16 +231,20 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at FROM conversations WHERE id = ?",
"SELECT id, title, pinned, created_at, updated_at, project_id FROM conversations WHERE id = ?",
id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt)
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在")
}
return nil, fmt.Errorf("查询对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
// 尝试多种时间格式解析
var err1, err2 error
@@ -292,16 +317,20 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at FROM conversations WHERE id = ?",
"SELECT id, title, pinned, created_at, updated_at, project_id FROM conversations WHERE id = ?",
id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt)
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在")
}
return nil, fmt.Errorf("查询对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
// 尝试多种时间格式解析
var err1, err2 error
@@ -341,7 +370,7 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
searchPattern := "%" + search + "%"
rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at
`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 ?)
@@ -351,7 +380,7 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
)
} else {
rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?",
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ?",
limit, offset,
)
}
@@ -366,10 +395,14 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
var conv Conversation
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt); err != nil {
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
// 尝试多种时间格式解析
var err1, err2 error
@@ -4,6 +4,7 @@ package database
type ConversationCreateMeta struct {
Source string
WebShellConnectionID string
ProjectID string
ClientIP string
SessionHint string
}
+98
View File
@@ -213,6 +213,40 @@ func (db *DB) initTables() error {
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);`
// 创建项目表
createProjectsTable := `
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
scope_json TEXT,
status TEXT NOT NULL DEFAULT 'active',
pinned INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);`
// 创建项目事实表(黑板)
createProjectFactsTable := `
CREATE TABLE IF NOT EXISTS project_facts (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
fact_key TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'note',
summary TEXT NOT NULL DEFAULT '',
body TEXT,
confidence TEXT NOT NULL DEFAULT 'tentative',
source_conversation_id TEXT,
source_message_id TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
supersedes_fact_id TEXT,
related_vulnerability_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, fact_key)
);`
// 创建漏洞表
createVulnerabilitiesTable := `
CREATE TABLE IF NOT EXISTS vulnerabilities (
@@ -445,6 +479,12 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_severity ON vulnerabilities(severity);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_status ON vulnerabilities(status);
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_created_at ON vulnerabilities(created_at);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
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_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);
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_created_at ON batch_task_queues(created_at);
CREATE INDEX IF NOT EXISTS idx_batch_task_queues_title ON batch_task_queues(title);
@@ -516,6 +556,14 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建robot_user_sessions表失败: %w", err)
}
if _, err := db.Exec(createProjectsTable); err != nil {
return fmt.Errorf("创建projects表失败: %w", err)
}
if _, err := db.Exec(createProjectFactsTable); err != nil {
return fmt.Errorf("创建project_facts表失败: %w", err)
}
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
}
@@ -583,6 +631,10 @@ func (db *DB) initTables() error {
// 不返回错误,允许继续运行
}
if err := db.migrateProjectsTable(); err != nil {
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
}
if err := db.migrateWebshellConnectionsTable(); err != nil {
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
// 不返回错误,允许继续运行
@@ -930,6 +982,51 @@ func (db *DB) migrateBatchTaskQueuesTable() error {
}
}
var projectIDCount int
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('batch_task_queues') WHERE name='project_id'").Scan(&projectIDCount)
if err != nil {
if _, addErr := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN project_id TEXT"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加batch_task_queues.project_id字段失败", zap.Error(addErr))
}
}
} else if projectIDCount == 0 {
if _, err := db.Exec("ALTER TABLE batch_task_queues ADD COLUMN project_id TEXT"); err != nil {
db.logger.Warn("添加batch_task_queues.project_id字段失败", zap.Error(err))
}
}
return nil
}
// migrateProjectsTable 迁移 projects / conversations / vulnerabilities 的项目关联字段。
func (db *DB) migrateProjectsTable() error {
for _, col := range []struct {
table string
name string
stmt string
}{
{"conversations", "project_id", "ALTER TABLE conversations ADD COLUMN project_id TEXT REFERENCES projects(id) ON DELETE SET NULL"},
{"vulnerabilities", "project_id", "ALTER TABLE vulnerabilities ADD COLUMN project_id TEXT"},
} {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name=?", col.table, col.name).Scan(&count)
if err != nil {
if _, addErr := db.Exec(col.stmt); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加字段失败", zap.String("table", col.table), zap.String("field", col.name), zap.Error(addErr))
}
}
continue
}
if count == 0 {
if _, addErr := db.Exec(col.stmt); addErr != nil {
db.logger.Warn("添加字段失败", zap.String("table", col.table), zap.String("field", col.name), zap.Error(addErr))
}
}
}
return nil
}
@@ -941,6 +1038,7 @@ func (db *DB) migrateVulnerabilitiesTable() error {
}{
{name: "conversation_tag", stmt: "ALTER TABLE vulnerabilities ADD COLUMN conversation_tag TEXT"},
{name: "task_tag", stmt: "ALTER TABLE vulnerabilities ADD COLUMN task_tag TEXT"},
{name: "project_id", stmt: "ALTER TABLE vulnerabilities ADD COLUMN project_id TEXT"},
}
for _, col := range columns {
+451
View File
@@ -0,0 +1,451 @@
package database
import (
"database/sql"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
)
var factKeyPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*$`)
// ValidateFactKey 校验事实 key(项目内唯一标识)。
func ValidateFactKey(key string) error {
key = strings.TrimSpace(key)
if key == "" {
return fmt.Errorf("fact_key 不能为空")
}
if len(key) > 128 {
return fmt.Errorf("fact_key 过长(最多 128 字符)")
}
if !factKeyPattern.MatchString(key) {
return fmt.Errorf("fact_key 格式无效,仅允许小写字母、数字及 . _ / -,且须以小写字母或数字开头")
}
return nil
}
// Project 渗透测试项目(跨对话共享黑板)。
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
ScopeJSON string `json:"scope_json,omitempty"`
Status string `json:"status"` // active | archived
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProjectFact 项目事实(黑板条目)。
type ProjectFact struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
FactKey string `json:"fact_key"`
Category string `json:"category"`
Summary string `json:"summary"`
Body string `json:"body"`
Confidence string `json:"confidence"` // confirmed | tentative | deprecated
SourceConversationID string `json:"source_conversation_id,omitempty"`
SourceMessageID string `json:"source_message_id,omitempty"`
Pinned bool `json:"pinned"`
SupersedesFactID string `json:"supersedes_fact_id,omitempty"`
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ProjectFactListFilter 事实列表筛选。
type ProjectFactListFilter struct {
Category string
Confidence string
Search string
}
// CreateProject 创建项目。
func (db *DB) CreateProject(p *Project) (*Project, error) {
if p.ID == "" {
p.ID = uuid.New().String()
}
if strings.TrimSpace(p.Status) == "" {
p.Status = "active"
}
now := time.Now()
if p.CreatedAt.IsZero() {
p.CreatedAt = now
}
p.UpdatedAt = now
_, err := db.Exec(
`INSERT INTO projects (id, name, description, scope_json, status, pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
p.ID, p.Name, p.Description, p.ScopeJSON, p.Status, boolToInt(p.Pinned), p.CreatedAt, p.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("创建项目失败: %w", err)
}
return p, nil
}
// GetProject 获取项目。
func (db *DB) GetProject(id string) (*Project, error) {
var p Project
var pinned int
var createdAt, updatedAt string
err := db.QueryRow(
`SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
FROM projects WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.Description, &p.ScopeJSON, &p.Status, &pinned, &createdAt, &updatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("项目不存在")
}
return nil, fmt.Errorf("获取项目失败: %w", err)
}
p.Pinned = pinned != 0
p.CreatedAt = parseDBTime(createdAt)
p.UpdatedAt = parseDBTime(updatedAt)
return &p, nil
}
// ListProjects 列出项目。
func (db *DB) ListProjects(status string, limit, offset int) ([]*Project, error) {
if limit <= 0 {
limit = 200
}
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
FROM projects WHERE 1=1`
args := []interface{}{}
if s := strings.TrimSpace(status); s != "" {
query += " AND status = ?"
args = append(args, s)
}
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("列出项目失败: %w", err)
}
defer rows.Close()
var out []*Project
for rows.Next() {
var p Project
var pinned int
var createdAt, updatedAt string
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.ScopeJSON, &p.Status, &pinned, &createdAt, &updatedAt); err != nil {
return nil, err
}
p.Pinned = pinned != 0
p.CreatedAt = parseDBTime(createdAt)
p.UpdatedAt = parseDBTime(updatedAt)
out = append(out, &p)
}
return out, rows.Err()
}
// UpdateProject 更新项目。
func (db *DB) UpdateProject(p *Project) error {
p.UpdatedAt = time.Now()
_, err := db.Exec(
`UPDATE projects SET name = ?, description = ?, scope_json = ?, status = ?, pinned = ?, updated_at = ? WHERE id = ?`,
p.Name, p.Description, p.ScopeJSON, p.Status, boolToInt(p.Pinned), p.UpdatedAt, p.ID,
)
if err != nil {
return fmt.Errorf("更新项目失败: %w", err)
}
return nil
}
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理)。
func (db *DB) DeleteProject(id string) error {
_, err := db.Exec(`DELETE FROM projects WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("删除项目失败: %w", err)
}
return nil
}
// GetConversationProjectID 返回对话绑定的项目 ID。
func (db *DB) GetConversationProjectID(conversationID string) (string, error) {
var pid sql.NullString
err := db.QueryRow(`SELECT project_id FROM conversations WHERE id = ?`, conversationID).Scan(&pid)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("对话不存在")
}
return "", err
}
if pid.Valid {
return strings.TrimSpace(pid.String), nil
}
return "", nil
}
// SetConversationProjectID 设置对话所属项目(空字符串表示解除绑定)。
func (db *DB) SetConversationProjectID(conversationID, projectID string) error {
projectID = strings.TrimSpace(projectID)
if projectID != "" {
if _, err := db.GetProject(projectID); err != nil {
return err
}
}
var val interface{}
if projectID == "" {
val = nil
} else {
val = projectID
}
_, err := db.Exec(`UPDATE conversations SET project_id = ?, updated_at = ? WHERE id = ?`, val, time.Now(), conversationID)
if err != nil {
return fmt.Errorf("设置对话项目失败: %w", err)
}
return nil
}
// ListProjectFactsForIndex 列出用于黑板索引注入的事实(不含 deprecated,除非 includeDeprecated)。
func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) {
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
FROM project_facts WHERE project_id = ?`
args := []interface{}{projectID}
if !includeDeprecated {
query += " AND confidence != 'deprecated'"
}
query += " ORDER BY pinned DESC, updated_at DESC"
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProjectFacts(rows)
}
// ListProjectFacts 分页列出项目事实。
func (db *DB) ListProjectFacts(projectID string, filter ProjectFactListFilter, limit, offset int) ([]*ProjectFact, error) {
if limit <= 0 {
limit = 100
}
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
FROM project_facts WHERE project_id = ?`
args := []interface{}{projectID}
if c := strings.TrimSpace(filter.Category); c != "" {
query += " AND category = ?"
args = append(args, c)
}
if c := strings.TrimSpace(filter.Confidence); c != "" {
query += " AND confidence = ?"
args = append(args, c)
}
if s := strings.TrimSpace(filter.Search); s != "" {
pat := "%" + s + "%"
query += " AND (fact_key LIKE ? OR summary LIKE ? OR body LIKE ?)"
args = append(args, pat, pat, pat)
}
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProjectFacts(rows)
}
// GetProjectFactByKey 按 key 获取事实。
func (db *DB) GetProjectFactByKey(projectID, factKey string) (*ProjectFact, error) {
row := db.QueryRow(
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
FROM project_facts WHERE project_id = ? AND fact_key = ?`,
projectID, factKey,
)
return scanProjectFactRow(row)
}
// GetProjectFact 按 ID 获取事实。
func (db *DB) GetProjectFact(id string) (*ProjectFact, error) {
row := db.QueryRow(
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
FROM project_facts WHERE id = ?`, id,
)
return scanProjectFactRow(row)
}
// UpsertProjectFact 创建或更新事实(按 project_id + fact_key)。
func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
if err := ValidateFactKey(f.FactKey); err != nil {
return nil, err
}
if strings.TrimSpace(f.Category) == "" {
f.Category = "note"
}
if strings.TrimSpace(f.Confidence) == "" {
f.Confidence = "tentative"
}
now := time.Now()
existing, err := db.GetProjectFactByKey(f.ProjectID, f.FactKey)
if err == nil && existing != nil {
f.ID = existing.ID
f.CreatedAt = existing.CreatedAt
f.UpdatedAt = now
_, err = db.Exec(
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
source_conversation_id = ?, source_message_id = ?, pinned = ?,
supersedes_fact_id = ?, related_vulnerability_id = ?, updated_at = ?
WHERE id = ?`,
f.Category, f.Summary, f.Body, f.Confidence,
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID), f.UpdatedAt, f.ID,
)
if err != nil {
return nil, fmt.Errorf("更新事实失败: %w", err)
}
return f, nil
}
if f.ID == "" {
f.ID = uuid.New().String()
}
f.CreatedAt = now
f.UpdatedAt = now
_, err = db.Exec(
`INSERT INTO project_facts (
id, project_id, fact_key, category, summary, body, confidence,
source_conversation_id, source_message_id, pinned, supersedes_fact_id, related_vulnerability_id,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID),
f.CreatedAt, f.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("创建事实失败: %w", err)
}
return f, nil
}
// DeprecateProjectFact 将事实标记为 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 = ?`,
time.Now(), projectID, factKey,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("事实不存在")
}
return nil
}
// DeleteProjectFact 删除事实。
func (db *DB) DeleteProjectFact(id string) error {
_, err := db.Exec(`DELETE FROM project_facts WHERE id = ?`, id)
return err
}
func scanProjectFacts(rows *sql.Rows) ([]*ProjectFact, error) {
var out []*ProjectFact
for rows.Next() {
f, err := scanProjectFactFromRows(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func scanProjectFactRow(row *sql.Row) (*ProjectFact, error) {
var f ProjectFact
var pinned int
var createdAt, updatedAt string
err := row.Scan(
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
&f.SourceConversationID, &f.SourceMessageID, &pinned,
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("事实不存在")
}
return nil, err
}
f.Pinned = pinned != 0
f.CreatedAt = parseDBTime(createdAt)
f.UpdatedAt = parseDBTime(updatedAt)
return &f, nil
}
func scanProjectFactFromRows(rows *sql.Rows) (*ProjectFact, error) {
var f ProjectFact
var pinned int
var createdAt, updatedAt string
err := rows.Scan(
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
&f.SourceConversationID, &f.SourceMessageID, &pinned,
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
f.Pinned = pinned != 0
f.CreatedAt = parseDBTime(createdAt)
f.UpdatedAt = parseDBTime(updatedAt)
return &f, nil
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func nullIfEmpty(s string) interface{} {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
func parseDBTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
// go-sqlite3 读 DATETIME 常返回 RFC3339(含 T),写入时可能是空格分隔格式,需兼容多种形态
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05-07:00",
"2006-01-02T15:04:05.999999999-07:00",
"2006-01-02T15:04:05-07:00",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
}
for _, layout := range layouts {
if t, e := time.Parse(layout, s); e == nil {
return t
}
}
return time.Time{}
}
+93
View File
@@ -0,0 +1,93 @@
package database
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"go.uber.org/zap"
)
func TestParseDBTime_projectFactFormats(t *testing.T) {
cases := []string{
"2026-05-26 11:13:07.442143+08:00",
"2026-05-26 11:13:07",
"2026-05-26T11:13:07.442143+08:00",
}
for _, s := range cases {
got := parseDBTime(s)
if got.IsZero() {
t.Fatalf("parseDBTime(%q) returned zero", s)
}
}
}
func TestListProjectFacts_updatedAtJSON(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)
}
projects, err := db.ListProjects("", 1, 0)
if err != nil || len(projects) == 0 {
t.Skip("no projects")
}
pid := projects[0].ID
list, err := db.ListProjectFacts(pid, ProjectFactListFilter{}, 5, 0)
if err != nil {
t.Fatal(err)
}
if len(list) == 0 {
t.Skip("no facts")
}
for _, f := range list {
if f.UpdatedAt.IsZero() {
t.Fatalf("fact %s UpdatedAt is zero after ListProjectFacts", f.FactKey)
}
b, err := json.Marshal(f)
if err != nil {
t.Fatal(err)
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
raw, ok := m["updated_at"].(string)
if !ok || raw == "" || raw[:4] == "0001" {
t.Fatalf("bad updated_at in JSON: %v", m["updated_at"])
}
}
}
func TestParseDBTime_zeroOnGarbage(t *testing.T) {
if !parseDBTime("").IsZero() {
t.Fatal("expected zero for empty")
}
}
// Ensure RFC3339 round-trip used by API is after year 2000.
func TestParseDBTime_marshalRoundTrip(t *testing.T) {
s := "2026-05-26 11:13:07.442143+08:00"
tm := parseDBTime(s)
b, err := json.Marshal(tm)
if err != nil {
t.Fatal(err)
}
var back time.Time
if err := json.Unmarshal(b, &back); err != nil {
t.Fatal(err)
}
if back.IsZero() {
t.Fatalf("unmarshal zero from %s", string(b))
}
}
+26 -9
View File
@@ -15,6 +15,7 @@ type VulnerabilityListFilter struct {
ID string
Search string // 关键词模糊匹配(标题、描述、类型、目标等)
ConversationID string
ProjectID string
Severity string
Status string
TaskID string
@@ -38,6 +39,10 @@ func (f VulnerabilityListFilter) appendWhere(query string, args []interface{}) (
query += " AND conversation_id = ?"
args = append(args, f.ConversationID)
}
if f.ProjectID != "" {
query += " AND project_id = ?"
args = append(args, f.ProjectID)
}
if f.TaskID != "" {
query += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
args = append(args, f.TaskID, f.TaskID)
@@ -85,6 +90,7 @@ func (f VulnerabilityListFilter) appendWhere(query string, args []interface{}) (
type Vulnerability struct {
ID string `json:"id"`
ConversationID string `json:"conversation_id"`
ProjectID string `json:"project_id,omitempty"`
ConversationTag string `json:"conversation_tag,omitempty"`
TaskTag string `json:"task_tag,omitempty"`
TaskID string `json:"task_id,omitempty"`
@@ -116,17 +122,23 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
}
vuln.UpdatedAt = now
if strings.TrimSpace(vuln.ProjectID) == "" && vuln.ConversationID != "" {
if pid, err := db.GetConversationProjectID(vuln.ConversationID); err == nil {
vuln.ProjectID = pid
}
}
query := `
INSERT INTO vulnerabilities (
id, conversation_id, conversation_tag, task_tag, title, description, severity, status,
id, conversation_id, project_id, conversation_tag, task_tag, title, description, severity, status,
vulnerability_type, target, proof, impact, recommendation,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(
query,
vuln.ID, vuln.ConversationID, vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
vuln.ID, vuln.ConversationID, nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
vuln.Severity, vuln.Status, vuln.Type, vuln.Target,
vuln.Proof, vuln.Impact, vuln.Recommendation,
vuln.CreatedAt, vuln.UpdatedAt,
@@ -142,7 +154,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
var vuln Vulnerability
query := `
SELECT id, conversation_id, title, description, severity, status,
SELECT id, conversation_id, COALESCE(project_id,''), title, description, severity, status,
conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation,
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
@@ -152,7 +164,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
`
err := db.QueryRow(query, id).Scan(
&vuln.ID, &vuln.ConversationID, &vuln.Title, &vuln.Description,
&vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description,
&vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target,
&vuln.Proof, &vuln.Impact, &vuln.Recommendation,
&vuln.TaskID, &vuln.TaskQueueID,
@@ -171,7 +183,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
// ListVulnerabilities 列出漏洞
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
query := `
SELECT id, conversation_id, title, description, severity, status, conversation_tag, task_tag,
SELECT id, conversation_id, COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
vulnerability_type, target, proof, impact, recommendation,
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
@@ -195,7 +207,7 @@ func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFil
for rows.Next() {
var vuln Vulnerability
err := rows.Scan(
&vuln.ID, &vuln.ConversationID, &vuln.Title, &vuln.Description,
&vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description,
&vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target,
&vuln.Proof, &vuln.Impact, &vuln.Recommendation,
&vuln.TaskID, &vuln.TaskQueueID,
@@ -232,7 +244,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
query := `
UPDATE vulnerabilities
SET conversation_tag = ?, task_tag = ?, title = ?, description = ?, severity = ?, status = ?,
SET project_id = ?, conversation_tag = ?, task_tag = ?, title = ?, description = ?, severity = ?, status = ?,
vulnerability_type = ?, target = ?, proof = ?, impact = ?,
recommendation = ?, updated_at = ?
WHERE id = ?
@@ -240,7 +252,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
_, err := db.Exec(
query,
vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description, vuln.Severity, vuln.Status,
nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description, vuln.Severity, vuln.Status,
vuln.Type, vuln.Target, vuln.Proof, vuln.Impact,
vuln.Recommendation, vuln.UpdatedAt, id,
)
@@ -366,10 +378,15 @@ func (db *DB) GetVulnerabilityFilterOptions() (map[string][]string, error) {
if err != nil {
return nil, fmt.Errorf("查询任务标签建议失败: %w", err)
}
projectIDs, err := collect(`SELECT DISTINCT project_id FROM vulnerabilities WHERE project_id IS NOT NULL AND project_id <> '' ORDER BY created_at DESC LIMIT 200`)
if err != nil {
return nil, fmt.Errorf("查询项目ID建议失败: %w", err)
}
return map[string][]string{
"vulnerability_ids": vulnIDs,
"conversation_ids": conversationIDs,
"project_ids": projectIDs,
"task_ids": taskIDs,
"queue_ids": queueIDs,
"conversation_tags": conversationTags,
+69 -22
View File
@@ -214,7 +214,7 @@ type ChatAttachment struct {
type ChatReasoningRequest struct {
// Mode: default(跟随系统)| off | on | auto
Mode string `json:"mode,omitempty"`
// Effort: low | medium | high | max;空表示不指定(由系统默认与各 profile 决定)
// Effort: low | medium | high | max | xhigh(原样下发;不同网关最高档命名不同)。空表示不指定
Effort string `json:"effort,omitempty"`
}
@@ -222,6 +222,7 @@ type ChatReasoningRequest struct {
type ChatRequest struct {
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
@@ -560,7 +561,9 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
conversationID := req.ConversationID
if conversationID == "" {
title := safeTruncateString(req.Message, 50)
conv, err := h.db.CreateConversation(title, audit.ConversationCreateMetaFromGin(c, "agent_loop"))
meta := audit.ConversationCreateMetaFromGin(c, "agent_loop")
meta.ProjectID = effectiveProjectID(h.config, req.ProjectID)
conv, err := h.db.CreateConversation(title, meta)
if err != nil {
h.logger.Error("创建对话失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -635,6 +638,8 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
builtin.ToolWebshellFileRead,
builtin.ToolWebshellFileWrite,
builtin.ToolRecordVulnerability,
builtin.ToolListVulnerabilities,
builtin.ToolGetVulnerability,
builtin.ToolListKnowledgeRiskTypes,
builtin.ToolSearchKnowledgeBase,
}
@@ -682,7 +687,7 @@ func (h *AgentHandler) AgentLoop(c *gin.Context) {
taskCtx = h.injectReactHITLInterceptor(taskCtx, cancelWithCause, conversationID, "", nil)
// 执行Agent Loop,传入历史消息和对话ID(使用包含角色提示词的finalMessage和角色工具列表)
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, h.projectBlackboardBlock(conversationID))
if err != nil {
h.logger.Error("Agent Loop执行失败", zap.Error(err))
@@ -760,7 +765,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, con
if strings.TrimSpace(platform) != "" {
src = "robot:" + strings.TrimSpace(platform)
}
conv, createErr := h.db.CreateConversation(title, audit.ConversationCreateMeta(src))
meta := audit.ConversationCreateMeta(src)
meta.ProjectID = effectiveProjectID(h.config, "")
conv, createErr := h.db.CreateConversation(title, meta)
if createErr != nil {
return "", "", fmt.Errorf("创建对话失败: %w", createErr)
}
@@ -830,11 +837,28 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, con
}
switch robotMode {
case "eino_single":
resultMA, errMA := multiagent.RunEinoSingleChatModelAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, nil,
)
if errMA != nil {
curHist := agentHistoryMessages
curMsg := finalMessage
segmentUserMessage := finalMessage
var resultMA *multiagent.RunResult
var errMA error
var transientRunAttempts 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),
)
if errMA == nil {
// 成功后重置 transient 重试窗口,下一次分段从第 1 次重试开始。
transientRunAttempts = 0
break
}
if handled, _ := h.handleEinoTransientRetryContinue(
taskCtx, conversationID, resultMA, errMA, &transientRunAttempts,
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
); handled {
continue
}
taskStatus = "failed"
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
}
@@ -845,19 +869,36 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform, con
zap.String("robot_mode", robotMode))
break
}
resultMA, errMA := multiagent.RunDeepAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback,
h.agentsMarkdownDir, robotMode, nil,
)
if errMA != nil {
curHist := agentHistoryMessages
curMsg := finalMessage
segmentUserMessage := finalMessage
var resultMA *multiagent.RunResult
var errMA error
var transientRunAttempts int
for {
resultMA, errMA = multiagent.RunDeepAgent(
taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger,
conversationID, curMsg, curHist, roleTools, progressCallback,
h.agentsMarkdownDir, robotMode, nil, h.projectBlackboardBlock(conversationID),
)
if errMA == nil {
// 成功后重置 transient 重试窗口,下一次分段从第 1 次重试开始。
transientRunAttempts = 0
break
}
if handled, _ := h.handleEinoTransientRetryContinue(
taskCtx, conversationID, resultMA, errMA, &transientRunAttempts,
&curHist, &curMsg, segmentUserMessage, progressCallback, nil,
); handled {
continue
}
taskStatus = "failed"
return h.finalizeRobotAgentError(taskCtx, assistantMessageID, conversationID, resultMA, errMA)
}
return h.finalizeRobotAgentSuccess(assistantMessageID, conversationID, resultMA)
}
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, h.projectBlackboardBlock(conversationID))
if err != nil {
taskStatus = "failed"
errMsg := "执行失败: " + err.Error()
@@ -1484,6 +1525,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
var conv *database.Conversation
var err error
meta := audit.ConversationCreateMetaFromGin(c, "agent_loop_stream")
meta.ProjectID = effectiveProjectID(h.config, req.ProjectID)
if req.WebShellConnectionID != "" {
meta.Source = "webshell_chat"
conv, err = h.db.CreateConversationWithWebshell(strings.TrimSpace(req.WebShellConnectionID), title, meta)
@@ -1561,6 +1603,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
builtin.ToolWebshellFileRead,
builtin.ToolWebshellFileWrite,
builtin.ToolRecordVulnerability,
builtin.ToolListVulnerabilities,
builtin.ToolGetVulnerability,
builtin.ToolListKnowledgeRiskTypes,
builtin.ToolSearchKnowledgeBase,
}
@@ -1691,7 +1735,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
defer close(stopKeepalive)
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools)
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, h.projectBlackboardBlock(conversationID))
if err != nil {
h.logger.Error("Agent Loop执行失败", zap.Error(err))
cause := context.Cause(baseCtx)
@@ -2003,6 +2047,7 @@ type BatchTaskRequest struct {
ScheduleMode string `json:"scheduleMode,omitempty"` // manual | cron
CronExpr string `json:"cronExpr,omitempty"` // scheduleMode=cron 时必填
ExecuteNow bool `json:"executeNow,omitempty"` // 创建后是否立即执行(默认 false)
ProjectID string `json:"projectId,omitempty"` // 队列内子对话绑定的项目(可选)
}
func normalizeBatchQueueAgentMode(mode string) string {
@@ -2083,7 +2128,7 @@ func (h *AgentHandler) CreateBatchQueue(c *gin.Context) {
nextRunAt = &next
}
queue, createErr := h.batchTaskManager.CreateBatchQueue(req.Title, req.Role, agentMode, scheduleMode, cronExpr, nextRunAt, validTasks)
queue, createErr := h.batchTaskManager.CreateBatchQueue(req.Title, req.Role, agentMode, scheduleMode, cronExpr, req.ProjectID, nextRunAt, validTasks)
if createErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": createErr.Error()})
return
@@ -2617,7 +2662,9 @@ func (h *AgentHandler) executeBatchQueue(queueID string) {
// 创建新对话
title := safeTruncateString(task.Message, 50)
conv, err := h.db.CreateConversation(title, audit.ConversationCreateMeta("batch_task"))
batchMeta := audit.ConversationCreateMeta("batch_task")
batchMeta.ProjectID = effectiveProjectID(h.config, queue.ProjectID)
conv, err := h.db.CreateConversation(title, batchMeta)
var conversationID string
if err != nil {
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
@@ -2767,15 +2814,15 @@ 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)
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))
case useEinoSingle:
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)
resultMA, runErr = multiagent.RunEinoSingleChatModelAgent(taskCtx, h.config, &h.config.MultiAgent, h.agent, h.logger, conversationID, finalMessage, []agent.ChatMessage{}, roleTools, progressCallback, nil, h.projectBlackboardBlock(conversationID))
}
default:
result, runErr = h.agent.AgentLoopWithProgress(taskCtx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools)
result, runErr = h.agent.AgentLoopWithProgress(taskCtx, finalMessage, []agent.ChatMessage{}, conversationID, progressCallback, roleTools, h.projectBlackboardBlock(conversationID))
}
if runErr != nil {
+10 -1
View File
@@ -65,6 +65,7 @@ type BatchTaskQueue struct {
LastScheduleTriggerAt *time.Time `json:"lastScheduleTriggerAt,omitempty"`
LastScheduleError string `json:"lastScheduleError,omitempty"`
LastRunError string `json:"lastRunError,omitempty"`
ProjectID string `json:"projectId,omitempty"`
Tasks []*BatchTask `json:"tasks"`
Status string `json:"status"` // pending, running, paused, completed, cancelled
CreatedAt time.Time `json:"createdAt"`
@@ -103,7 +104,7 @@ func (m *BatchTaskManager) SetDB(db *database.DB) {
// CreateBatchQueue 创建批量任务队列
func (m *BatchTaskManager) CreateBatchQueue(
title, role, agentMode, scheduleMode, cronExpr string,
title, role, agentMode, scheduleMode, cronExpr, projectID string,
nextRunAt *time.Time,
tasks []string,
) (*BatchTaskQueue, error) {
@@ -126,6 +127,7 @@ func (m *BatchTaskManager) CreateBatchQueue(
ID: queueID,
Title: title,
Role: role,
ProjectID: strings.TrimSpace(projectID),
AgentMode: normalizeBatchQueueAgentMode(agentMode),
ScheduleMode: normalizeBatchQueueScheduleMode(scheduleMode),
CronExpr: strings.TrimSpace(cronExpr),
@@ -171,6 +173,7 @@ func (m *BatchTaskManager) CreateBatchQueue(
queue.ScheduleMode,
queue.CronExpr,
queue.NextRunAt,
queue.ProjectID,
dbTasks,
); err != nil {
m.logger.Warn("batch queue DB create failed", zap.String("queueId", queueID), zap.Error(err))
@@ -263,6 +266,9 @@ func (m *BatchTaskManager) loadQueueFromDB(queueID string) *BatchTaskQueue {
if queueRow.LastRunError.Valid {
queue.LastRunError = strings.TrimSpace(queueRow.LastRunError.String)
}
if queueRow.ProjectID.Valid {
queue.ProjectID = strings.TrimSpace(queueRow.ProjectID.String)
}
if queueRow.StartedAt.Valid {
queue.StartedAt = &queueRow.StartedAt.Time
}
@@ -499,6 +505,9 @@ func (m *BatchTaskManager) LoadFromDB() error {
if queueRow.LastRunError.Valid {
queue.LastRunError = strings.TrimSpace(queueRow.LastRunError.String)
}
if queueRow.ProjectID.Valid {
queue.ProjectID = strings.TrimSpace(queueRow.ProjectID.String)
}
if queueRow.StartedAt.Valid {
queue.StartedAt = &queueRow.StartedAt.Time
}
+6 -1
View File
@@ -176,6 +176,10 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
"type": "boolean",
"description": "创建后是否立即开始执行队列,默认 falsepending,需 batch_task_start",
},
"project_id": map[string]interface{}{
"type": "string",
"description": "队列内子对话绑定的项目 ID(可选,未指定时使用 config.project.default_project_id",
},
},
},
}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
@@ -204,7 +208,8 @@ func RegisterBatchTaskMCPTools(mcpServer *mcp.Server, h *AgentHandler, logger *z
if !ok {
executeNow = false
}
queue, createErr := h.batchTaskManager.CreateBatchQueue(title, role, agentMode, scheduleMode, cronExpr, nextRunAt, tasks)
projectID := strings.TrimSpace(mcpArgString(args, "project_id"))
queue, createErr := h.batchTaskManager.CreateBatchQueue(title, role, agentMode, scheduleMode, cronExpr, projectID, nextRunAt, tasks)
if createErr != nil {
return batchMCPTextResult("创建队列失败: "+createErr.Error(), true), nil
}
+30 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/database"
@@ -33,7 +34,13 @@ func NewConversationHandler(db *database.DB, logger *zap.Logger) *ConversationHa
// CreateConversationRequest 创建对话请求
type CreateConversationRequest struct {
Title string `json:"title"`
Title string `json:"title"`
ProjectID string `json:"projectId,omitempty"`
}
// SetConversationProjectRequest 设置对话所属项目
type SetConversationProjectRequest struct {
ProjectID string `json:"projectId"` // 空字符串表示解除绑定
}
// CreateConversation 创建新对话
@@ -49,7 +56,9 @@ func (h *ConversationHandler) CreateConversation(c *gin.Context) {
title = "新对话"
}
conv, err := h.db.CreateConversation(title, audit.ConversationCreateMetaFromGin(c, "api"))
meta := audit.ConversationCreateMetaFromGin(c, "api")
meta.ProjectID = strings.TrimSpace(req.ProjectID)
conv, err := h.db.CreateConversation(title, meta)
if err != nil {
h.logger.Error("创建对话失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -59,6 +68,25 @@ func (h *ConversationHandler) CreateConversation(c *gin.Context) {
c.JSON(http.StatusOK, conv)
}
// SetConversationProject 设置或清除对话绑定的项目
func (h *ConversationHandler) SetConversationProject(c *gin.Context) {
id := c.Param("id")
var req SetConversationProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if _, err := h.db.GetConversation(id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
return
}
if err := h.db.SetConversationProjectID(id, req.ProjectID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "projectId": strings.TrimSpace(req.ProjectID)})
}
// ListConversations 列出对话
func (h *ConversationHandler) ListConversations(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "50")
+122
View File
@@ -0,0 +1,122 @@
package handler
import (
"context"
"errors"
"fmt"
"strings"
"time"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/multiagent"
)
func (h *AgentHandler) einoRunRetryMaxAttempts() int {
if h.config != nil {
return multiagent.RunRetryMaxAttemptsFromConfig(&h.config.MultiAgent.EinoMiddleware)
}
return multiagent.RunRetryMaxAttemptsFromConfig(nil)
}
func (h *AgentHandler) einoRunRetryMaxBackoffSec() int {
if h.config != nil && h.config.MultiAgent.EinoMiddleware.RunRetryMaxBackoffSec > 0 {
return h.config.MultiAgent.EinoMiddleware.RunRetryMaxBackoffSec
}
return 0
}
// applyEinoTraceResumeSegment 中断并继续:persist last_react_* → loadHistory,可选替换下一段 user 文案。
func (h *AgentHandler) applyEinoTraceResumeSegment(
conversationID string,
result *multiagent.RunResult,
curHistory *[]agent.ChatMessage,
curFinalMessage *string,
segmentUserMessage string,
) {
if shouldPersistEinoAgentTraceAfterRunError(context.Background()) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
*curHistory = hist
}
if segmentUserMessage != "" {
*curFinalMessage = segmentUserMessage
}
}
// applyEinoTransientRetrySegment 临时错误重试:恢复轨迹并保留本请求原始 user 文案(不注入续跑说明)。
// segmentUserMessage 为本轮 HTTP 请求开始时用户发送的内容,避免因清空 finalMessage 而丢失「你好」等短句。
func (h *AgentHandler) applyEinoTransientRetrySegment(
conversationID string,
result *multiagent.RunResult,
curHistory *[]agent.ChatMessage,
curFinalMessage *string,
segmentUserMessage string,
) {
if shouldPersistEinoAgentTraceAfterRunError(context.Background()) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
*curHistory = hist
}
if s := strings.TrimSpace(segmentUserMessage); s != "" {
*curFinalMessage = segmentUserMessage
}
}
// handleEinoTransientRetryContinue 在 SSE 任务循环内处理临时错误重试;返回 true 表示外层 for 应 continue。
func (h *AgentHandler) handleEinoTransientRetryContinue(
baseCtx context.Context,
conversationID string,
result *multiagent.RunResult,
runErr error,
transientAttempts *int,
curHistory *[]agent.ChatMessage,
curFinalMessage *string,
segmentUserMessage string,
progressCallback func(eventType, message string, data interface{}),
sendProgress func(msg string, extra map[string]interface{}),
) (handled bool, fatal error) {
if !errors.Is(runErr, multiagent.ErrTransientRetryContinue) {
return false, nil
}
maxAttempts := h.einoRunRetryMaxAttempts()
*transientAttempts++
if *transientAttempts > maxAttempts {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(conversationID, result)
}
return false, errors.New("transient retry exhausted: " + runErr.Error())
}
attemptNo := *transientAttempts
backoff := multiagent.TransientRetryBackoff(attemptNo-1, h.einoRunRetryMaxBackoffSec())
if progressCallback != nil {
progressCallback("eino_run_retry", fmt.Sprintf("遇到临时错误,%d 秒后第 %d/%d 次重试…", int(backoff.Seconds()), attemptNo, maxAttempts), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"attempt": attemptNo,
"maxAttempts": maxAttempts,
"backoffSec": int(backoff.Seconds()),
})
}
select {
case <-baseCtx.Done():
return false, context.Cause(baseCtx)
case <-time.After(backoff):
}
h.applyEinoTransientRetrySegment(conversationID, result, curHistory, curFinalMessage, segmentUserMessage)
if progressCallback != nil {
progressCallback("eino_run_retry", "已恢复上下文,正在重试…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"attempt": attemptNo,
})
}
if sendProgress != nil {
sendProgress("正在重试…", map[string]interface{}{
"conversationId": conversationID,
"source": "transient_retry",
})
}
return true, nil
}
+67 -3
View File
@@ -119,6 +119,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
var cancelWithCause context.CancelCauseFunc
curFinalMessage := prep.FinalMessage
segmentUserMessage := prep.FinalMessage // 本请求原始用户句,临时重试时不得丢失
curHistory := prep.History
roleTools := prep.RoleTools
@@ -176,9 +177,41 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
taskOwned = true
var cumulativeMCPExecutionIDs []string
var transientRunAttempts int
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int
for {
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
segmentMainIterationMax := 0
rawProgressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
progressCallback := func(eventType, message string, data interface{}) {
if eventType == "iteration" {
if m, ok := data.(map[string]interface{}); ok {
if scope, _ := m["einoScope"].(string); scope == "main" {
raw := 0
switch v := m["iteration"].(type) {
case int:
raw = v
case int32:
raw = int(v)
case int64:
raw = int(v)
case float64:
raw = int(v)
case float32:
raw = int(v)
}
if raw > 0 {
if raw > segmentMainIterationMax {
segmentMainIterationMax = raw
}
m["iteration"] = raw + mainIterationOffset
}
}
}
}
rawProgressCallback(eventType, message, data)
}
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
@@ -197,17 +230,38 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
roleTools,
progressCallback,
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(conversationID),
)
timeoutCancel()
if result != nil && len(result.MCPExecutionIDs) > 0 {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
}
if runErr == nil {
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
transientRunAttempts = 0
timeoutCancel()
break
}
handled, fatalErr := h.handleEinoTransientRetryContinue(
baseCtx, conversationID, result, runErr, &transientRunAttempts,
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
)
if handled {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
if fatalErr != nil {
runErr = fatalErr
}
cause := context.Cause(baseCtx)
if errors.Is(cause, multiagent.ErrInterruptContinue) {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
@@ -231,10 +285,14 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
"conversationId": conversationID,
"source": "interrupt_continue",
})
h.tasks.UpdateTaskStatus(conversationID, "running")
mainIterationOffset += segmentMainIterationMax
// 非临时错误分段续跑(用户中断并继续)时,清空 transient 计数,避免跨分段累加。
transientRunAttempts = 0
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
@@ -261,6 +319,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
"messageId": assistantMessageID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
@@ -278,6 +337,7 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
"errorType": "timeout",
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
@@ -294,9 +354,12 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
"messageId": assistantMessageID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
timeoutCancel()
if assistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
}
@@ -367,6 +430,7 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
prep.RoleTools,
progressCallback,
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(prep.ConversationID),
)
if runErr != nil {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
+67 -3
View File
@@ -136,6 +136,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
var cancelWithCause context.CancelCauseFunc
curFinalMessage := prep.FinalMessage
segmentUserMessage := prep.FinalMessage // 本请求原始用户句,临时重试时不得丢失
curHistory := prep.History
roleTools := prep.RoleTools
orch := strings.TrimSpace(req.Orchestration)
@@ -186,9 +187,41 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
// 同一 HTTP 流内多段 Run(如中断并继续)合并 MCP execution id,供最终 response / 库表与工具芯片展示完整列表
var cumulativeMCPExecutionIDs []string
var transientRunAttempts int
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int
for {
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
segmentMainIterationMax := 0
rawProgressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
progressCallback := func(eventType, message string, data interface{}) {
if eventType == "iteration" {
if m, ok := data.(map[string]interface{}); ok {
if scope, _ := m["einoScope"].(string); scope == "main" {
raw := 0
switch v := m["iteration"].(type) {
case int:
raw = v
case int32:
raw = int(v)
case int64:
raw = int(v)
case float64:
raw = int(v)
case float32:
raw = int(v)
}
if raw > 0 {
if raw > segmentMainIterationMax {
segmentMainIterationMax = raw
}
m["iteration"] = raw + mainIterationOffset
}
}
}
}
rawProgressCallback(eventType, message, data)
}
taskCtxLoop := mcp.WithMCPConversationID(taskCtx, conversationID)
taskCtxLoop = mcp.WithToolRunRegistry(taskCtxLoop, h.tasks)
taskCtxLoop = multiagent.WithHITLToolInterceptor(taskCtxLoop, func(ctx context.Context, toolName, arguments string) (string, error) {
@@ -209,17 +242,38 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
h.agentsMarkdownDir,
orch,
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(conversationID),
)
timeoutCancel()
if result != nil && len(result.MCPExecutionIDs) > 0 {
cumulativeMCPExecutionIDs = mergeMCPExecutionIDLists(cumulativeMCPExecutionIDs, result.MCPExecutionIDs)
}
if runErr == nil {
// 任一段成功完成后,重置临时错误重试窗口(次数/退避从头开始)。
transientRunAttempts = 0
timeoutCancel()
break
}
handled, fatalErr := h.handleEinoTransientRetryContinue(
baseCtx, conversationID, result, runErr, &transientRunAttempts,
&curHistory, &curFinalMessage, segmentUserMessage, progressCallback,
func(msg string, extra map[string]interface{}) { sendEvent("progress", msg, extra) },
)
if handled {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
if fatalErr != nil {
runErr = fatalErr
}
cause := context.Cause(baseCtx)
if errors.Is(cause, multiagent.ErrInterruptContinue) {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
@@ -243,10 +297,14 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
"conversationId": conversationID,
"source": "interrupt_continue",
})
h.tasks.UpdateTaskStatus(conversationID, "running")
mainIterationOffset += segmentMainIterationMax
// 非临时错误分段续跑(用户中断并继续)时,清空 transient 计数,避免跨分段累加。
transientRunAttempts = 0
timeoutCancel()
baseCtx, cancelWithCause = context.WithCancelCause(context.Background())
h.tasks.BindTaskCancel(conversationID, cancelWithCause)
taskCtx, timeoutCancel = context.WithTimeout(baseCtx, 600*time.Minute)
h.tasks.UpdateTaskStatus(conversationID, "running")
continue
}
@@ -273,6 +331,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
"messageId": assistantMessageID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
@@ -290,6 +349,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
"errorType": "timeout",
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
@@ -306,9 +366,12 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
"messageId": assistantMessageID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
timeoutCancel()
return
}
timeoutCancel()
if assistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
}
@@ -381,6 +444,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
h.agentsMarkdownDir,
strings.TrimSpace(req.Orchestration),
chatReasoningToClientIntent(req.Reasoning),
h.projectBlackboardBlock(prep.ConversationID),
)
if runErr != nil {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
+8
View File
@@ -36,6 +36,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
var conv *database.Conversation
var err error
meta := audit.ConversationCreateMetaFromGin(c, source)
meta.ProjectID = effectiveProjectID(h.config, req.ProjectID)
if strings.TrimSpace(req.WebShellConnectionID) != "" {
meta.Source = source + "_webshell"
meta.WebShellConnectionID = strings.TrimSpace(req.WebShellConnectionID)
@@ -90,6 +91,13 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
builtin.ToolWebshellFileRead,
builtin.ToolWebshellFileWrite,
builtin.ToolRecordVulnerability,
builtin.ToolListVulnerabilities,
builtin.ToolGetVulnerability,
builtin.ToolUpsertProjectFact,
builtin.ToolGetProjectFact,
builtin.ToolListProjectFacts,
builtin.ToolSearchProjectFacts,
builtin.ToolDeprecateProjectFact,
builtin.ToolListKnowledgeRiskTypes,
builtin.ToolSearchKnowledgeBase,
}
+138
View File
@@ -73,8 +73,22 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"description": "对话标题",
"example": "Web应用安全测试",
},
"projectId": map[string]interface{}{
"type": "string",
"description": "绑定的项目 ID(可选,共享事实黑板)",
},
},
},
"SetConversationProjectRequest": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"projectId": map[string]interface{}{
"type": "string",
"description": "项目 ID;空字符串表示解除绑定",
},
},
"required": []string{"projectId"},
},
"Conversation": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@@ -98,6 +112,10 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"format": "date-time",
"description": "更新时间",
},
"projectId": map[string]interface{}{
"type": "string",
"description": "绑定的项目 ID(可选)",
},
},
},
"ConversationDetail": map[string]interface{}{
@@ -1326,6 +1344,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/conversations/{id}/project": map[string]interface{}{
"put": map[string]interface{}{
"tags": []string{"对话管理"},
"summary": "设置对话所属项目",
"description": "绑定或解除对话与项目的关联,用于共享事实黑板",
"operationId": "setConversationProject",
"parameters": []map[string]interface{}{
{
"name": "id", "in": "path", "required": true,
"description": "对话ID",
"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{}{
"$ref": "#/components/schemas/SetConversationProjectRequest",
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{"description": "设置成功"},
"400": map[string]interface{}{"description": "项目不存在或参数错误"},
"404": map[string]interface{}{"description": "对话不存在"},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
"/api/conversations/{id}/results": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话管理"},
@@ -2444,6 +2493,86 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/projects": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"项目管理"},
"summary": "列出项目",
"operationId": "listProjects",
"parameters": []map[string]interface{}{
{"name": "status", "in": "query", "schema": map[string]interface{}{"type": "string", "enum": []string{"active", "archived"}}},
{"name": "limit", "in": "query", "schema": map[string]interface{}{"type": "integer", "default": 200}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{"description": "项目列表"},
"401": map[string]interface{}{"description": "未授权"},
},
},
"post": map[string]interface{}{
"tags": []string{"项目管理"},
"summary": "创建项目",
"operationId": "createProject",
"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{}{
"name": map[string]interface{}{"type": "string"},
"description": map[string]interface{}{"type": "string"},
"scope_json": map[string]interface{}{"type": "string"},
},
"required": []string{"name"},
},
},
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{"description": "创建成功"},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
"/api/projects/{id}": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"项目管理"}, "summary": "获取项目", "operationId": "getProject",
"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": "项目详情"}},
},
"put": map[string]interface{}{
"tags": []string{"项目管理"}, "summary": "更新项目", "operationId": "updateProject",
"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": "更新成功"}},
},
"delete": map[string]interface{}{
"tags": []string{"项目管理"}, "summary": "删除项目", "operationId": "deleteProject",
"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": "删除成功"}},
},
},
"/api/projects/{id}/facts": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"项目管理"}, "summary": "列出或按 key 获取事实", "operationId": "listProjectFacts",
"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"}},
},
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "事实列表或单条"}},
},
"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"}},
},
"responses": map[string]interface{}{"200": map[string]interface{}{"description": "成功"}},
},
},
"/api/vulnerabilities": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"漏洞管理"},
@@ -2502,6 +2631,15 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
},
},
{
"name": "project_id",
"in": "query",
"required": false,
"description": "项目ID",
"schema": map[string]interface{}{
"type": "string",
},
},
{
"name": "severity",
"in": "query",
+262
View File
@@ -0,0 +1,262 @@
package handler
import (
"net/http"
"strconv"
"strings"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// ProjectHandler 项目管理处理器。
type ProjectHandler struct {
db *database.DB
logger *zap.Logger
}
// NewProjectHandler 创建项目管理处理器。
func NewProjectHandler(db *database.DB, logger *zap.Logger) *ProjectHandler {
return &ProjectHandler{db: db, logger: logger}
}
type createProjectRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
ScopeJSON string `json:"scope_json"`
Status string `json:"status"`
}
type updateProjectRequest struct {
Name string `json:"name"`
Description string `json:"description"`
ScopeJSON string `json:"scope_json"`
Status string `json:"status"`
Pinned *bool `json:"pinned"`
}
// CreateProject POST /api/projects
func (h *ProjectHandler) CreateProject(c *gin.Context) {
var req createProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
p := &database.Project{
Name: strings.TrimSpace(req.Name),
Description: req.Description,
ScopeJSON: req.ScopeJSON,
Status: strings.TrimSpace(req.Status),
}
created, err := h.db.CreateProject(p)
if err != nil {
h.logger.Error("创建项目失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, created)
}
// ListProjects GET /api/projects
func (h *ProjectHandler) ListProjects(c *gin.Context) {
status := c.Query("status")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
offset, _ := strconv.Atoi(c.Query("offset"))
list, err := h.db.ListProjects(status, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []*database.Project{}
}
c.JSON(http.StatusOK, list)
}
// GetProject GET /api/projects/:id
func (h *ProjectHandler) GetProject(c *gin.Context) {
p, err := h.db.GetProject(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "项目不存在"})
return
}
c.JSON(http.StatusOK, p)
}
// UpdateProject PUT /api/projects/:id
func (h *ProjectHandler) UpdateProject(c *gin.Context) {
id := c.Param("id")
p, err := h.db.GetProject(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "项目不存在"})
return
}
var req updateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if s := strings.TrimSpace(req.Name); s != "" {
p.Name = s
}
if req.Description != "" || c.Request.ContentLength > 0 {
p.Description = req.Description
}
if req.ScopeJSON != "" || c.GetHeader("Content-Type") != "" {
p.ScopeJSON = req.ScopeJSON
}
if s := strings.TrimSpace(req.Status); s != "" {
p.Status = s
}
if req.Pinned != nil {
p.Pinned = *req.Pinned
}
if err := h.db.UpdateProject(p); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, p)
}
// DeleteProject DELETE /api/projects/:id
func (h *ProjectHandler) DeleteProject(c *gin.Context) {
if err := h.db.DeleteProject(c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
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"`
}
// ListFacts GET /api/projects/:id/facts fact_key 查询参数可获取单条详情)
func (h *ProjectHandler) ListFacts(c *gin.Context) {
projectID := c.Param("id")
if key := strings.TrimSpace(c.Query("fact_key")); key != "" {
f, err := h.db.GetProjectFactByKey(projectID, key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, f)
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
offset, _ := strconv.Atoi(c.Query("offset"))
filter := database.ProjectFactListFilter{
Category: c.Query("category"),
Confidence: c.Query("confidence"),
Search: c.Query("search"),
}
list, err := h.db.ListProjectFacts(projectID, filter, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if list == nil {
list = []*database.ProjectFact{}
}
c.JSON(http.StatusOK, list)
}
// CreateFact POST /api/projects/:id/facts
func (h *ProjectHandler) CreateFact(c *gin.Context) {
var req upsertFactRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
f := &database.ProjectFact{
ProjectID: c.Param("id"),
FactKey: req.FactKey,
Category: req.Category,
Summary: req.Summary,
Body: req.Body,
Confidence: req.Confidence,
Pinned: req.Pinned,
RelatedVulnerabilityID: req.RelatedVulnerabilityID,
}
created, err := h.db.UpsertProjectFact(f)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, created)
}
// UpdateFact PUT /api/projects/:id/facts/:factId
func (h *ProjectHandler) UpdateFact(c *gin.Context) {
existing, err := h.db.GetProjectFact(c.Param("factId"))
if err != nil || existing.ProjectID != c.Param("id") {
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
return
}
var req upsertFactRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if k := strings.TrimSpace(req.FactKey); k != "" {
existing.FactKey = k
}
if req.Category != "" {
existing.Category = req.Category
}
if req.Summary != "" {
existing.Summary = req.Summary
}
existing.Body = req.Body
if req.Confidence != "" {
existing.Confidence = req.Confidence
}
existing.Pinned = req.Pinned
existing.RelatedVulnerabilityID = req.RelatedVulnerabilityID
updated, err := h.db.UpsertProjectFact(existing)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
// DeleteFact DELETE /api/projects/:id/facts/:factId
func (h *ProjectHandler) DeleteFact(c *gin.Context) {
existing, err := h.db.GetProjectFact(c.Param("factId"))
if err != nil || existing.ProjectID != c.Param("id") {
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
return
}
if err := h.db.DeleteProjectFact(existing.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
type deprecateFactRequest struct {
FactKey string `json:"fact_key" binding:"required"`
}
// DeprecateFact POST /api/projects/:id/facts/deprecate
func (h *ProjectHandler) DeprecateFact(c *gin.Context) {
var req deprecateFactRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.DeprecateProjectFact(c.Param("id"), req.FactKey); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
+32
View File
@@ -0,0 +1,32 @@
package handler
import (
"strings"
"cyberstrike-ai/internal/project"
"go.uber.org/zap"
)
// projectBlackboardBlock 根据对话 ID 构建项目事实索引块(用于注入 system prompt)。
func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
if h == nil || h.db == nil || h.config == nil {
return ""
}
if !h.config.Project.Enabled {
return ""
}
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" {
return ""
}
projectID, err := h.db.GetConversationProjectID(conversationID)
if err != nil || projectID == "" {
return ""
}
block, err := project.BuildFactIndexBlock(h.db, projectID, h.config.Project)
if err != nil {
h.logger.Warn("构建项目黑板索引失败", zap.String("conversationId", conversationID), zap.Error(err))
return ""
}
return strings.TrimSpace(block)
}
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"strings"
"cyberstrike-ai/internal/config"
)
// effectiveProjectID 请求/队列显式项目优先,否则使用 config.project.default_project_id。
func effectiveProjectID(cfg *config.Config, explicit string) string {
if pid := strings.TrimSpace(explicit); pid != "" {
return pid
}
if cfg != nil {
return strings.TrimSpace(cfg.Project.DefaultProjectID)
}
return ""
}
+6 -2
View File
@@ -133,7 +133,9 @@ func (h *RobotHandler) getOrCreateConversation(platform, userID, title string) (
} else {
t = safeTruncateString(t, 50)
}
conv, err := h.db.CreateConversation(t, database.ConversationCreateMeta{Source: "robot:" + platform})
meta := database.ConversationCreateMeta{Source: "robot:" + platform}
meta.ProjectID = effectiveProjectID(h.config, "")
conv, err := h.db.CreateConversation(t, meta)
if err != nil {
h.logger.Warn("创建机器人会话失败", zap.Error(err))
return "", false
@@ -188,7 +190,9 @@ func (h *RobotHandler) setRole(platform, userID, roleName string) {
// clearConversation 清空当前会话(切换到新对话)
func (h *RobotHandler) clearConversation(platform, userID string) (newConvID string) {
title := "新对话 " + time.Now().Format("01-02 15:04")
conv, err := h.db.CreateConversation(title, database.ConversationCreateMeta{Source: "robot:" + platform + ":new"})
meta := database.ConversationCreateMeta{Source: "robot:" + platform + ":new"}
meta.ProjectID = effectiveProjectID(h.config, "")
conv, err := h.db.CreateConversation(title, meta)
if err != nil {
h.logger.Warn("创建新对话失败", zap.Error(err))
return ""
+19 -12
View File
@@ -36,6 +36,7 @@ func NewVulnerabilityHandler(db *database.DB, logger *zap.Logger) *Vulnerability
// CreateVulnerabilityRequest 创建漏洞请求
type CreateVulnerabilityRequest struct {
ConversationID string `json:"conversation_id" binding:"required"`
ProjectID string `json:"project_id"`
ConversationTag string `json:"conversation_tag"`
TaskTag string `json:"task_tag"`
Title string `json:"title" binding:"required"`
@@ -59,6 +60,7 @@ func (h *VulnerabilityHandler) CreateVulnerability(c *gin.Context) {
vuln := &database.Vulnerability{
ConversationID: req.ConversationID,
ProjectID: strings.TrimSpace(req.ProjectID),
ConversationTag: req.ConversationTag,
TaskTag: req.TaskTag,
Title: req.Title,
@@ -116,6 +118,7 @@ func parseVulnerabilityListFilter(c *gin.Context) database.VulnerabilityListFilt
q = strings.TrimSpace(c.Query("search"))
}
return database.VulnerabilityListFilter{
ProjectID: c.Query("project_id"),
ID: c.Query("id"),
Search: q,
ConversationID: c.Query("conversation_id"),
@@ -193,17 +196,18 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
// UpdateVulnerabilityRequest 更新漏洞请求
type UpdateVulnerabilityRequest struct {
ConversationTag string `json:"conversation_tag"`
TaskTag string `json:"task_tag"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
Status string `json:"status"`
Type string `json:"type"`
Target string `json:"target"`
Proof string `json:"proof"`
Impact string `json:"impact"`
Recommendation string `json:"recommendation"`
ProjectID *string `json:"project_id"`
ConversationTag string `json:"conversation_tag"`
TaskTag string `json:"task_tag"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
Status string `json:"status"`
Type string `json:"type"`
Target string `json:"target"`
Proof string `json:"proof"`
Impact string `json:"impact"`
Recommendation string `json:"recommendation"`
}
// UpdateVulnerability 更新漏洞
@@ -224,6 +228,9 @@ func (h *VulnerabilityHandler) UpdateVulnerability(c *gin.Context) {
}
// 更新字段
if req.ProjectID != nil {
existing.ProjectID = strings.TrimSpace(*req.ProjectID)
}
if req.ConversationTag != "" {
existing.ConversationTag = req.ConversationTag
}
@@ -274,7 +281,7 @@ func (h *VulnerabilityHandler) UpdateVulnerability(c *gin.Context) {
if h.audit != nil {
h.audit.RecordOK(c, "vulnerability", "update", "更新漏洞记录", "vulnerability", id, map[string]interface{}{
"severity": updated.Severity, "status": updated.Status,
"severity": updated.Severity, "status": updated.Status, "project_id": updated.ProjectID,
})
}
c.JSON(http.StatusOK, updated)
+1 -1
View File
@@ -15,7 +15,7 @@ const WebshellSkillHintMultiAgent = "Skills 包请使用 Eino 多代理内置 `s
// webshellAssistantToolList AI 助手在 WebShell 上下文下允许使用的工具清单(展示给模型用)。
// 注意:此处只是展示字符串,真正的权限限制是在调用方设置的 roleTools 切片里。
const webshellAssistantToolList = "webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_knowledge_risk_types、search_knowledge_base"
const webshellAssistantToolList = "webshell_exec、webshell_file_list、webshell_file_read、webshell_file_write、record_vulnerability、list_vulnerabilities、get_vulnerability、upsert_project_fact、get_project_fact、list_project_facts、search_project_facts、deprecate_project_fact、list_knowledge_risk_types、search_knowledge_base"
// BuildWebshellAssistantContext 根据连接信息与用户原始消息组装 AI 助手的上下文提示词。
// 上下文包含:连接 ID、备注、目标系统(及对应命令集建议)、响应编码、可用工具清单、Skills 加载入口、
+24 -1
View File
@@ -4,7 +4,16 @@ package builtin
// 所有代码中使用内置工具名称的地方都应该使用这些常量,而不是硬编码字符串
const (
// 漏洞管理工具
ToolRecordVulnerability = "record_vulnerability"
ToolRecordVulnerability = "record_vulnerability"
ToolListVulnerabilities = "list_vulnerabilities"
ToolGetVulnerability = "get_vulnerability"
// 项目黑板(事实)工具
ToolUpsertProjectFact = "upsert_project_fact"
ToolGetProjectFact = "get_project_fact"
ToolListProjectFacts = "list_project_facts"
ToolSearchProjectFacts = "search_project_facts"
ToolDeprecateProjectFact = "deprecate_project_fact"
// 知识库工具
ToolListKnowledgeRiskTypes = "list_knowledge_risk_types"
@@ -53,6 +62,13 @@ const (
func IsBuiltinTool(toolName string) bool {
switch toolName {
case ToolRecordVulnerability,
ToolListVulnerabilities,
ToolGetVulnerability,
ToolUpsertProjectFact,
ToolGetProjectFact,
ToolListProjectFacts,
ToolSearchProjectFacts,
ToolDeprecateProjectFact,
ToolListKnowledgeRiskTypes,
ToolSearchKnowledgeBase,
ToolWebshellExec,
@@ -96,6 +112,13 @@ func IsBuiltinTool(toolName string) bool {
func GetAllBuiltinTools() []string {
return []string{
ToolRecordVulnerability,
ToolListVulnerabilities,
ToolGetVulnerability,
ToolUpsertProjectFact,
ToolGetProjectFact,
ToolListProjectFacts,
ToolSearchProjectFacts,
ToolDeprecateProjectFact,
ToolListKnowledgeRiskTypes,
ToolSearchKnowledgeBase,
ToolWebshellExec,
+27 -2
View File
@@ -77,6 +77,9 @@ type einoADKRunLoopArgs struct {
StreamsMainAssistant func(agent string) bool
EinoRoleTag func(agent string) string
CheckpointDir string
// RunRetryMaxAttempts / RunRetryMaxBackoffSec429、5xx、网络抖动时的指数退避续跑(0=默认 10 次 / 30s 上限)。
RunRetryMaxAttempts int
RunRetryMaxBackoffSec int
McpIDsMu *sync.Mutex
McpIDs *[]string
@@ -437,6 +440,28 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
return runErr
}
// maybeRetryTransientRun:不在此层 runner.Run/Resume;由 handler 落库 + loadHistoryFromAgentTrace 分段续跑(同中断并继续)。
maybeRetryTransientRun := func(runErr error) (retry bool, fatal error) {
if runErr == nil || !isEinoTransientRunError(runErr) {
return false, handleRunErr(runErr)
}
if logger != nil {
logger.Warn("eino transient error, ending run segment for handler resume",
zap.Error(runErr),
zap.String("orchestration", orchMode))
}
if progress != nil {
progress("eino_run_retry", "遇到临时错误(限流或网络波动),将保存上下文并重试…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"orchestration": orchMode,
"error": runErr.Error(),
"resumeKind": "trace_segment",
})
}
return false, ErrTransientRetryContinue
}
takePartial := func(runErr error) (*RunResult, error) {
if len(runAccumulatedMsgs) <= baseAccumulatedCount {
return nil, runErr
@@ -519,7 +544,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
continue
}
if ev.Err != nil {
if retErr := handleRunErr(ev.Err); retErr != nil {
if _, retErr := maybeRetryTransientRun(ev.Err); retErr != nil {
return takePartial(retErr)
}
}
@@ -821,7 +846,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
"einoRole": einoRoleTag(ev.AgentName),
})
}
if retErr := handleRunErr(streamRecvErr); retErr != nil {
if _, retErr := maybeRetryTransientRun(streamRecvErr); retErr != nil {
return takePartial(retErr)
}
}
+7 -3
View File
@@ -13,12 +13,12 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/project"
"cyberstrike-ai/internal/reasoning"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/schema"
"go.uber.org/zap"
)
@@ -39,6 +39,7 @@ func RunEinoSingleChatModelAgent(
roleTools []string,
progress func(eventType, message string, data interface{}),
reasoningClient *reasoning.ClientIntent,
systemPromptExtra string,
) (*RunResult, error) {
if appCfg == nil || ag == nil {
return nil, fmt.Errorf("eino single: 配置或 Agent 为空")
@@ -178,7 +179,8 @@ func RunEinoSingleChatModelAgent(
},
EmitInternalEvents: true,
}
ins := injectToolNamesOnlyInstruction(ctx, ag.EinoSingleAgentSystemInstruction(), mainTools, singleToolSearchActive)
ins := project.AppendSystemPromptBlock(ag.EinoSingleAgentSystemInstruction(), systemPromptExtra)
ins = injectToolNamesOnlyInstruction(ctx, ins, mainTools, singleToolSearchActive)
if logger != nil {
names := collectToolNames(ctx, mainTools)
mountedNames := collectToolNames(ctx, mainToolsForCfg)
@@ -213,7 +215,7 @@ func RunEinoSingleChatModelAgent(
}
baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware)
baseMsgs = append(baseMsgs, schema.UserMessage(userMessage))
baseMsgs = appendUserMessageIfNeeded(baseMsgs, userMessage)
streamsMainAssistant := func(agent string) bool {
return agent == "" || agent == einoSingleAgentName
@@ -233,6 +235,8 @@ func RunEinoSingleChatModelAgent(
StreamsMainAssistant: streamsMainAssistant,
EinoRoleTag: einoRoleTag,
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
RunRetryMaxAttempts: ma.EinoMiddleware.RunRetryMaxAttempts,
RunRetryMaxBackoffSec: ma.EinoMiddleware.RunRetryMaxBackoffSec,
McpIDsMu: &mcpIDsMu,
McpIDs: &mcpIDs,
FilesystemMonitorAgent: ag,
+173
View File
@@ -0,0 +1,173 @@
package multiagent
import (
"context"
"errors"
"strings"
"time"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
const (
defaultEinoRunRetryMaxAttempts = 10
defaultEinoRunRetryMaxBackoff = 30 * time.Second
)
// isEinoTransientRunError 判断 ADK 运行期错误是否适合指数退避续跑(429、5xx、网络抖动等)。
// 用户取消、超时、迭代上限等由 run loop 单独处理,不在此列。
func isEinoTransientRunError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if isEinoIterationLimitError(err) {
return false
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
if msg == "" {
return false
}
transientMarkers := []string{
"406",
"429",
"too many requests",
"rate limit",
"rate_limit",
"ratelimit",
"quota exceeded",
"overloaded",
"capacity",
"temporarily unavailable",
"service unavailable",
"bad gateway",
"gateway timeout",
"internal server error",
"connection reset",
"connection refused",
"connection closed",
"i/o timeout",
"no such host",
"network is unreachable",
"broken pipe",
"eof",
"read tcp",
"write tcp",
"dial tcp",
"tls handshake timeout",
"stream error",
"unexpected eof",
"unexpected end of json",
"status code: 406",
"status code: 502",
"502",
"503",
"504",
"500",
}
for _, m := range transientMarkers {
if strings.Contains(msg, m) {
return true
}
}
return false
}
func einoRunRetryMaxAttempts(args *einoADKRunLoopArgs) int {
if args != nil && args.RunRetryMaxAttempts > 0 {
return args.RunRetryMaxAttempts
}
return defaultEinoRunRetryMaxAttempts
}
// RunRetryMaxAttemptsFromConfig 供 handler 分段续跑计数(与 eino_middleware.run_retry_max_attempts 一致)。
func RunRetryMaxAttemptsFromConfig(mw *config.MultiAgentEinoMiddlewareConfig) int {
if mw != nil && mw.RunRetryMaxAttempts > 0 {
return mw.RunRetryMaxAttempts
}
return defaultEinoRunRetryMaxAttempts
}
// TransientRetryBackoff 供 handler 在分段续跑前退避。
func TransientRetryBackoff(attempt int, maxBackoffSec int) time.Duration {
max := defaultEinoRunRetryMaxBackoff
if maxBackoffSec > 0 {
max = time.Duration(maxBackoffSec) * time.Second
}
return einoTransientRetryBackoff(attempt, max)
}
func einoRunRetryMaxBackoff(args *einoADKRunLoopArgs) time.Duration {
if args != nil && args.RunRetryMaxBackoffSec > 0 {
return time.Duration(args.RunRetryMaxBackoffSec) * time.Second
}
return defaultEinoRunRetryMaxBackoff
}
// einoRunRestartContextSource 描述无 checkpoint Resume 时 Run 使用的消息来源(日志/SSE)。
type einoRunRestartContextSource string
const (
einoRestartContextInitial einoRunRestartContextSource = "initial"
einoRestartContextAccumulated einoRunRestartContextSource = "accumulated"
einoRestartContextModelTrace einoRunRestartContextSource = "model_trace"
)
// einoMessagesForRunRestart 在退避后重新 Run 时选用最完整的上下文:
// 1) ModelFacingTrace(与模型实际入参一致) 2) 事件流累积的 runAccumulatedMsgs 3) 初始 msgs。
func einoMessagesForRunRestart(args *einoADKRunLoopArgs, baseMsgs, accumulated []adk.Message, baseCount int) ([]adk.Message, einoRunRestartContextSource) {
if trace := persistTraceSource(args, nil); len(trace) > 0 {
return append([]adk.Message(nil), trace...), einoRestartContextModelTrace
}
if len(accumulated) > baseCount {
return append([]adk.Message(nil), accumulated...), einoRestartContextAccumulated
}
return append([]adk.Message(nil), baseMsgs...), einoRestartContextInitial
}
// adkMessagesHasUserContent 从尾部向前查找,是否已有与 want 相同的 user 消息(避免重复 append)。
func adkMessagesHasUserContent(msgs []adk.Message, want string) bool {
want = strings.TrimSpace(want)
if want == "" {
return true
}
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m == nil {
continue
}
if m.Role == schema.User {
return strings.TrimSpace(m.Content) == want
}
if m.Role == schema.Assistant || m.Role == schema.Tool {
continue
}
break
}
return false
}
// appendUserMessageIfNeeded 在 history 轨迹之后追加本轮 user 消息(仅当轨迹中尚未包含该句)。
func appendUserMessageIfNeeded(msgs []adk.Message, userMessage string) []adk.Message {
if strings.TrimSpace(userMessage) == "" || adkMessagesHasUserContent(msgs, userMessage) {
return msgs
}
return append(msgs, schema.UserMessage(userMessage))
}
// einoTransientRetryBackoff 指数退避:2s, 4s, 8s… capped by maxBackoff。
func einoTransientRetryBackoff(attempt int, maxBackoff time.Duration) time.Duration {
if attempt < 0 {
attempt = 0
}
backoff := time.Duration(1<<uint(attempt+1)) * time.Second
if maxBackoff > 0 && backoff > maxBackoff {
backoff = maxBackoff
}
return backoff
}
@@ -0,0 +1,104 @@
package multiagent
import (
"context"
"errors"
"testing"
"time"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestIsEinoTransientRunError(t *testing.T) {
t.Parallel()
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"429", errors.New("HTTP 429 Too Many Requests"), true},
{"rate limit", errors.New(`{"error":"rate limit exceeded"}`), true},
{"connection reset", errors.New("read tcp: connection reset by peer"), true},
{"503", errors.New("upstream returned 503"), true},
{"iteration limit", errors.New("max iteration reached"), false},
{"canceled", context.Canceled, false},
{"deadline", context.DeadlineExceeded, false},
{"auth", errors.New("invalid api key"), false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isEinoTransientRunError(tc.err); got != tc.want {
t.Fatalf("isEinoTransientRunError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
func TestEinoTransientRetryBackoff(t *testing.T) {
t.Parallel()
max := 30 * time.Second
if got := einoTransientRetryBackoff(0, max); got != 2*time.Second {
t.Fatalf("attempt 0: got %v", got)
}
if got := einoTransientRetryBackoff(4, max); got != 30*time.Second {
t.Fatalf("attempt 4 capped: got %v", got)
}
}
func TestEinoMessagesForRunRestart(t *testing.T) {
t.Parallel()
base := []adk.Message{schema.UserMessage("hi")}
acc := append([]adk.Message(nil), base...)
acc = append(acc, schema.AssistantMessage("step1", nil))
got, src := einoMessagesForRunRestart(nil, base, acc, len(base))
if src != einoRestartContextAccumulated || len(got) != 2 {
t.Fatalf("accumulated: src=%s len=%d", src, len(got))
}
holder := newModelFacingTraceHolder()
holder.storeFromState(&adk.ChatModelAgentState{
Messages: []adk.Message{schema.UserMessage("u"), schema.AssistantMessage("model-view", nil)},
})
got2, src2 := einoMessagesForRunRestart(&einoADKRunLoopArgs{ModelFacingTrace: holder}, base, acc, len(base))
if src2 != einoRestartContextModelTrace || len(got2) != 2 {
t.Fatalf("model trace: src=%s len=%d", src2, len(got2))
}
}
func TestEinoRunRetryMaxAttemptsFromArgs(t *testing.T) {
t.Parallel()
if einoRunRetryMaxAttempts(nil) != defaultEinoRunRetryMaxAttempts {
t.Fatal("nil args should use default")
}
if einoRunRetryMaxAttempts(&einoADKRunLoopArgs{RunRetryMaxAttempts: 3}) != 3 {
t.Fatal("custom max attempts")
}
if RunRetryMaxAttemptsFromConfig(nil) != defaultEinoRunRetryMaxAttempts {
t.Fatal("config nil should use default")
}
}
func TestAppendUserMessageIfNeeded(t *testing.T) {
t.Parallel()
msgs := []adk.Message{schema.UserMessage("old task")}
out := appendUserMessageIfNeeded(msgs, "你好,你是谁")
if len(out) != 2 || out[1].Content != "你好,你是谁" {
t.Fatalf("should append user: len=%d", len(out))
}
dup := appendUserMessageIfNeeded(out, "你好,你是谁")
if len(dup) != 2 {
t.Fatalf("should not duplicate user message: len=%d", len(dup))
}
}
func TestErrTransientRetryContinue(t *testing.T) {
t.Parallel()
if !errors.Is(ErrTransientRetryContinue, ErrTransientRetryContinue) {
t.Fatal("sentinel should match")
}
}
+4
View File
@@ -5,3 +5,7 @@ import "errors"
// ErrInterruptContinue 作为 context.CancelCause 使用:用户选择「中断并继续」且当前无进行中的 MCP 工具时,
// 取消当前推理/流式输出,并在同一会话任务内携带用户补充说明自动续跑下一轮(类似 Hermes 式人机回合)。
var ErrInterruptContinue = errors.New("agent interrupt: continue with user-supplied context")
// ErrTransientRetryContinue 表示 Run 因 429/网络等临时错误结束,应由 handler 落库轨迹后
// loadHistoryFromAgentTrace 再开下一轮 Run(与 ErrInterruptContinue 同级的「分段续跑」语义)。
var ErrTransientRetryContinue = errors.New("agent transient: retry after persisting trace")
@@ -106,16 +106,16 @@ func DefaultPlanExecuteOrchestratorInstruction() string {
当工具返回错误时错误信息会包含在工具响应中请仔细阅读并做出合理的决策
## 漏洞记录
## 项目黑板事实与漏洞记录分离
发现有效漏洞时必须使用 ` + builtin.ToolRecordVulnerability + ` 记录标题描述严重程度类型目标证明POC影响修复建议
绑定项目时会自动注入黑板索引fact_key + 摘要**摘要不足必须 ` + builtin.ToolGetProjectFact + `(fact_key) body禁止臆造** 环境认知用 ` + builtin.ToolUpsertProjectFact + `key target/primary_domain正式漏洞用 ` + builtin.ToolRecordVulnerability + `记前可先 ` + builtin.ToolListVulnerabilities + ` 防重复详情用 ` + builtin.ToolGetVulnerability + `二者可各记一次误报用 ` + builtin.ToolDeprecateProjectFact + `漏洞查询默认仅当前项目未绑项目则仅当前会话
严重程度critical / high / medium / low / info证明须含足够证据请求响应截图命令输出等记录后可在授权范围内继续测试
严重程度critical / high / medium / low / info证明须含足够证据
## 技能库Skills与知识库
- 技能包位于服务器 skills/ 目录各子目录 SKILL.md遵循 agentskills.io知识库用于向量检索片段Skills 为可执行工作流指令
- plan_execute 执行器通过 MCP 使用知识库与漏洞记录等Skills 的渐进式加载在多代理 / Eino DeepAgent等模式中由内置 skill 工具完成 multi_agent.eino_skills
- plan_execute 执行器通过 MCP 使用知识库项目事实与漏洞记录等Skills 的渐进式加载在多代理 / Eino DeepAgent等模式中由内置 skill 工具完成 multi_agent.eino_skills
- 若需要完整 Skill 工作流而当前会话无 skill 工具请在计划或对用户说明中建议切换多代理或 Eino 编排会话
## 执行器对用户输出重要
@@ -206,7 +206,7 @@ func DefaultSupervisorOrchestratorInstruction() string {
- **委派优先**可独立封装需要专项上下文的子目标枚举验证归纳报告素材优先 transfer 给匹配子代理并在委派说明中写清子目标约束期望交付物结构证据要求
- **亲自执行**仅当无合适专家需全局衔接或子代理结果不足时由你直接调用工具
- **汇总**子代理输出是证据来源你要对齐矛盾补全上下文给出统一结论与可复现验证步骤避免机械拼接
- **漏洞**有效漏洞应通过 ` + builtin.ToolRecordVulnerability + ` 记录 POC 与严重性critical / high / medium / low / info
- **事实与漏洞**环境认知用 ` + builtin.ToolUpsertProjectFact + `正式漏洞用 ` + builtin.ToolRecordVulnerability + `查询用 ` + builtin.ToolListVulnerabilities + ` / ` + builtin.ToolGetVulnerability + `索引摘要不足时必须 ` + builtin.ToolGetProjectFact + ` 取详情
## transfer 交接与防重复劳动
+15 -2
View File
@@ -17,6 +17,7 @@ import (
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/einomcp"
"cyberstrike-ai/internal/openai"
"cyberstrike-ai/internal/project"
"cyberstrike-ai/internal/reasoning"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
@@ -64,6 +65,7 @@ func RunDeepAgent(
agentsMarkdownDir string,
orchestrationOverride string,
reasoningClient *reasoning.ClientIntent,
systemPromptExtra string,
) (*RunResult, error) {
if appCfg == nil || ma == nil || ag == nil {
return nil, fmt.Errorf("multiagent: 配置或 Agent 为空")
@@ -339,6 +341,7 @@ func RunDeepAgent(
return nil, err
}
orchInstruction = project.AppendSystemPromptBlock(orchInstruction, systemPromptExtra)
orchInstruction = injectToolNamesOnlyInstruction(ctx, orchInstruction, mainTools, mainToolSearchActive)
if logger != nil {
mainNames := collectToolNames(ctx, mainTools)
@@ -387,7 +390,8 @@ func RunDeepAgent(
// noNestedTaskMiddleware 必须在最外层(最先拦截),防止 skill 或其他中间件内部触发 task 调用绕过检测。
deepHandlers := []adk.ChatModelAgentMiddleware{newNoNestedTaskMiddleware()}
if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunes); mw != nil {
taskEnrichExtra := systemPromptExtra
if mw := newTaskContextEnrichMiddleware(userMessage, history, ma.SubAgentUserContextMaxRunes, taskEnrichExtra); mw != nil {
deepHandlers = append(deepHandlers, mw)
}
if len(mainOrchestratorPre) > 0 {
@@ -538,7 +542,7 @@ func RunDeepAgent(
}
baseMsgs := historyToMessages(history, appCfg, &ma.EinoMiddleware)
baseMsgs = append(baseMsgs, schema.UserMessage(userMessage))
baseMsgs = appendUserMessageIfNeeded(baseMsgs, userMessage)
streamsMainAssistant := func(agent string) bool {
if orchMode == "plan_execute" {
@@ -566,6 +570,8 @@ func RunDeepAgent(
StreamsMainAssistant: streamsMainAssistant,
EinoRoleTag: einoRoleTag,
CheckpointDir: ma.EinoMiddleware.CheckpointDir,
RunRetryMaxAttempts: ma.EinoMiddleware.RunRetryMaxAttempts,
RunRetryMaxBackoffSec: ma.EinoMiddleware.RunRetryMaxBackoffSec,
McpIDsMu: &mcpIDsMu,
McpIDs: &mcpIDs,
FilesystemMonitorAgent: ag,
@@ -595,6 +601,13 @@ func chatToolCallsToSchema(tcs []agent.ToolCall) []schema.ToolCall {
argsStr = string(b)
}
}
// Some OpenAI-compatible gateways require `function.arguments` to exist
// on every assistant tool_call message. When args are empty, omitempty may
// drop the field during serialization and cause "missing field arguments"
// on the next turn history replay.
if strings.TrimSpace(argsStr) == "" {
argsStr = "{}"
}
typ := tc.Type
if typ == "" {
typ = "function"
+8 -1
View File
@@ -30,8 +30,15 @@ type taskContextEnrichMiddleware struct {
// newTaskContextEnrichMiddleware returns a middleware that enriches task
// descriptions with user conversation context. Returns nil if disabled
// (maxRunes < 0) or no user messages exist.
func newTaskContextEnrichMiddleware(userMessage string, history []agent.ChatMessage, maxRunes int) adk.ChatModelAgentMiddleware {
func newTaskContextEnrichMiddleware(userMessage string, history []agent.ChatMessage, maxRunes int, projectBlackboard string) adk.ChatModelAgentMiddleware {
supplement := buildUserContextSupplement(userMessage, history, maxRunes)
if bb := strings.TrimSpace(projectBlackboard); bb != "" {
if supplement != "" {
supplement += "\n\n## 项目黑板索引\n" + bb
} else {
supplement = "\n\n## 项目黑板索引\n" + bb
}
}
if supplement == "" {
return nil
}
@@ -105,6 +105,7 @@ func TestTaskContextEnrichMiddleware_EnrichesTaskDescription(t *testing.T) {
"继续测试",
[]agent.ChatMessage{{Role: "user", Content: "http://8.163.32.73:8081 pikachu靶场"}},
0,
"",
)
if mw == nil {
t.Fatal("expected non-nil middleware")
@@ -149,7 +150,7 @@ func TestTaskContextEnrichMiddleware_EnrichesTaskDescription(t *testing.T) {
}
func TestTaskContextEnrichMiddleware_IgnoresNonTaskTools(t *testing.T) {
mw := newTaskContextEnrichMiddleware("test", nil, 0)
mw := newTaskContextEnrichMiddleware("test", nil, 0, "")
if mw == nil {
t.Fatal("expected non-nil middleware")
}
@@ -175,7 +176,7 @@ func TestTaskContextEnrichMiddleware_IgnoresNonTaskTools(t *testing.T) {
}
func TestTaskContextEnrichMiddleware_NilWhenDisabled(t *testing.T) {
mw := newTaskContextEnrichMiddleware("test", nil, -1)
mw := newTaskContextEnrichMiddleware("test", nil, -1, "")
if mw != nil {
t.Error("middleware should be nil when disabled")
}
+77
View File
@@ -0,0 +1,77 @@
package project
import (
"fmt"
"sort"
"strings"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
)
// AppendSystemPromptBlock 将附加块追加到 system prompt。
func AppendSystemPromptBlock(base, block string) string {
base = strings.TrimSpace(base)
block = strings.TrimSpace(block)
if block == "" {
return base
}
if base == "" {
return block
}
return base + "\n\n" + block
}
// BuildFactIndexBlock 为 Agent 系统提示生成项目黑板索引(仅 key + summary,不含 body)。
func BuildFactIndexBlock(db *database.DB, projectID string, cfg config.ProjectConfig) (string, error) {
if db == nil || !cfg.Enabled {
return "", nil
}
projectID = strings.TrimSpace(projectID)
if projectID == "" {
return "", nil
}
proj, err := db.GetProject(projectID)
if err != nil {
return "", err
}
facts, err := db.ListProjectFactsForIndex(projectID, cfg.DefaultInjectDeprecated)
if err != nil {
return "", err
}
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
}
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)
})
maxRunes := cfg.FactIndexMaxRunesEffective()
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)
lineRunes := len([]rune(line))
if used+lineRunes > maxRunes {
omitted++
continue
}
b.WriteString(line)
used += lineRunes
}
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")
return b.String(), nil
}
+9 -4
View File
@@ -149,13 +149,18 @@ func effectiveEffort(sr *config.OpenAIReasoningConfig, client *ClientIntent, all
func normalizeEffort(s string) string {
e := strings.ToLower(strings.TrimSpace(s))
switch e {
case "low", "medium", "high", "max":
case "low", "medium", "high", "max", "xhigh":
return e
default:
return ""
}
}
// usesExtraFieldsReasoningEffort 为 Eino 无枚举的最高档 effort,经 ExtraFields 原样下发(max / xhigh 由网关自行识别,不做互转)。
func usesExtraFieldsReasoningEffort(e string) bool {
return e == "max" || e == "xhigh"
}
func resolveWireProfile(oa *config.OpenAIConfig, sr *config.OpenAIReasoningConfig) wireProfile {
if strings.EqualFold(strings.TrimSpace(oa.Provider), "claude") {
return wireClaude
@@ -210,11 +215,11 @@ func applyOpenAICompat(cfg *einoopenai.ChatModelConfig, mode, effort string) {
if e == "" {
return
}
if e == "max" {
if usesExtraFieldsReasoningEffort(e) {
if cfg.ExtraFields == nil {
cfg.ExtraFields = make(map[string]any)
}
cfg.ExtraFields["reasoning_effort"] = "max"
cfg.ExtraFields["reasoning_effort"] = effortStringForAPI(e)
return
}
switch e {
@@ -245,6 +250,6 @@ func applyOutputConfigEffort(cfg *einoopenai.ChatModelConfig, mode, effort strin
}
func effortStringForAPI(e string) string {
// Gateways expect lowercase strings; "max" kept as max.
// 原样透传:OpenAI 官方多为 xhigh,部分兼容网关为 max,由配置/对话 effort 选择。
return strings.ToLower(strings.TrimSpace(e))
}
+66
View File
@@ -0,0 +1,66 @@
package reasoning
import (
"testing"
"cyberstrike-ai/internal/config"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
)
func TestEffortStringForAPI_passthrough(t *testing.T) {
cases := map[string]string{
"max": "max",
"xhigh": "xhigh",
"HIGH": "high",
"Medium": "medium",
}
for in, want := range cases {
if got := effortStringForAPI(in); got != want {
t.Fatalf("%q -> %q, want %q", in, got, want)
}
}
}
func TestNormalizeEffort_maxAndXhigh(t *testing.T) {
if normalizeEffort("xhigh") != "xhigh" {
t.Fatal("xhigh not accepted")
}
if normalizeEffort("max") != "max" {
t.Fatal("max not accepted")
}
}
func TestApplyOpenAICompat_xhighExtraField(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Reasoning: config.OpenAIReasoningConfig{
Profile: "openai_compat",
Mode: "on",
Effort: "xhigh",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
if cfg.ExtraFields == nil {
t.Fatal("expected ExtraFields")
}
if got, _ := cfg.ExtraFields["reasoning_effort"].(string); got != "xhigh" {
t.Fatalf("reasoning_effort=%q", got)
}
}
func TestApplyOpenAICompat_maxPassthrough(t *testing.T) {
cfg := &einoopenai.ChatModelConfig{}
oa := &config.OpenAIConfig{
Reasoning: config.OpenAIReasoningConfig{
Profile: "openai_compat",
Mode: "on",
Effort: "max",
},
}
ApplyToEinoChatModelConfig(cfg, oa, nil)
got, _ := cfg.ExtraFields["reasoning_effort"].(string)
if got != "max" {
t.Fatalf("max effort wire=%q, want max", got)
}
}
File diff suppressed because it is too large Load Diff
+8
View File
@@ -1588,6 +1588,11 @@
"detailVulnId": "Vuln ID",
"detailType": "Type",
"detailTarget": "Target",
"detailProject": "Project",
"projectUnbound": "No project",
"projectBindHint": "Once bound, agents can list this finding under the project scope.",
"projectBindFailed": "Failed to update project binding",
"projectBindOk": "Project binding updated",
"detailConversationId": "Conversation ID",
"detailTaskId": "Task ID",
"detailTaskQueueId": "Task queue ID",
@@ -2161,6 +2166,9 @@
"add": "Add"
},
"vulnerabilityModal": {
"project": "Project",
"projectNone": "(Unbound)",
"projectHint": "Bound findings appear in list_vulnerabilities for that project; leave empty to infer from the conversation when possible.",
"conversationId": "Conversation ID",
"conversationIdPlaceholder": "Enter conversation ID",
"conversationTag": "Conversation tag",
+8
View File
@@ -1577,6 +1577,11 @@
"detailVulnId": "漏洞ID",
"detailType": "类型",
"detailTarget": "目标",
"detailProject": "所属项目",
"projectUnbound": "未绑定项目",
"projectBindHint": "绑定后 Agent 可在项目范围内查询到该漏洞",
"projectBindFailed": "绑定项目失败",
"projectBindOk": "已更新项目绑定",
"detailConversationId": "会话ID",
"detailTaskId": "任务ID",
"detailTaskQueueId": "任务队列ID",
@@ -2150,6 +2155,9 @@
"add": "添加"
},
"vulnerabilityModal": {
"project": "所属项目",
"projectNone": "(未绑定)",
"projectHint": "绑定后 Agent 在项目范围内可通过 list_vulnerabilities 看到本条记录;留空则尝试从会话自动关联。",
"conversationId": "会话ID",
"conversationIdPlaceholder": "输入会话ID",
"conversationTag": "对话标签",
+7
View File
@@ -282,6 +282,13 @@ async function submitLogin(event) {
}
async function refreshAppData(showTaskErrors = false) {
if (typeof initChatAgentModeFromConfig === 'function') {
try {
await initChatAgentModeFromConfig();
} catch (error) {
console.warn('刷新对话模式配置失败:', error);
}
}
await Promise.allSettled([
loadConversations(),
loadActiveTasks(showTaskErrors),
+39 -6
View File
@@ -574,7 +574,7 @@ function restoreChatReasoningControlsFromStorage() {
}
if (e) {
const v = localStorage.getItem(REASONING_EFFORT_LS);
if (v !== null && ['', 'low', 'medium', 'high', 'max'].indexOf(v) !== -1) {
if (v !== null && ['', 'low', 'medium', 'high', 'max', 'xhigh'].indexOf(v) !== -1) {
e.value = v;
}
}
@@ -646,6 +646,9 @@ function toggleAgentModePanel() {
if (typeof closeRoleSelectionPanel === 'function') {
closeRoleSelectionPanel();
}
if (typeof closeChatProjectPanel === 'function') {
closeChatProjectPanel();
}
panel.style.display = 'flex';
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
@@ -662,6 +665,29 @@ function selectAgentMode(mode) {
}
async function initChatAgentModeFromConfig() {
const wrap = document.getElementById('agent-mode-wrapper');
const sel = document.getElementById('agent-mode-select');
if (!wrap || !sel) return;
// 先展示基础模式,避免首次登录时配置接口短暂失败导致入口被隐藏。
wrap.style.display = '';
let stored = localStorage.getItem(AGENT_MODE_STORAGE_KEY);
if (!(stored === CHAT_AGENT_MODE_REACT || chatAgentModeIsEinoSingle(stored) || chatAgentModeIsEino(stored))) {
stored = CHAT_AGENT_MODE_REACT;
}
sel.value = stored;
syncAgentModeFromValue(stored);
document.querySelectorAll('.agent-mode-option').forEach(function (el) {
const v = el.getAttribute('data-value');
if (v === 'deep' || v === 'plan_execute' || v === 'supervisor') {
el.style.display = 'none';
} else {
el.style.display = '';
}
});
restoreChatReasoningControlsFromStorage();
syncReasoningRowVisibility(stored);
try {
const r = await apiFetch('/api/config');
if (!r.ok) return;
@@ -674,10 +700,6 @@ async function initChatAgentModeFromConfig() {
window.csaiHitlGlobalToolWhitelist = tw.slice();
}
}
const wrap = document.getElementById('agent-mode-wrapper');
const sel = document.getElementById('agent-mode-select');
if (!wrap || !sel) return;
wrap.style.display = '';
document.querySelectorAll('.agent-mode-option').forEach(function (el) {
const v = el.getAttribute('data-value');
if (v === 'deep' || v === 'plan_execute' || v === 'supervisor') {
@@ -686,7 +708,6 @@ async function initChatAgentModeFromConfig() {
el.style.display = '';
}
});
let stored = localStorage.getItem(AGENT_MODE_STORAGE_KEY);
stored = chatAgentModeNormalizeStored(stored, cfg);
try {
localStorage.setItem(AGENT_MODE_STORAGE_KEY, stored);
@@ -879,6 +900,10 @@ async function sendMessage() {
conversationId: currentConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
if (!currentConversationId && typeof getActiveProjectId === 'function') {
const pid = getActiveProjectId();
if (pid) body.projectId = pid;
}
const hitlCfg = readHitlConfigFromForm();
if (normalizeHitlMode(hitlCfg.mode) !== HITL_MODE_OFF) {
const sensitiveTools = hitlToolsSplitToArray(hitlCfg.sensitiveTools || '');
@@ -2882,10 +2907,14 @@ async function startNewConversation() {
}
currentConversationId = null;
window._loadedConversationProjectId = '';
try {
window.currentConversationId = '';
} catch (e) { /* ignore */ }
currentConversationGroupId = null; // 新对话不属于任何分组
if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector();
}
document.getElementById('chat-messages').innerHTML = '';
const readyMsgNew = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
addMessage('assistant', readyMsgNew, null, null, null, { systemReadyMessage: true });
@@ -3140,9 +3169,13 @@ async function loadConversation(conversationId) {
// 更新当前对话ID
currentConversationId = conversationId;
window._loadedConversationProjectId = conversation.projectId || conversation.project_id || '';
try {
window.currentConversationId = conversationId;
} catch (e) { /* ignore */ }
if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector();
}
if (typeof window.syncHitlConfigFromServer === 'function') {
await window.syncHitlConfigFromServer(conversationId);
} else {
+6 -1
View File
@@ -1028,7 +1028,12 @@ async function batchScanSelectedFofaRows() {
const resp = await apiFetch('/api/batch-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, tasks, role })
body: JSON.stringify({
title,
tasks,
role,
projectId: typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '',
})
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok) {
+907
View File
@@ -0,0 +1,907 @@
/**
* 项目管理与事实黑板
*/
let projectsCache = [];
let projectsCacheAll = [];
let currentProjectId = null;
let currentProjectTab = 'facts';
const projectNameById = {};
let _projectsListReady = false;
let _projectsFetchPromise = null;
const PROJECT_ACTIVE_KEY = 'cyberstrike.activeProjectId';
function getActiveProjectId() {
try {
return localStorage.getItem(PROJECT_ACTIVE_KEY) || '';
} catch (e) {
return '';
}
}
function setActiveProjectId(id) {
try {
if (id) localStorage.setItem(PROJECT_ACTIVE_KEY, id);
else localStorage.removeItem(PROJECT_ACTIVE_KEY);
} catch (e) { /* ignore */ }
}
function rebuildProjectNameMap(list) {
Object.keys(projectNameById).forEach((k) => delete projectNameById[k]);
(list || []).forEach((p) => {
if (p && p.id) projectNameById[p.id] = p.name || p.id;
});
}
async function fetchProjectsList(includeArchived) {
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
const url = showArchived ? '/api/projects?limit=200' : '/api/projects?status=active&limit=200';
const res = await apiFetch(url);
if (!res.ok) throw new Error('加载项目失败');
const data = await res.json();
projectsCache = Array.isArray(data) ? data : [];
rebuildProjectNameMap(projectsCache);
_projectsListReady = true;
return projectsCache;
}
/** 对话页等项目选择器:确保列表已拉取(去重并发请求) */
async function ensureProjectsLoaded(force) {
if (!force && _projectsListReady) return projectsCache;
if (!force && _projectsFetchPromise) return _projectsFetchPromise;
_projectsFetchPromise = fetchProjectsList(false)
.catch((e) => {
_projectsListReady = false;
throw e;
})
.finally(() => {
_projectsFetchPromise = null;
});
return _projectsFetchPromise;
}
function prefetchProjectsForChat() {
ensureProjectsLoaded().catch(() => {});
}
function getProjectName(id) {
return projectNameById[id] || id || '';
}
function initProjectsModalEscape() {
if (window._projectsModalEscapeBound) return;
window._projectsModalEscapeBound = true;
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (document.getElementById('project-modal')?.style.display === 'flex') closeProjectModal();
else if (document.getElementById('fact-modal')?.style.display === 'flex') closeFactModal();
else if (document.getElementById('fact-detail-modal')?.style.display === 'flex') closeFactDetailModal();
});
}
async function initProjectsPage() {
const page = document.getElementById('page-projects');
if (!page || page.style.display === 'none') return;
initProjectsModalEscape();
updateProjectsDetailVisibility();
await loadProjectsList();
if (!currentProjectId && projectsCache.length) {
const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id');
currentProjectId = fromHash || projectsCache[0].id;
}
renderProjectsSidebar();
if (currentProjectId) {
await selectProject(currentProjectId);
}
}
async function loadProjectsList() {
await fetchProjectsList();
renderProjectsSidebar();
if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector();
}
if (typeof refreshVulnerabilityProjectFilter === 'function') {
refreshVulnerabilityProjectFilter();
}
}
function projectInitial(name) {
const s = (name || 'P').trim();
return s ? s.charAt(0).toUpperCase() : 'P';
}
function updateProjectsDetailVisibility() {
const main = document.getElementById('projects-detail-main');
const placeholder = document.getElementById('projects-detail-placeholder');
const inner = document.getElementById('projects-detail-inner');
const show = !!currentProjectId;
if (main) main.classList.toggle('has-project', show);
if (placeholder) placeholder.hidden = show;
if (inner) inner.hidden = !show;
}
function updateProjectsListCount() {
const el = document.getElementById('projects-list-count');
if (el) el.textContent = String(projectsCache.length);
}
function formatConfidenceBadge(confidence) {
const c = (confidence || '').toLowerCase();
let cls = 'projects-confidence--tentative';
let label = c || '—';
if (c === 'confirmed') {
cls = 'projects-confidence--confirmed';
label = '已确认';
} else if (c === 'deprecated') {
cls = 'projects-confidence--deprecated';
label = '已废弃';
} else if (c === 'tentative') {
label = '待确认';
}
return `<span class="projects-confidence ${cls}">${escapeHtml(label)}</span>`;
}
function renderProjectFactActions(keyEsc, idEsc) {
return `<div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--edit" data-fact-key="${keyEsc}" onclick="showEditFactModal(this.dataset.factKey)" title="编辑各字段">编辑</button>
<button type="button" class="projects-action-btn projects-action-btn--view" data-fact-key="${keyEsc}" onclick="viewProjectFactBody(this.dataset.factKey)" title="查看完整 body">详情</button>
<button type="button" class="projects-action-btn projects-action-btn--mute" data-fact-key="${keyEsc}" onclick="deprecateProjectFactByKey(this.dataset.factKey)" title="标记为已废弃">废弃</button>
<button type="button" class="projects-action-btn projects-action-btn--danger" data-fact-id="${idEsc}" onclick="deleteProjectFact(this.dataset.factId)" title="永久删除">删除</button>
</div>`;
}
function formatSeverityBadge(severity) {
const s = (severity || 'info').toLowerCase();
const cls = 'projects-severity--' + (['critical', 'high', 'medium', 'low', 'info'].includes(s) ? s : 'info');
return `<span class="projects-severity ${cls}">${escapeHtml(severity || '—')}</span>`;
}
function getProjectsListFilter() {
return (document.getElementById('projects-list-search')?.value || '').trim().toLowerCase();
}
function filterProjectsList() {
renderProjectsSidebar();
}
function renderProjectsSidebar() {
const el = document.getElementById('projects-list');
if (!el) return;
updateProjectsListCount();
const q = getProjectsListFilter();
const list = q
? projectsCache.filter((p) => (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q))
: projectsCache;
if (!projectsCache.length) {
el.innerHTML =
'<div class="projects-empty">暂无项目<br><button type="button" class="btn-primary btn-small projects-empty-btn" onclick="showNewProjectModal()">新建项目</button></div>';
updateProjectsDetailVisibility();
return;
}
if (!list.length) {
el.innerHTML = '<div class="projects-empty">无匹配项目</div>';
updateProjectsDetailVisibility();
return;
}
el.innerHTML = list.map((p) => {
const active = p.id === currentProjectId ? ' is-active' : '';
const archived = p.status === 'archived' ? ' is-archived' : '';
const badges = [
p.pinned ? '<span class="projects-list-item-badge">置顶</span>' : '',
p.status === 'archived' ? '<span class="projects-list-item-badge">归档</span>' : '',
].join('');
return `<div class="projects-list-item${active}${archived}" data-id="${escapeHtml(p.id)}" onclick="selectProject('${escapeHtml(p.id)}')">
<div class="projects-list-item-body">
<div class="projects-list-item-name">${escapeHtml(p.name)}${badges}</div>
<div class="projects-list-item-meta">${formatProjectTime(p.updated_at)}</div>
</div>
</div>`;
}).join('');
updateProjectsDetailVisibility();
}
function updateProjectStatusPill(status) {
const el = document.getElementById('projects-detail-status');
if (!el) return;
const archived = status === 'archived';
el.textContent = archived ? '已归档' : '进行中';
el.className = 'projects-status-pill ' + (archived ? 'projects-status-pill--archived' : 'projects-status-pill--active');
}
function updateProjectStats(factCount, vulnCount) {
const f = document.getElementById('project-stat-facts');
const v = document.getElementById('project-stat-vulns');
if (f) f.textContent = `${factCount ?? 0} 条事实`;
if (v) v.textContent = `${vulnCount ?? 0} 个漏洞`;
}
async function selectProject(id) {
currentProjectId = id;
renderProjectsSidebar();
updateProjectsDetailVisibility();
try {
const res = await apiFetch(`/api/projects/${id}`);
if (!res.ok) throw new Error('项目不存在');
const p = await res.json();
const titleEl = document.getElementById('projects-detail-title');
if (titleEl) titleEl.textContent = p.name || '项目';
document.getElementById('project-edit-name').value = p.name || '';
document.getElementById('project-edit-description').value = p.description || '';
document.getElementById('project-edit-scope').value = p.scope_json || '';
const statusEl = document.getElementById('project-edit-status');
if (statusEl) statusEl.value = p.status || 'active';
updateProjectStatusPill(p.status || 'active');
const metaEl = document.getElementById('projects-detail-meta');
if (metaEl) metaEl.textContent = `更新于 ${formatProjectTime(p.updated_at)}`;
const descEl = document.getElementById('projects-detail-desc');
if (descEl) {
const desc = (p.description || '').trim();
if (desc) {
descEl.textContent = desc;
descEl.hidden = false;
} else {
descEl.textContent = '';
descEl.hidden = true;
}
}
projectNameById[p.id] = p.name || p.id;
} catch (e) {
console.warn(e);
}
refreshProjectHeaderStats();
switchProjectTab(currentProjectTab);
}
function switchProjectTab(tab) {
currentProjectTab = tab;
['facts', '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 === 'vulns') loadProjectVulnerabilities();
}
async function loadProjectFacts() {
const tbody = document.getElementById('project-facts-tbody');
if (!tbody || !currentProjectId) return;
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="6">加载中…</td></tr>';
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?limit=200`);
if (!res.ok) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="6">加载失败</td></tr>';
return;
}
const facts = await res.json();
if (!facts.length) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="6">暂无事实,点击「添加事实」或由 Agent 自动写入</td></tr>';
refreshProjectHeaderStats();
return;
}
tbody.innerHTML = facts.map((f) => {
const keyEsc = escapeHtml(f.fact_key);
const idEsc = escapeHtml(f.id);
return `<tr>
<td><code>${keyEsc}</code></td>
<td>${escapeHtml(f.category)}</td>
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
<td>${formatConfidenceBadge(f.confidence)}</td>
<td>${formatProjectTime(f.updated_at, f.created_at)}</td>
<td class="col-actions">${renderProjectFactActions(keyEsc, idEsc)}</td>
</tr>`;
}).join('');
refreshProjectHeaderStats();
}
async function refreshProjectHeaderStats() {
if (!currentProjectId) return;
try {
const [factsRes, vulnRes] = await Promise.all([
apiFetch(`/api/projects/${currentProjectId}/facts?limit=500`),
apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=100`),
]);
let fc = 0;
let vc = 0;
if (factsRes.ok) {
const f = await factsRes.json();
fc = Array.isArray(f) ? f.length : 0;
}
if (vulnRes.ok) {
const d = await vulnRes.json();
const items = d.Vulnerabilities || d.vulnerabilities || d.items || [];
vc = items.length;
}
updateProjectStats(fc, vc);
} catch (e) {
console.warn(e);
}
}
let _factDetailKey = null;
async function viewProjectFactBody(factKey) {
const res = await apiFetch(`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`);
if (!res.ok) return alert('加载失败');
const f = await res.json();
_factDetailKey = f.fact_key;
document.getElementById('fact-detail-title').textContent = `[${f.fact_key}]`;
document.getElementById('fact-detail-meta').textContent =
`分类: ${f.category} · 置信度: ${f.confidence} · 更新: ${formatProjectTime(f.updated_at, f.created_at)}` +
(f.related_vulnerability_id ? ` · 关联漏洞: ${f.related_vulnerability_id}` : '');
document.getElementById('fact-detail-body').textContent = f.body || '(无 body)';
openProjectsOverlay('fact-detail-modal');
}
function editFactFromDetail() {
const key = _factDetailKey;
closeFactDetailModal();
if (key) showEditFactModal(key);
}
function closeFactDetailModal() {
closeProjectsOverlay('fact-detail-modal');
_factDetailKey = null;
}
async function deprecateProjectFactByKey(factKey) {
if (!confirm(`将事实 ${factKey} 标记为 deprecated`)) return;
const res = await apiFetch(`/api/projects/${currentProjectId}/facts/deprecate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fact_key: factKey }),
});
if (!res.ok) return alert('操作失败');
loadProjectFacts();
}
function openVulnerabilitiesForProject(projectId) {
const pid = projectId || currentProjectId;
if (!pid) return;
if (typeof switchPage === 'function') {
switchPage('vulnerabilities');
}
if (typeof window.setVulnerabilityProjectFilter === 'function') {
window.setVulnerabilityProjectFilter(pid);
} else {
window.location.hash = `vulnerabilities?project_id=${encodeURIComponent(pid)}`;
}
}
async function loadProjectVulnerabilities() {
const tbody = document.getElementById('project-vulns-tbody');
if (!tbody || !currentProjectId) return;
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="4">加载中…</td></tr>';
const res = await apiFetch(`/api/vulnerabilities?project_id=${encodeURIComponent(currentProjectId)}&limit=100`);
if (!res.ok) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="4">加载失败</td></tr>';
return;
}
const data = await res.json();
const items = data.Vulnerabilities || data.vulnerabilities || data.items || [];
if (!items.length) {
tbody.innerHTML = '<tr class="is-empty-row"><td colspan="4">本项目暂无漏洞记录</td></tr>';
refreshProjectHeaderStats();
return;
}
tbody.innerHTML = items.map((v) => {
const idEsc = escapeHtml(v.id);
return `<tr>
<td class="cell-summary" title="${escapeHtml(v.title)}">${escapeHtml(v.title)}</td>
<td>${formatSeverityBadge(v.severity)}</td>
<td>${escapeHtml(v.status)}</td>
<td class="col-actions">
<div class="projects-table-actions">
<button type="button" class="projects-action-btn projects-action-btn--view" data-vuln-id="${idEsc}" onclick="openVulnerabilityDetail(this.dataset.vulnId)">查看</button>
</div>
</td>
</tr>`;
}).join('');
refreshProjectHeaderStats();
}
function openVulnerabilityDetail(vulnId) {
openVulnerabilitiesForProject(currentProjectId);
if (typeof window.setVulnerabilityIdFilter === 'function') {
setTimeout(() => window.setVulnerabilityIdFilter(vulnId), 300);
}
}
function openProjectsOverlay(id) {
const el = document.getElementById(id);
if (!el) return;
el.style.display = 'flex';
document.body.classList.add('projects-modal-open');
const focusTarget = el.querySelector('input.form-input, textarea.form-input, select.form-input');
if (focusTarget) {
setTimeout(() => focusTarget.focus(), 80);
}
}
function closeProjectsOverlay(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
const anyOpen = document.querySelector('.projects-modal-overlay[style*="flex"]');
if (!anyOpen) document.body.classList.remove('projects-modal-open');
}
function showNewProjectModal() {
document.getElementById('project-modal-title').textContent = '新建项目';
const sub = document.getElementById('project-modal-subtitle');
if (sub) sub.textContent = '创建后可绑定对话,跨会话共享事实黑板';
const submitBtn = document.getElementById('project-modal-submit-btn');
if (submitBtn) submitBtn.textContent = '创建项目';
document.getElementById('project-modal-name').value = '';
document.getElementById('project-modal-description').value = '';
window._projectModalEditId = null;
openProjectsOverlay('project-modal');
}
async function saveProjectModal() {
const name = document.getElementById('project-modal-name').value.trim();
if (!name) return alert('请输入项目名称');
const body = {
name,
description: document.getElementById('project-modal-description').value.trim(),
};
const editId = window._projectModalEditId;
const res = editId
? await apiFetch(`/api/projects/${editId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
: await apiFetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error || '保存失败');
return;
}
closeProjectModal();
const saved = await res.json();
await loadProjectsList();
if (saved.id) await selectProject(saved.id);
}
function closeProjectModal() {
closeProjectsOverlay('project-modal');
}
function formatProjectScopeJson() {
const el = document.getElementById('project-edit-scope');
if (!el) return;
const raw = el.value.trim();
if (!raw) return;
try {
el.value = JSON.stringify(JSON.parse(raw), null, 2);
} catch (e) {
alert('JSON 格式无效:' + (e.message || String(e)));
}
}
function insertProjectScopeExample() {
const el = document.getElementById('project-edit-scope');
if (!el) return;
const example = {
targets: ['https://example.com'],
exclude: ['*.cdn.example.com'],
notes: '仅授权 Web 应用层测试',
};
el.value = JSON.stringify(example, null, 2);
el.focus();
}
async function saveProjectSettings() {
if (!currentProjectId) return;
const scopeRaw = document.getElementById('project-edit-scope').value.trim();
if (scopeRaw) {
try {
JSON.parse(scopeRaw);
} catch (e) {
alert('测试范围 JSON 无效,请先修正或点击「格式化」:' + (e.message || String(e)));
return;
}
}
const body = {
name: document.getElementById('project-edit-name').value.trim(),
description: document.getElementById('project-edit-description').value.trim(),
scope_json: scopeRaw,
status: document.getElementById('project-edit-status')?.value || 'active',
};
const res = await apiFetch(`/api/projects/${currentProjectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) return alert('保存失败');
await loadProjectsList();
await selectProject(currentProjectId);
alert('已保存');
}
async function archiveCurrentProject() {
if (!currentProjectId) return;
const statusEl = document.getElementById('project-edit-status');
const cur = statusEl?.value || 'active';
const next = cur === 'archived' ? 'active' : 'archived';
if (!confirm(next === 'archived' ? '归档后默认不再出现在活跃列表,是否继续?' : '恢复为 active')) return;
const res = await apiFetch(`/api/projects/${currentProjectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: next }),
});
if (!res.ok) return alert('操作失败');
await loadProjectsList();
await selectProject(currentProjectId);
}
async function deleteCurrentProject() {
if (!currentProjectId || !confirm('确定删除该项目?事实将一并删除,对话将解除绑定。')) return;
const deletedId = currentProjectId;
const deletedIndex = projectsCache.findIndex((p) => p.id === deletedId);
const res = await apiFetch(`/api/projects/${deletedId}`, { method: 'DELETE' });
if (!res.ok) return alert('删除失败');
if (getActiveProjectId() === deletedId) setActiveProjectId('');
currentProjectId = null;
await loadProjectsList();
if (projectsCache.length) {
const nextIndex = Math.min(deletedIndex >= 0 ? deletedIndex : 0, projectsCache.length - 1);
await selectProject(projectsCache[nextIndex].id);
} else {
updateProjectsDetailVisibility();
}
}
function resetFactModalForm() {
window._factModalEditId = null;
const keyEl = document.getElementById('fact-modal-key');
if (keyEl) keyEl.disabled = false;
document.getElementById('fact-modal-title').textContent = '添加事实';
document.getElementById('fact-modal-submit-btn').textContent = '保存事实';
document.getElementById('fact-modal-key').value = '';
document.getElementById('fact-modal-category').value = 'note';
document.getElementById('fact-modal-summary').value = '';
document.getElementById('fact-modal-body').value = '';
document.getElementById('fact-modal-confidence').value = 'tentative';
const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = '';
}
function fillFactModalForm(f) {
window._factModalEditId = f.id;
document.getElementById('fact-modal-title').textContent = '编辑事实';
document.getElementById('fact-modal-submit-btn').textContent = '保存修改';
document.getElementById('fact-modal-key').value = f.fact_key || '';
document.getElementById('fact-modal-category').value = f.category || 'note';
document.getElementById('fact-modal-summary').value = f.summary || '';
document.getElementById('fact-modal-body').value = f.body || '';
const conf = (f.confidence || 'tentative').toLowerCase();
const confEl = document.getElementById('fact-modal-confidence');
if (confEl) {
const allowed = ['tentative', 'confirmed', 'deprecated'];
confEl.value = allowed.includes(conf) ? conf : 'tentative';
}
const rel = document.getElementById('fact-modal-related-vuln');
if (rel) rel.value = f.related_vulnerability_id || '';
}
function showAddFactModal() {
if (!currentProjectId) return alert('请先选择项目');
resetFactModalForm();
openProjectsOverlay('fact-modal');
}
async function showEditFactModal(factKey) {
if (!currentProjectId) return alert('请先选择项目');
const res = await apiFetch(
`/api/projects/${currentProjectId}/facts?fact_key=${encodeURIComponent(factKey)}`,
);
if (!res.ok) return alert('加载事实失败');
const f = await res.json();
resetFactModalForm();
fillFactModalForm(f);
openProjectsOverlay('fact-modal');
}
function closeFactModal() {
closeProjectsOverlay('fact-modal');
resetFactModalForm();
}
async function saveFactModal() {
const fact_key = document.getElementById('fact-modal-key').value.trim();
const summary = document.getElementById('fact-modal-summary').value.trim();
if (!fact_key || !summary) return alert('fact_key 与 summary 必填');
const payload = {
fact_key,
category: document.getElementById('fact-modal-category').value.trim() || 'note',
summary,
body: document.getElementById('fact-modal-body').value,
confidence: document.getElementById('fact-modal-confidence').value,
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
};
const editId = window._factModalEditId;
const res = editId
? await apiFetch(`/api/projects/${currentProjectId}/facts/${editId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
: await apiFetch(`/api/projects/${currentProjectId}/facts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
return alert(err.error || '保存失败');
}
closeFactModal();
loadProjectFacts();
}
async function deleteProjectFact(id) {
if (!confirm('删除该事实?')) return;
await apiFetch(`/api/projects/${currentProjectId}/facts/${id}`, { method: 'DELETE' });
loadProjectFacts();
}
function parseProjectDate(t) {
if (t == null || t === '') return null;
if (typeof t === 'number' && Number.isFinite(t)) {
const d = new Date(t);
return isNaN(d.getTime()) || d.getFullYear() < 2000 ? null : d;
}
let s = String(t).trim();
if (!s || s.startsWith('0001-01-01')) return null;
let d = new Date(s);
if (!isNaN(d.getTime()) && d.getFullYear() >= 2000) return d;
const m = s.match(
/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:([Zz]|([+-])(\d{2}):?(\d{2}))?)?$/,
);
if (m) {
const ms = m[7] ? parseInt(String(m[7]).slice(0, 3).padEnd(3, '0'), 10) : 0;
let offMin = 0;
if (m[8] && m[9] && m[10]) {
offMin = parseInt(m[10], 10) * 60 + parseInt(m[11] || '0', 10);
if (m[9] === '-') offMin = -offMin;
}
d = new Date(
Date.UTC(
parseInt(m[1], 10),
parseInt(m[2], 10) - 1,
parseInt(m[3], 10),
parseInt(m[4], 10),
parseInt(m[5], 10),
parseInt(m[6], 10),
ms,
) - offMin * 60 * 1000,
);
if (!isNaN(d.getTime()) && d.getFullYear() >= 2000) return d;
}
return null;
}
function formatProjectTime(t, fallback) {
const d = parseProjectDate(t) || (fallback != null ? parseProjectDate(fallback) : null);
if (!d) return '尚未更新';
const now = Date.now();
const diff = now - d.getTime();
if (diff < 60000) return '刚刚';
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`;
return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function getChatProjectSelection() {
const convId = window.currentConversationId;
if (convId) {
return window._loadedConversationProjectId || '';
}
return getActiveProjectId();
}
function updateChatProjectButtonLabel() {
const textEl = document.getElementById('chat-project-text');
if (!textEl) return;
const id = getChatProjectSelection();
textEl.textContent = id ? getProjectName(id) || id : '无项目';
}
function renderChatProjectPanelList() {
const list = document.getElementById('chat-project-list');
if (!list) return;
const selected = getChatProjectSelection();
const activeProjects = projectsCache.filter((p) => p.status !== 'archived');
const items = [{ id: '', name: '无项目', description: '不绑定项目黑板' }, ...activeProjects];
if (!items.length) {
list.innerHTML = '<div class="chat-project-panel-empty">暂无项目,可在「项目管理」中创建</div>';
return;
}
list.innerHTML = '';
items.forEach((p) => {
const isNone = !p.id;
const isSelected = isNone ? !selected : selected === p.id;
const desc = isNone
? (p.description || '')
: (p.description || '').trim().slice(0, 80) || '共享事实黑板';
const projectId = p.id || '';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
btn.setAttribute('role', 'option');
btn.onclick = () => {
selectChatProject(projectId);
};
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 renderChatProjectPanel() {
const list = document.getElementById('chat-project-list');
if (!list) return;
list.innerHTML = '<div class="chat-project-panel-loading">加载中…</div>';
try {
await ensureProjectsLoaded();
} catch (e) {
console.warn(e);
list.innerHTML = '<div class="chat-project-panel-empty">加载失败,请稍后重试</div>';
return;
}
renderChatProjectPanelList();
}
function closeChatProjectPanel() {
const panel = document.getElementById('chat-project-panel');
const btn = document.getElementById('chat-project-btn');
if (panel) panel.style.display = 'none';
if (btn) {
btn.classList.remove('active');
btn.setAttribute('aria-expanded', 'false');
}
}
async function toggleChatProjectPanel() {
const panel = document.getElementById('chat-project-panel');
const btn = document.getElementById('chat-project-btn');
if (!panel) return;
const isHidden = panel.style.display === 'none' || !panel.style.display;
if (!isHidden) {
closeChatProjectPanel();
return;
}
if (typeof closeRoleSelectionPanel === 'function') closeRoleSelectionPanel();
if (typeof closeAgentModePanel === 'function') closeAgentModePanel();
if (typeof closeChatReasoningPanel === 'function') closeChatReasoningPanel();
panel.style.display = 'flex';
if (btn) {
btn.classList.add('active');
btn.setAttribute('aria-expanded', 'true');
}
await renderChatProjectPanel();
}
async function selectChatProject(projectId) {
closeChatProjectPanel();
await applyChatProjectSelection(projectId || '');
}
async function applyChatProjectSelection(projectId) {
const prev = getChatProjectSelection();
if (projectId === prev) {
updateChatProjectButtonLabel();
return;
}
if (window.currentConversationId) {
try {
const res = await apiFetch(`/api/conversations/${encodeURIComponent(window.currentConversationId)}/project`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || res.statusText);
}
window._loadedConversationProjectId = projectId;
if (typeof showNotification === 'function') {
showNotification(projectId ? '已绑定项目' : '已解除项目绑定', 'success');
}
} catch (e) {
console.error(e);
alert('更新项目绑定失败: ' + (e.message || e));
updateChatProjectButtonLabel();
return;
}
} else {
setActiveProjectId(projectId);
}
updateChatProjectButtonLabel();
}
/** 对话页项目选择器:同步按钮文案;若浮层已打开则刷新列表 */
async function refreshChatProjectSelector() {
if (!document.getElementById('chat-project-btn')) return;
try {
await ensureProjectsLoaded();
} catch (e) {
console.warn(e);
}
updateChatProjectButtonLabel();
const panel = document.getElementById('chat-project-panel');
if (panel && panel.style.display === 'flex') {
renderChatProjectPanelList();
}
}
async function onChatProjectChange() {
/* 兼容旧调用;新 UI 使用 selectChatProject */
await applyChatProjectSelection(getChatProjectSelection());
}
function initChatProjectSelector() {
if (window._chatProjectSelectorInited) return;
window._chatProjectSelectorInited = true;
prefetchProjectsForChat();
updateChatProjectButtonLabel();
document.addEventListener('click', (e) => {
const panel = document.getElementById('chat-project-panel');
const wrapper = document.querySelector('.project-selector-wrapper');
if (!panel || panel.style.display === 'none' || !panel.style.display) return;
if (!wrapper?.contains(e.target)) {
closeChatProjectPanel();
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChatProjectSelector);
} else {
initChatProjectSelector();
}
window.initProjectsPage = initProjectsPage;
window.showNewProjectModal = showNewProjectModal;
window.saveProjectModal = saveProjectModal;
window.closeProjectModal = closeProjectModal;
window.selectProject = selectProject;
window.switchProjectTab = switchProjectTab;
window.showAddFactModal = showAddFactModal;
window.showEditFactModal = showEditFactModal;
window.editFactFromDetail = editFactFromDetail;
window.saveFactModal = saveFactModal;
window.closeFactModal = closeFactModal;
window.closeFactDetailModal = closeFactDetailModal;
window.saveProjectSettings = saveProjectSettings;
window.archiveCurrentProject = archiveCurrentProject;
window.deleteCurrentProject = deleteCurrentProject;
window.refreshChatProjectSelector = refreshChatProjectSelector;
window.onChatProjectChange = onChatProjectChange;
window.toggleChatProjectPanel = toggleChatProjectPanel;
window.closeChatProjectPanel = closeChatProjectPanel;
window.selectChatProject = selectChatProject;
window.prefetchProjectsForChat = prefetchProjectsForChat;
window.getActiveProjectId = getActiveProjectId;
window.getProjectName = getProjectName;
window.viewProjectFactBody = viewProjectFactBody;
window.deprecateProjectFactByKey = deprecateProjectFactByKey;
window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
window.openVulnerabilityDetail = openVulnerabilityDetail;
window.filterProjectsList = filterProjectsList;
window.rebuildProjectNameMap = rebuildProjectNameMap;
window.projectNameById = projectNameById;
+26 -15
View File
@@ -244,30 +244,46 @@ function selectRole(roleName) {
renderRoleSelectionSidebar(); // 重新渲染以更新选中状态
}
function getChatRoleSelectorWrapper() {
return document.getElementById('role-selector-wrapper')
|| document.getElementById('role-selector-btn')?.closest('.role-selector-wrapper:not(.project-selector-wrapper)');
}
function isRoleSelectionPanelOpen() {
const panel = document.getElementById('role-selection-panel');
if (!panel) return false;
return panel.style.display !== 'none' && panel.style.display !== '';
}
// 切换角色选择面板显示/隐藏
function toggleRoleSelectionPanel() {
const panel = document.getElementById('role-selection-panel');
const roleSelectorBtn = document.getElementById('role-selector-btn');
if (!panel) return;
const isHidden = panel.style.display === 'none' || !panel.style.display;
const isHidden = !isRoleSelectionPanelOpen();
if (isHidden) {
if (typeof closeAgentModePanel === 'function') {
closeAgentModePanel();
}
if (typeof closeChatProjectPanel === 'function') {
closeChatProjectPanel();
}
if (typeof closeChatReasoningPanel === 'function') {
closeChatReasoningPanel();
}
renderRoleSelectionSidebar();
panel.style.display = 'flex'; // 使用flex布局
// 添加打开状态的视觉反馈
if (roleSelectorBtn) {
roleSelectorBtn.classList.add('active');
roleSelectorBtn.setAttribute('aria-expanded', 'true');
}
// 确保面板渲染后再检查位置
setTimeout(() => {
const wrapper = document.querySelector('.role-selector-wrapper');
const wrapper = getChatRoleSelectorWrapper();
if (wrapper) {
const rect = wrapper.getBoundingClientRect();
const panelHeight = panel.offsetHeight || 400;
@@ -281,11 +297,7 @@ function toggleRoleSelectionPanel() {
}
}, 10);
} else {
panel.style.display = 'none';
// 移除打开状态的视觉反馈
if (roleSelectorBtn) {
roleSelectorBtn.classList.remove('active');
}
closeRoleSelectionPanel();
}
}
@@ -298,6 +310,7 @@ function closeRoleSelectionPanel() {
}
if (roleSelectorBtn) {
roleSelectorBtn.classList.remove('active');
roleSelectorBtn.setAttribute('aria-expanded', 'false');
}
}
@@ -1568,9 +1581,9 @@ async function deleteRole(roleName) {
}
// 在页面切换时初始化角色列表
if (typeof switchPage === 'function') {
const originalSwitchPage = switchPage;
switchPage = function(page) {
if (typeof window.switchPage === 'function') {
const originalSwitchPage = window.switchPage;
window.switchPage = function(page) {
originalSwitchPage(page);
if (page === 'roles-management') {
loadRoles().then(() => renderRolesList());
@@ -1590,11 +1603,9 @@ document.addEventListener('click', (e) => {
closeRoleModal();
}
// 点击角色选择面板外部关闭面板(但不包括角色选择按钮和面板本身
const roleSelectionPanel = document.getElementById('role-selection-panel');
const roleSelectorWrapper = document.querySelector('.role-selector-wrapper');
if (roleSelectionPanel && roleSelectionPanel.style.display !== 'none' && roleSelectionPanel.style.display) {
// 检查点击是否在面板或包装器上
// 点击角色选择面板外部关闭(须用 #role-selector-wrapper,勿用 .role-selector-wrapper:项目选择器也带该类
if (isRoleSelectionPanelOpen()) {
const roleSelectorWrapper = getChatRoleSelectorWrapper();
if (!roleSelectorWrapper?.contains(e.target)) {
closeRoleSelectionPanel();
}
+70 -7
View File
@@ -25,6 +25,13 @@ function scheduleChatConversationFromHash(delayMs) {
}
const params = new URLSearchParams(hashParts.slice(1).join('?'));
const conversationId = params.get('conversation');
const projectId = params.get('project');
if (projectId && typeof setActiveProjectId === 'function') {
setActiveProjectId(projectId);
if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector();
}
}
if (!conversationId) {
return;
}
@@ -50,7 +57,7 @@ function initRouter() {
if (hash) {
const hashParts = hash.split('?');
const pageId = hashParts[0];
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(500);
@@ -187,6 +194,24 @@ function updateNavState(pageId) {
}
}
/** 读取侧栏子菜单项(仅 .nav-submenu 内,避免误匹配) */
function getNavSubmenuItems(navItem) {
if (!navItem) return [];
const submenu = navItem.querySelector('.nav-submenu');
if (!submenu) return [];
return Array.from(submenu.querySelectorAll('.nav-submenu-item'));
}
/** 仅一个子页时直接进入,避免展开后菜单在侧栏底部不可见 */
function navigateSingleSubmenuPage(navItem) {
const items = getNavSubmenuItems(navItem);
if (items.length !== 1) return false;
const pageId = items[0].getAttribute('data-page');
if (!pageId) return false;
switchPage(pageId);
return true;
}
// 切换子菜单
function toggleSubmenu(menuId) {
const sidebar = document.getElementById('main-sidebar');
@@ -194,24 +219,50 @@ function toggleSubmenu(menuId) {
if (!navItem) return;
const collapsed = sidebar && sidebar.classList.contains('collapsed');
// 检查侧边栏是否折叠
if (sidebar && sidebar.classList.contains('collapsed')) {
if (collapsed) {
// 折叠状态下显示弹出菜单
showSubmenuPopup(navItem, menuId);
} else {
// 展开状态下正常切换子菜单
navItem.classList.toggle('expanded');
return;
}
// 展开侧栏且仅一个子项(角色、Agents 等):单击直接进入,无需再点二级菜单
if (navigateSingleSubmenuPage(navItem)) {
return;
}
// 展开状态下切换子菜单,并滚入视口以便看到子项
const willExpand = !navItem.classList.contains('expanded');
navItem.classList.toggle('expanded');
if (willExpand) {
requestAnimationFrame(() => {
navItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
const items = getNavSubmenuItems(navItem);
const last = items[items.length - 1];
if (last) {
last.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
});
}
}
window.toggleSubmenu = toggleSubmenu;
// 显示子菜单弹出框
function showSubmenuPopup(navItem, menuId) {
// 移除其他已打开的弹出菜单
const existingPopup = document.querySelector('.submenu-popup');
if (existingPopup) {
const sameMenu = existingPopup.dataset.menuId === menuId;
existingPopup.remove();
return; // 如果已经打开,点击时关闭
// 再次点击同一项:仅关闭;点击另一项:继续打开新菜单
if (sameMenu) {
return;
}
}
if (navigateSingleSubmenuPage(navItem)) {
return;
}
const navItemContent = navItem.querySelector('.nav-item-content');
@@ -225,6 +276,7 @@ function showSubmenuPopup(navItem, menuId) {
// 创建弹出菜单
const popup = document.createElement('div');
popup.className = 'submenu-popup';
popup.dataset.menuId = menuId;
popup.style.position = 'fixed';
popup.style.left = (rect.right + 8) + 'px';
popup.style.top = rect.top + 'px';
@@ -289,6 +341,12 @@ async function initPage(pageId) {
case 'chat':
// 恢复对话列表折叠状态(从其他页返回时保持用户选择)
initConversationSidebarState();
if (typeof prefetchProjectsForChat === 'function') {
prefetchProjectsForChat();
}
if (typeof refreshChatProjectSelector === 'function') {
refreshChatProjectSelector();
}
break;
case 'hitl':
if (typeof refreshHitlPending === 'function') {
@@ -348,6 +406,11 @@ async function initPage(pageId) {
});
}
break;
case 'projects':
if (typeof initProjectsPage === 'function') {
initProjectsPage();
}
break;
case 'vulnerabilities':
// 初始化漏洞管理页面
if (typeof initVulnerabilityPage === 'function') {
+1 -1
View File
@@ -184,7 +184,7 @@ async function loadConfig(loadTools = true) {
const orEffEl = document.getElementById('openai-reasoning-effort');
if (orEffEl) {
const ev = (orm.effort || '').toString().trim().toLowerCase();
orEffEl.value = ['', 'low', 'medium', 'high', 'max'].includes(ev) ? ev : '';
orEffEl.value = ['', 'low', 'medium', 'high', 'max', 'xhigh'].includes(ev) ? ev : '';
}
const orProfEl = document.getElementById('openai-reasoning-profile');
if (orProfEl) {
+10 -1
View File
@@ -979,7 +979,16 @@ async function createBatchQueue() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ title, tasks, role, agentMode, scheduleMode, cronExpr, executeNow }),
body: JSON.stringify({
title,
tasks,
role,
agentMode,
scheduleMode,
cronExpr,
executeNow,
projectId: typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '',
}),
});
if (!response.ok) {
+189 -7
View File
@@ -48,6 +48,7 @@ let currentVulnerabilityId = null;
let vulnerabilityFilters = {
q: '',
id: '',
project_id: '',
conversation_id: '',
task_id: '',
conversation_tag: '',
@@ -77,6 +78,7 @@ const VULN_FILTER_CHIP_FIELDS = [
{ key: 'id', labelKey: 'vulnerabilityPage.vulnId' },
{ key: 'status', labelKey: null, format: 'status' },
{ key: 'severity', labelKey: null, format: 'severity' },
{ key: 'project_id', labelKey: null },
{ key: 'conversation_id', labelKey: 'vulnerabilityPage.conversationId' },
{ key: 'task_id', labelKey: 'vulnerabilityPage.taskOrQueueId' },
{ key: 'conversation_tag', labelKey: 'vulnerabilityPage.conversationTag' },
@@ -98,13 +100,15 @@ function syncVulnerabilityFiltersFromLocationHash() {
const st = (params.get('status') || '').trim();
const convTag = (params.get('conversation_tag') || '').trim();
const taskTag = (params.get('task_tag') || '').trim();
const pid = (params.get('project_id') || '').trim();
const q = (params.get('q') || params.get('search') || '').trim();
if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag && !q) {
if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag && !q && !pid) {
return;
}
vulnerabilityFilters.q = '';
vulnerabilityFilters.id = '';
vulnerabilityFilters.project_id = '';
vulnerabilityFilters.conversation_id = '';
vulnerabilityFilters.task_id = '';
vulnerabilityFilters.conversation_tag = '';
@@ -117,6 +121,7 @@ function syncVulnerabilityFiltersFromLocationHash() {
const taskEl = document.getElementById('vulnerability-task-filter');
const convTagEl = document.getElementById('vulnerability-conversation-tag-filter');
const taskTagEl = document.getElementById('vulnerability-task-tag-filter');
const projEl = document.getElementById('vulnerability-project-filter');
const sevEl = document.getElementById('vulnerability-severity-filter');
const stEl = document.getElementById('vulnerability-status-filter');
if (searchEl) searchEl.value = '';
@@ -125,6 +130,7 @@ function syncVulnerabilityFiltersFromLocationHash() {
if (taskEl) taskEl.value = '';
if (convTagEl) convTagEl.value = '';
if (taskTagEl) taskTagEl.value = '';
if (projEl) projEl.value = '';
if (sevEl) sevEl.value = '';
if (stEl) stEl.value = '';
@@ -132,6 +138,10 @@ function syncVulnerabilityFiltersFromLocationHash() {
vulnerabilityFilters.q = q;
if (searchEl) searchEl.value = q;
}
if (pid) {
vulnerabilityFilters.project_id = pid;
if (projEl) projEl.value = pid;
}
if (vid) {
vulnerabilityFilters.id = vid;
if (exactIdEl) exactIdEl.value = vid;
@@ -167,12 +177,13 @@ function syncVulnerabilityFiltersFromLocationHash() {
}
// 初始化漏洞管理页面
function initVulnerabilityPage() {
async function initVulnerabilityPage() {
// 从localStorage加载每页条数设置
vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
initVulnerabilityStatCards();
initVulnerabilityFilterPanel();
syncVulnerabilityFiltersFromLocationHash();
await refreshVulnerabilityProjectFilter();
updateVulnerabilityFilterPanelState();
renderVulnerabilityFilterChips();
loadVulnerabilityFilterOptions();
@@ -224,6 +235,7 @@ function applyVulnerabilitySeverityFilter(severity) {
function readVulnerabilityFiltersFromForm() {
vulnerabilityFilters.q = (document.getElementById('vulnerability-search-filter')?.value || '').trim();
vulnerabilityFilters.id = (document.getElementById('vulnerability-exact-id-filter')?.value || '').trim();
vulnerabilityFilters.project_id = (document.getElementById('vulnerability-project-filter')?.value || '').trim();
vulnerabilityFilters.conversation_id = (document.getElementById('vulnerability-conversation-filter')?.value || '').trim();
vulnerabilityFilters.task_id = (document.getElementById('vulnerability-task-filter')?.value || '').trim();
vulnerabilityFilters.conversation_tag = (document.getElementById('vulnerability-conversation-tag-filter')?.value || '').trim();
@@ -241,7 +253,7 @@ function hasVulnerabilityAdvancedFiltersActive() {
function hasAnyVulnerabilityFilterActive() {
const f = vulnerabilityFilters;
return Boolean(
f.q || f.id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status
f.q || f.id || f.project_id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status
);
}
@@ -265,6 +277,7 @@ function updateVulnerabilityLocationHashFromFilters() {
const pairs = [
['q', f.q],
['id', f.id],
['project_id', f.project_id],
['conversation_id', f.conversation_id],
['task_id', f.task_id],
['conversation_tag', f.conversation_tag],
@@ -476,6 +489,10 @@ function updateVulnerabilityFilterPanelState() {
function formatVulnerabilityFilterChipValue(key, value) {
if (key === 'severity') return vulnSeverityLabel(value);
if (key === 'status') return vulnStatusLabel(value);
if (key === 'project_id') {
const name = typeof getProjectName === 'function' ? getProjectName(value) : '';
return name && name !== value ? name : value;
}
return value;
}
@@ -489,7 +506,7 @@ function renderVulnerabilityFilterChips() {
VULN_FILTER_CHIP_FIELDS.forEach(function (field) {
const val = vulnerabilityFilters[field.key];
if (!val) return;
const label = field.labelKey ? vulnT(field.labelKey) : '';
const label = field.labelKey ? vulnT(field.labelKey) : (field.key === 'project_id' ? '项目' : '');
const displayVal = formatVulnerabilityFilterChipValue(field.key, val);
const text = label ? label + ': ' + displayVal : displayVal;
chips.push({ key: field.key, text: text });
@@ -529,6 +546,7 @@ function removeVulnerabilityFilterByKey(key) {
task_id: 'vulnerability-task-filter',
conversation_tag: 'vulnerability-conversation-tag-filter',
task_tag: 'vulnerability-task-tag-filter',
project_id: 'vulnerability-project-filter',
severity: 'vulnerability-severity-filter',
status: 'vulnerability-status-filter'
};
@@ -850,6 +868,12 @@ function renderVulnerabilities(vulnerabilities) {
const severityText = vulnSeverityLabel(vuln.severity);
const statusText = vulnStatusLabel(vuln.status);
const createdDate = new Date(vuln.created_at).toLocaleString(vulnDateLocale());
const projectLabel = vuln.project_id
? escapeHtml(typeof getProjectName === 'function' ? getProjectName(vuln.project_id) : vuln.project_id)
: escapeHtml(vulnT('vulnerabilityPage.projectUnbound'));
const projectBadge = vuln.project_id
? `<span class="vulnerability-project-badge" title="${escapeHtml(vuln.project_id)}">${escapeHtml(vulnT('vulnerabilityPage.detailProject'))}: ${projectLabel}</span>`
: `<span class="vulnerability-project-badge vulnerability-project-badge--unbound">${escapeHtml(vulnT('vulnerabilityPage.projectUnbound'))}</span>`;
const dlTitle = escapeHtml(vulnT('vulnerabilityPage.downloadMarkdownTitle'));
const editTitle = escapeHtml(vulnT('common.edit'));
const deleteTitle = escapeHtml(vulnT('common.delete'));
@@ -867,6 +891,7 @@ function renderVulnerabilities(vulnerabilities) {
<div class="vulnerability-meta">
<span class="severity-badge ${severityClass}">${severityText}</span>
<span class="status-badge status-${vuln.status}">${statusText}</span>
${projectBadge}
<span class="vulnerability-date">${createdDate}</span>
</div>
</div>
@@ -895,6 +920,7 @@ function renderVulnerabilities(vulnerabilities) {
${vuln.description ? `<div class="vulnerability-description">${escapeHtml(vuln.description)}</div>` : ''}
<div class="vulnerability-details">
${vulnDetailField(vulnT('vulnerabilityPage.detailVulnId'), vuln.id, true)}
${vulnDetailProjectField(vuln)}
${vuln.type ? vulnDetailField(vulnT('vulnerabilityPage.detailType'), vuln.type, false) : ''}
${vuln.target ? vulnDetailField(vulnT('vulnerabilityPage.detailTarget'), vuln.target, false) : ''}
${vulnDetailField(vulnT('vulnerabilityPage.detailConversationId'), vuln.conversation_id, true)}
@@ -1005,11 +1031,50 @@ async function changeVulnerabilityPageSize() {
await loadVulnerabilities();
}
function buildVulnerabilityProjectOptionsHtml(selectedId) {
const sel = (selectedId || '').trim();
let html = `<option value="">${escapeHtml(vulnT('vulnerabilityModal.projectNone'))}</option>`;
const entries = typeof projectNameById !== 'undefined' ? Object.entries(projectNameById) : [];
entries.sort((a, b) => (a[1] || '').localeCompare(b[1] || '', undefined, { sensitivity: 'base' }));
entries.forEach(([id, name]) => {
if (!id) return;
const selected = id === sel ? ' selected' : '';
html += `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(name || id)}</option>`;
});
if (sel && !entries.some(([id]) => id === sel)) {
html += `<option value="${escapeHtml(sel)}" selected>${escapeHtml(sel)}</option>`;
}
return html;
}
async function populateVulnerabilityModalProjectSelect(selectedId) {
const sel = document.getElementById('vulnerability-project-id');
if (!sel) return;
try {
const res = await apiFetch('/api/projects?limit=200');
if (res.ok) {
const list = await res.json();
if (typeof rebuildProjectNameMap === 'function') {
rebuildProjectNameMap(list);
} else if (typeof projectNameById !== 'undefined') {
(list || []).forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
}
}
} catch (e) {
console.warn('加载项目列表失败', e);
}
sel.innerHTML = buildVulnerabilityProjectOptionsHtml(selectedId || '');
sel.value = selectedId || '';
}
// 显示添加漏洞模态框
function showAddVulnerabilityModal() {
async function showAddVulnerabilityModal() {
currentVulnerabilityId = null;
document.getElementById('vulnerability-modal-title').textContent = vulnT('vulnerability.addVuln');
const defaultProject = vulnerabilityFilters.project_id || '';
await populateVulnerabilityModalProjectSelect(defaultProject);
// 清空表单
document.getElementById('vulnerability-conversation-id').value = '';
document.getElementById('vulnerability-conversation-tag').value = '';
@@ -1051,6 +1116,8 @@ async function editVulnerability(id) {
document.getElementById('vulnerability-impact').value = vuln.impact || '';
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || '';
await populateVulnerabilityModalProjectSelect(vuln.project_id || '');
document.getElementById('vulnerability-modal').style.display = 'block';
} catch (error) {
console.error('加载漏洞失败:', error);
@@ -1069,8 +1136,11 @@ async function saveVulnerability() {
return;
}
const projectId = (document.getElementById('vulnerability-project-id')?.value || '').trim();
const data = {
conversation_id: conversationId,
project_id: projectId,
conversation_tag: document.getElementById('vulnerability-conversation-tag').value.trim(),
task_tag: document.getElementById('vulnerability-task-tag').value.trim(),
title: title,
@@ -1090,12 +1160,30 @@ async function saveVulnerability() {
: '/api/vulnerabilities';
const method = currentVulnerabilityId ? 'PUT' : 'POST';
let body = data;
if (currentVulnerabilityId) {
body = {
project_id: projectId,
conversation_tag: data.conversation_tag,
task_tag: data.task_tag,
title: data.title,
description: data.description,
severity: data.severity,
status: data.status,
type: data.type,
target: data.target,
proof: data.proof,
impact: data.impact,
recommendation: data.recommendation,
};
}
const response = await apiFetch(url, {
method: method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
body: JSON.stringify(body)
});
if (!response.ok) {
@@ -1167,6 +1255,7 @@ function clearVulnerabilityFilters() {
'vulnerability-task-filter',
'vulnerability-conversation-tag-filter',
'vulnerability-task-tag-filter',
'vulnerability-project-filter',
'vulnerability-severity-filter',
'vulnerability-status-filter'
];
@@ -1178,6 +1267,7 @@ function clearVulnerabilityFilters() {
vulnerabilityFilters = {
q: '',
id: '',
project_id: '',
conversation_id: '',
task_id: '',
conversation_tag: '',
@@ -1272,6 +1362,21 @@ function vulnerabilityCopyEncoded(evt, encoded) {
}
}
function vulnDetailProjectField(vuln) {
const label = vulnT('vulnerabilityPage.detailProject');
const hint = escapeHtml(vulnT('vulnerabilityPage.projectBindHint'));
return `<div class="vuln-detail-field">
<div class="vuln-detail-field__label">${escapeHtml(label)}</div>
<div class="vuln-detail-field__row">
<select class="vuln-detail-field-select vulnerability-project-bind-select" data-vuln-id="${escapeHtml(vuln.id)}"
onchange="bindVulnerabilityProject(this.dataset.vulnId, this.value, true)" onclick="event.stopPropagation();"
title="${hint}" aria-label="${escapeHtml(label)}">
${buildVulnerabilityProjectOptionsHtml(vuln.project_id || '')}
</select>
</div>
</div>`;
}
function vulnDetailField(label, value, asCode) {
if (value === undefined || value === null || String(value) === '') {
return '';
@@ -1352,7 +1457,7 @@ function buildVulnerabilityFilterParams() {
if (vulnerabilityFilters.q) {
params.append('q', vulnerabilityFilters.q);
}
const keys = ['id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
const keys = ['id', 'project_id', 'conversation_id', 'task_id', 'conversation_tag', 'task_tag', 'severity', 'status'];
keys.forEach(function (k) {
if (vulnerabilityFilters[k]) {
params.append(k, vulnerabilityFilters[k]);
@@ -1470,3 +1575,80 @@ document.addEventListener('languagechange', function () {
}
});
async function bindVulnerabilityProject(vulnId, projectId, silent) {
if (!vulnId) return;
try {
const response = await apiFetch(`/api/vulnerabilities/${encodeURIComponent(vulnId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: projectId || '' }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || vulnT('vulnerabilityPage.projectBindFailed'));
}
if (!silent) {
alert(vulnT('vulnerabilityPage.projectBindOk'));
}
loadVulnerabilityStats();
loadVulnerabilities();
} catch (error) {
console.error('绑定项目失败:', error);
alert(vulnT('vulnerabilityPage.projectBindFailed') + ': ' + error.message);
loadVulnerabilities();
}
}
async function refreshVulnerabilityProjectFilter() {
const sel = document.getElementById('vulnerability-project-filter');
if (!sel) return;
try {
const res = await apiFetch('/api/projects?limit=200');
if (!res.ok) return;
const list = await res.json();
if (typeof rebuildProjectNameMap === 'function') {
rebuildProjectNameMap(list);
} else if (typeof projectNameById !== 'undefined') {
list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
}
const cur = vulnerabilityFilters.project_id || sel.value || '';
let html = '<option value="">全部项目</option>';
(list || []).forEach((p) => {
if (!p.id) return;
const selected = p.id === cur ? ' selected' : '';
const arch = p.status === 'archived' ? ' [归档]' : '';
html += `<option value="${escapeHtml(p.id)}"${selected}>${escapeHtml(p.name || p.id)}${arch}</option>`;
});
sel.innerHTML = html;
if (cur) sel.value = cur;
const modalSel = document.getElementById('vulnerability-project-id');
if (modalSel && document.getElementById('vulnerability-modal')?.style.display === 'block') {
const modalCur = modalSel.value || '';
modalSel.innerHTML = buildVulnerabilityProjectOptionsHtml(modalCur);
modalSel.value = modalCur;
}
} catch (e) {
console.warn('加载项目筛选列表失败', e);
}
}
function setVulnerabilityProjectFilter(projectId) {
vulnerabilityFilters.project_id = projectId || '';
const sel = document.getElementById('vulnerability-project-filter');
if (sel) sel.value = projectId || '';
applyVulnerabilityFilters();
}
function setVulnerabilityIdFilter(vulnId) {
vulnerabilityFilters.id = vulnId || '';
const el = document.getElementById('vulnerability-exact-id-filter');
if (el) el.value = vulnId || '';
applyVulnerabilityFilters();
}
window.refreshVulnerabilityProjectFilter = refreshVulnerabilityProjectFilter;
window.setVulnerabilityProjectFilter = setVulnerabilityProjectFilter;
window.setVulnerabilityIdFilter = setVulnerabilityIdFilter;
window.bindVulnerabilityProject = bindVulnerabilityProject;
window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml;
+317 -2
View File
@@ -161,6 +161,16 @@
<span data-i18n="nav.tasks">任务管理</span>
</div>
</div>
<div class="nav-item" data-page="projects">
<div class="nav-item-content" data-title="项目管理" onclick="switchPage('projects')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline>
<polyline points="2 12 12 17 22 12"></polyline>
</svg>
<span>项目管理</span>
</div>
</div>
<div class="nav-item" data-page="vulnerabilities">
<div class="nav-item-content" data-title="漏洞管理" onclick="switchPage('vulnerabilities')" data-i18n="nav.vulnerabilities" data-i18n-attr="data-title" data-i18n-skip-text="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -833,6 +843,7 @@
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
</div>
@@ -943,8 +954,28 @@
<div id="chat-input-container" class="chat-input-container">
<div class="chat-input-primary-row">
<div class="chat-input-leading">
<div class="role-selector-wrapper">
<button id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" data-i18n="chat.selectRole" data-i18n-attr="title" title="选择角色">
<div class="role-selector-wrapper project-selector-wrapper">
<button type="button" id="chat-project-btn" class="role-selector-btn" onclick="toggleChatProjectPanel()" aria-label="选择项目" aria-haspopup="listbox" aria-expanded="false" title="绑定项目后共享事实黑板(跨对话)">
<span class="role-selector-icon" aria-hidden="true">📁</span>
<span id="chat-project-text" class="role-selector-text">无项目</span>
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div id="chat-project-panel" class="role-selection-panel chat-project-panel" style="display: none;" role="listbox" aria-labelledby="chat-project-panel-title">
<div class="role-selection-panel-header">
<h3 id="chat-project-panel-title" class="role-selection-panel-title">选择项目</h3>
<button type="button" class="role-selection-panel-close" onclick="closeChatProjectPanel()" title="关闭" aria-label="关闭">
<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 id="chat-project-list" class="role-selection-list-main"></div>
</div>
</div>
<div id="role-selector-wrapper" class="role-selector-wrapper">
<button type="button" id="role-selector-btn" class="role-selector-btn" onclick="toggleRoleSelectionPanel()" data-i18n="chat.selectRole" data-i18n-attr="title" title="选择角色">
<span id="role-selector-icon" class="role-selector-icon">🔵</span>
<span id="role-selector-text" class="role-selector-text" data-i18n="chat.defaultRole">默认</span>
<svg class="role-selector-arrow" width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1383,6 +1414,177 @@
</div>
</div>
<!-- 项目管理页面 -->
<div id="page-projects" class="page projects-page">
<div class="page-header">
<h2>项目管理</h2>
<div class="page-header-actions">
<label class="projects-show-archived-label"><input type="checkbox" id="projects-show-archived" onchange="loadProjectsList()"> 显示已归档</label>
<button class="btn-secondary" type="button" onclick="loadProjectsList()">刷新</button>
<button class="btn-primary" type="button" onclick="showNewProjectModal()">+ 新建项目</button>
</div>
</div>
<div class="page-content projects-page-layout">
<aside class="projects-sidebar-card">
<div class="projects-sidebar-head">
<span class="projects-sidebar-title">项目列表</span>
<span class="projects-sidebar-count" id="projects-list-count">0</span>
</div>
<div class="projects-sidebar-search">
<input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off">
</div>
<div id="projects-list" class="projects-list"></div>
</aside>
<main class="projects-detail" id="projects-detail-main">
<div class="projects-detail-placeholder" id="projects-detail-placeholder">
<h3>选择或创建项目</h3>
<p>项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。</p>
<button class="btn-primary" type="button" onclick="showNewProjectModal()">创建第一个项目</button>
</div>
<div class="projects-detail-inner" id="projects-detail-inner" hidden>
<header class="projects-detail-header">
<div class="projects-detail-header-main">
<div class="projects-detail-title-row">
<h3 id="projects-detail-title" class="projects-detail-title">项目</h3>
<span id="projects-detail-status" class="projects-status-pill projects-status-pill--active">进行中</span>
</div>
<p id="projects-detail-meta" class="projects-detail-meta"></p>
<p id="projects-detail-desc" class="projects-detail-desc"></p>
<div class="projects-detail-stats" id="projects-detail-stats">
<span class="projects-stat-chip" id="project-stat-facts">0 条事实</span>
<span class="projects-stat-chip" id="project-stat-vulns">0 个漏洞</span>
</div>
</div>
<div class="projects-detail-header-actions">
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()">漏洞管理</button>
<button type="button" class="btn-primary btn-small" onclick="showAddFactModal()">+ 添加事实</button>
</div>
</header>
<nav class="projects-tabs" role="tablist">
<button type="button" id="project-tab-facts" class="projects-tab is-active" role="tab" onclick="switchProjectTab('facts')">事实黑板</button>
<button type="button" id="project-tab-vulns" class="projects-tab" role="tab" onclick="switchProjectTab('vulns')">关联漏洞</button>
<button type="button" id="project-tab-settings" class="projects-tab" role="tab" onclick="switchProjectTab('settings')">设置</button>
</nav>
<div id="project-panel-facts" class="projects-panel" role="tabpanel">
<div class="projects-panel-toolbar">
<span class="projects-panel-hint">Agent 每轮可见 key + 摘要;完整内容通过 get_project_fact 获取</span>
<button class="btn-primary btn-small" type="button" onclick="showAddFactModal()">+ 添加事实</button>
</div>
<div class="projects-table-wrap">
<table class="data-table data-table--projects">
<thead><tr><th>Key</th><th>分类</th><th>摘要</th><th>置信度</th><th>更新</th><th class="col-actions">操作</th></tr></thead>
<tbody id="project-facts-tbody"></tbody>
</table>
</div>
</div>
<div id="project-panel-vulns" class="projects-panel" role="tabpanel" hidden>
<div class="projects-panel-toolbar">
<span class="projects-panel-hint">本项目下记录的漏洞汇总</span>
<button type="button" class="btn-secondary btn-small" onclick="openVulnerabilitiesForProject()">在漏洞管理中查看</button>
</div>
<div class="projects-table-wrap">
<table class="data-table data-table--projects">
<thead><tr><th>标题</th><th>严重度</th><th>状态</th><th class="col-actions">操作</th></tr></thead>
<tbody id="project-vulns-tbody"></tbody>
</table>
</div>
</div>
<div id="project-panel-settings" class="projects-panel projects-panel--settings" role="tabpanel" hidden>
<div class="projects-settings-layout">
<header class="projects-settings-intro">
<div class="projects-settings-intro-text">
<h4 class="projects-settings-intro-title">项目设置</h4>
<p class="projects-settings-intro-hint">配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。</p>
</div>
</header>
<div class="projects-settings-grid">
<section class="projects-settings-card projects-settings-card--basic">
<div class="projects-settings-card-head">
<div class="projects-settings-card-head-left">
<span class="projects-settings-icon projects-settings-icon--blue" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
</span>
<div>
<h4 class="projects-settings-card-title">基本信息</h4>
<p class="projects-settings-card-hint">名称与描述会显示在项目详情中</p>
</div>
</div>
</div>
<div class="projects-settings-card-body">
<div class="projects-form-row projects-form-row--2">
<div class="projects-form-field">
<label for="project-edit-name">项目名称</label>
<input type="text" id="project-edit-name" class="form-input" placeholder="例如:某客户 Web 渗透">
</div>
<div class="projects-form-field">
<label for="project-edit-status">状态</label>
<div class="projects-status-select-wrap">
<select id="project-edit-status" class="form-input projects-status-select">
<option value="active">进行中</option>
<option value="archived">已归档</option>
</select>
</div>
</div>
</div>
<div class="projects-form-field">
<label for="project-edit-description">描述</label>
<textarea id="project-edit-description" class="form-input" rows="4" placeholder="测试目标、授权范围、联系人、注意事项…"></textarea>
</div>
</div>
</section>
<section class="projects-settings-card projects-settings-card--scope">
<div class="projects-settings-card-head">
<div class="projects-settings-card-head-left">
<span class="projects-settings-icon projects-settings-icon--violet" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
<div>
<h4 class="projects-settings-card-title">测试范围</h4>
<p class="projects-settings-card-hint">JSON 格式,供 Agent 理解授权边界与目标资产</p>
</div>
</div>
<div class="projects-scope-toolbar">
<button type="button" class="btn-ghost btn-small" onclick="formatProjectScopeJson()" title="格式化 JSON">格式化</button>
<button type="button" class="btn-ghost btn-small" onclick="insertProjectScopeExample()" title="插入示例">示例</button>
</div>
</div>
<div class="projects-settings-card-body projects-settings-card-body--fill">
<div class="projects-scope-editor">
<label for="project-edit-scope" class="sr-only">范围 JSON</label>
<textarea id="project-edit-scope" class="form-input form-input--mono projects-scope-textarea" spellcheck="false" placeholder='{"targets":["https://example.com"],"exclude":["*.cdn.example.com"]}'></textarea>
</div>
<p class="projects-scope-footnote">支持 <code>targets</code><code>exclude</code><code>notes</code> 等字段,留空表示不限制范围。</p>
</div>
</section>
</div>
<section class="projects-settings-card projects-settings-card--danger">
<div class="projects-settings-danger-main">
<span class="projects-settings-icon projects-settings-icon--red" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</span>
<div>
<h4 class="projects-settings-card-title">危险操作</h4>
<p class="projects-settings-card-hint">归档后需在列表勾选「显示已归档」才能查看;删除将清除全部事实且不可恢复。</p>
</div>
</div>
<div class="projects-settings-danger-actions">
<button class="btn-secondary btn-small" type="button" onclick="archiveCurrentProject()">归档 / 恢复</button>
<button class="btn-secondary btn-small btn-danger-outline" type="button" onclick="deleteCurrentProject()">删除项目</button>
</div>
</section>
<footer class="projects-settings-footer">
<span class="projects-settings-footer-hint">修改后请点击保存以同步到服务器</span>
<button class="btn-primary" type="button" onclick="saveProjectSettings()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
保存更改
</button>
</footer>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- 漏洞管理页面 -->
<div id="page-vulnerabilities" class="page">
<div class="page-header">
@@ -1455,6 +1657,12 @@
<input type="search" id="vulnerability-search-filter" autocomplete="off"
data-i18n="vulnerabilityPage.searchKeyword" data-i18n-attr="placeholder" placeholder="搜索标题、描述、类型、目标…" />
</label>
<label class="vulnerability-filter-field vulnerability-filter-field--project">
<span class="sr-only">项目</span>
<select id="vulnerability-project-filter" title="按项目筛选" onchange="scheduleVulnerabilityFilterApply(true)">
<option value="">全部项目</option>
</select>
</label>
<label class="vulnerability-filter-field vulnerability-filter-field--status">
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
<select id="vulnerability-status-filter" data-i18n-attr="title" title="状态">
@@ -2122,6 +2330,7 @@
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="xhigh">xhigh</option>
<option value="max">max</option>
</select>
<label for="openai-reasoning-profile" style="font-size: 0.8125rem;" data-i18n="settingsBasic.openaiReasoningProfile">线路</label>
@@ -3490,6 +3699,13 @@
<span class="modal-close" onclick="closeVulnerabilityModal()">&times;</span>
</div>
<div class="modal-body">
<div class="form-group">
<label for="vulnerability-project-id" data-i18n="vulnerabilityModal.project">所属项目</label>
<select id="vulnerability-project-id" class="form-input">
<option value="" data-i18n="vulnerabilityModal.projectNone">(未绑定)</option>
</select>
<p class="form-hint" data-i18n="vulnerabilityModal.projectHint">绑定后 Agent 在项目范围内可通过 list_vulnerabilities 看到本条记录;留空则尝试从会话自动关联。</p>
</div>
<div class="form-group">
<label for="vulnerability-conversation-id"><span data-i18n="vulnerabilityModal.conversationId">会话ID</span> <span style="color: red;">*</span></label>
<input type="text" id="vulnerability-conversation-id" data-i18n="vulnerabilityModal.conversationIdPlaceholder" data-i18n-attr="placeholder" placeholder="输入会话ID" required />
@@ -3722,6 +3938,104 @@
</div>
</div>
<!-- 项目管理弹窗(挂 body 下,避免被 .page overflow 裁剪) -->
<div id="project-modal" class="modal-overlay projects-modal-overlay" style="display:none;" role="dialog" aria-modal="true" aria-labelledby="project-modal-title" onclick="if(event.target===this)closeProjectModal()">
<div class="projects-modal-dialog" onclick="event.stopPropagation()">
<div class="projects-modal-header">
<div class="projects-modal-header-text">
<div>
<h3 id="project-modal-title">新建项目</h3>
<p id="project-modal-subtitle" class="projects-modal-subtitle">创建后可绑定对话,跨会话共享事实黑板</p>
</div>
</div>
<button type="button" class="projects-modal-close" onclick="closeProjectModal()" aria-label="关闭">&times;</button>
</div>
<div class="projects-modal-body">
<div class="projects-form-field">
<label for="project-modal-name">项目名称 <span class="required">*</span></label>
<input type="text" id="project-modal-name" class="form-input" placeholder="例如:某客户 Web 渗透" autocomplete="off">
</div>
<div class="projects-form-field">
<label for="project-modal-description">项目描述</label>
<textarea id="project-modal-description" class="form-input" rows="4" placeholder="测试范围、授权边界、注意事项…"></textarea>
</div>
</div>
<div class="projects-modal-footer">
<button class="btn-secondary" type="button" onclick="closeProjectModal()">取消</button>
<button class="btn-primary" type="button" id="project-modal-submit-btn" onclick="saveProjectModal()">创建项目</button>
</div>
</div>
</div>
<div id="fact-modal" class="modal-overlay projects-modal-overlay" style="display:none;" role="dialog" aria-modal="true" onclick="if(event.target===this)closeFactModal()">
<div class="projects-modal-dialog projects-modal-dialog--wide" onclick="event.stopPropagation()">
<div class="projects-modal-header">
<div class="projects-modal-header-text">
<div>
<h3 id="fact-modal-title">添加事实</h3>
<p class="projects-modal-subtitle">摘要会注入 Agent;完整内容通过 get_project_fact 获取</p>
</div>
</div>
<button type="button" class="projects-modal-close" onclick="closeFactModal()" aria-label="关闭">&times;</button>
</div>
<div class="projects-modal-body">
<div class="projects-form-field">
<label for="fact-modal-key">fact_key</label>
<input type="text" id="fact-modal-key" class="form-input" placeholder="target/primary_domain">
</div>
<div class="projects-form-row">
<div class="projects-form-field">
<label for="fact-modal-category">分类</label>
<input type="text" id="fact-modal-category" class="form-input" value="note">
</div>
<div class="projects-form-field">
<label for="fact-modal-confidence">置信度</label>
<select id="fact-modal-confidence" class="form-input">
<option value="tentative">待确认</option>
<option value="confirmed">已确认</option>
<option value="deprecated">已废弃</option>
</select>
</div>
</div>
<div class="projects-form-field">
<label for="fact-modal-summary">摘要</label>
<input type="text" id="fact-modal-summary" class="form-input" placeholder="一行概述,会注入到 Agent 上下文">
</div>
<div class="projects-form-field">
<label for="fact-modal-body">body(完整详情)</label>
<textarea id="fact-modal-body" class="form-input" rows="5" placeholder="POC、长文本、原始输出等"></textarea>
</div>
<div class="projects-form-field">
<label for="fact-modal-related-vuln">关联漏洞 ID</label>
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选">
</div>
</div>
<div class="projects-modal-footer">
<button class="btn-secondary" type="button" onclick="closeFactModal()">取消</button>
<button class="btn-primary" type="button" id="fact-modal-submit-btn" onclick="saveFactModal()">保存事实</button>
</div>
</div>
</div>
<div id="fact-detail-modal" class="modal-overlay projects-modal-overlay" style="display:none;" role="dialog" aria-modal="true" onclick="if(event.target===this)closeFactDetailModal()">
<div class="projects-modal-dialog projects-modal-dialog--wide" onclick="event.stopPropagation()">
<div class="projects-modal-header">
<div class="projects-modal-header-text">
<div>
<h3 id="fact-detail-title">事实详情</h3>
<p id="fact-detail-meta" class="projects-modal-subtitle"></p>
</div>
</div>
<button type="button" class="projects-modal-close" onclick="closeFactDetailModal()" aria-label="关闭">&times;</button>
</div>
<div class="projects-modal-body">
<pre id="fact-detail-body" class="fact-detail-body"></pre>
</div>
<div class="projects-modal-footer">
<button class="btn-secondary" type="button" onclick="closeFactDetailModal()">关闭</button>
<button class="btn-primary" type="button" id="fact-detail-edit-btn" onclick="editFactFromDetail()">编辑</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/i18next@23.11.5/i18next.min.js"></script>
<script src="/static/js/i18n.js"></script>
<script src="/static/js/builtin-tools.js"></script>
@@ -3742,6 +4056,7 @@
<script src="/static/js/terminal.js"></script>
<script src="/static/js/knowledge.js"></script>
<script src="/static/js/skills.js"></script>
<script src="/static/js/projects.js"></script>
<script src="/static/js/vulnerability.js?v=12"></script>
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>