Compare commits

...

23 Commits

Author SHA1 Message Date
公明 1f46d4a930 Add files via upload 2026-07-03 17:06:18 +08:00
公明 3a995183a6 Add files via upload 2026-07-03 17:03:37 +08:00
公明 3ed7499a0b Add files via upload 2026-07-03 17:01:43 +08:00
公明 f26354d483 Add files via upload 2026-07-03 16:59:39 +08:00
公明 ebd872b373 Add files via upload 2026-07-03 16:57:09 +08:00
公明 07439bce6e Add files via upload 2026-07-03 16:54:18 +08:00
公明 625ac4358f Update config.yaml 2026-07-03 14:29:16 +08:00
公明 eb6b9d6f45 Add files via upload 2026-07-03 14:28:37 +08:00
公明 ad97544bbe Add files via upload 2026-07-03 14:20:06 +08:00
公明 12a1ebe9cd Add files via upload 2026-07-03 14:17:47 +08:00
公明 b97e726237 Add files via upload 2026-07-03 14:15:51 +08:00
公明 2eb923e5fa Add files via upload 2026-07-03 14:13:35 +08:00
公明 745a69f93b Add files via upload 2026-07-03 14:12:20 +08:00
公明 011a242acc Add files via upload 2026-07-03 14:10:14 +08:00
公明 6a52ef96f4 Add files via upload 2026-07-03 10:56:22 +08:00
公明 52f8c377b6 Add files via upload 2026-07-03 10:55:07 +08:00
公明 8d04b0c266 Add files via upload 2026-07-03 10:52:21 +08:00
公明 bcdff06702 Add files via upload 2026-07-03 10:49:53 +08:00
公明 3210bc727f Add files via upload 2026-07-03 10:48:38 +08:00
公明 5254ca52fb Add files via upload 2026-07-03 10:46:04 +08:00
公明 1ff2df68ac Add files via upload 2026-07-02 23:32:48 +08:00
公明 fe60497863 Add files via upload 2026-07-02 19:21:29 +08:00
公明 7acd21bc98 Add files via upload 2026-07-02 19:14:30 +08:00
33 changed files with 4408 additions and 302 deletions
-5
View File
@@ -472,11 +472,6 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
- **Web management** create, update, delete knowledge items through the web UI, with category-based organization; settings page exposes MultiQuery / rerank / prefetch options.
- **Retrieval logs** tracks all knowledge retrieval operations for audit and debugging.
**Quick Start (Using Pre-built Knowledge Base):**
1. **Download the knowledge database** Download the pre-built knowledge database file from [GitHub Releases](https://github.com/Ed1s0nZ/CyberStrikeAI/releases).
2. **Extract and place** Extract the downloaded knowledge database file (`knowledge.db`) and place it in the project's `data/` directory.
3. **Restart the service** Restart the CyberStrikeAI service, and the knowledge base will be ready to use immediately without rebuilding the index.
**Setting up the knowledge base:**
1. **Enable in config** set `knowledge.enabled: true` in `config.yaml`:
```yaml
-5
View File
@@ -470,11 +470,6 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
- **Web 管理**:通过 Web 界面创建、更新、删除知识项,支持分类管理;设置页可配置 MultiQuery / 精排 / 预取候选数。
- **检索日志**:记录所有知识检索操作,便于审计与调试。
**快速开始(使用预构建知识库):**
1. **下载知识数据库**:从 [GitHub Releases](https://github.com/Ed1s0nZ/CyberStrikeAI/releases) 下载预构建的知识数据库文件。
2. **解压并放置**:将下载的知识数据库文件(`knowledge.db`)解压后放到项目的 `data/` 目录下。
3. **重启服务**:重启 CyberStrikeAI 服务,知识库即可直接使用,无需重新构建索引。
**知识库配置步骤:**
1. **启用功能**:在 `config.yaml` 中设置 `knowledge.enabled: true`
```yaml
+2
View File
@@ -106,6 +106,8 @@ agent:
# approval → audit_agent_prompt
# review_edit → audit_agent_prompt_review_edit(可改参后放行)
hitl:
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;未选会话时切换会写入本项,重启后仍生效
default_reviewer: human
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
retention_days: 90
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
+14
View File
@@ -356,6 +356,8 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
projectHandler := handler.NewProjectHandler(db, log.Logger)
workflowHandler := handler.NewWorkflowHandler(db, log.Logger)
workflowHandler.SetAudit(auditSvc)
vulnerabilityHandler.SetAudit(auditSvc)
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
webshellHandler.SetAudit(auditSvc)
@@ -367,6 +369,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
configHandler.SetAudit(auditSvc)
agentHandler.SetHitlToolWhitelistSaver(configHandler)
agentHandler.SetHitlAuditStrategySaver(configHandler)
agentHandler.SetHitlDefaultReviewerSaver(configHandler)
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
externalMCPHandler.SetAudit(auditSvc)
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
@@ -517,6 +520,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
app, // 传递 App 实例以便动态获取 knowledgeHandler
vulnerabilityHandler,
projectHandler,
workflowHandler,
webshellHandler,
chatUploadsHandler,
roleHandler,
@@ -763,6 +767,7 @@ func setupRoutes(
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
vulnerabilityHandler *handler.VulnerabilityHandler,
projectHandler *handler.ProjectHandler,
workflowHandler *handler.WorkflowHandler,
webshellHandler *handler.WebShellHandler,
chatUploadsHandler *handler.ChatUploadsHandler,
roleHandler *handler.RoleHandler,
@@ -826,6 +831,8 @@ func setupRoutes(
protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
protected.GET("/hitl/default-reviewer", agentHandler.GetHITLDefaultReviewer)
protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer)
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
protected.PUT("/hitl/audit-strategy", agentHandler.UpdateHITLAuditStrategy)
// Agent Loop 取消与任务列表
@@ -1189,6 +1196,13 @@ func setupRoutes(
protected.PUT("/roles/:name", roleHandler.UpdateRole)
protected.DELETE("/roles/:name", roleHandler.DeleteRole)
// 图编排 / 工作流定义(图结构固定,业务字段保存在 graph_json 中)
protected.GET("/workflows", workflowHandler.List)
protected.GET("/workflows/:id", workflowHandler.Get)
protected.POST("/workflows", workflowHandler.Create)
protected.PUT("/workflows/:id", workflowHandler.Update)
protected.DELETE("/workflows/:id", workflowHandler.Delete)
// Skills管理(具体路径需注册在 /skills/:name 之前)
protected.GET("/skills", skillsHandler.GetSkills)
protected.GET("/skills/stats", skillsHandler.GetSkillStats)
+72 -17
View File
@@ -120,9 +120,19 @@ func formatVulnerabilityDetail(v *database.Vulnerability) string {
b.WriteString(v.Description)
b.WriteString("\n")
}
if v.Proof != "" {
b.WriteString("\n--- 证明(POC ---\n")
b.WriteString(v.Proof)
if v.Preconditions != "" {
b.WriteString("\n--- 前置条件 ---\n")
b.WriteString(v.Preconditions)
b.WriteString("\n")
}
if v.ReproSteps != "" {
b.WriteString("\n--- 复现步骤 ---\n")
b.WriteString(v.ReproSteps)
b.WriteString("\n")
}
if v.Evidence != "" {
b.WriteString("\n--- 证据 / POC ---\n")
b.WriteString(v.Evidence)
b.WriteString("\n")
}
if v.Impact != "" {
@@ -135,9 +145,36 @@ func formatVulnerabilityDetail(v *database.Vulnerability) string {
b.WriteString(v.Recommendation)
b.WriteString("\n")
}
if v.RetestNotes != "" {
b.WriteString("\n--- 复测方式 ---\n")
b.WriteString(v.RetestNotes)
b.WriteString("\n")
}
return b.String()
}
func missingVulnerabilityReproFields(args map[string]interface{}) []string {
required := []struct {
key string
label string
}{
{"target", "target(受影响的 URL/IP/服务/接口)"},
{"vulnerability_type", "vulnerability_type(漏洞类型)"},
{"description", "description(漏洞摘要与触发点)"},
{"reproduction_steps", "reproduction_steps(可逐步执行的复现步骤)"},
{"evidence", "evidencePOC、原始请求/响应、命令输出或截图/日志证据)"},
{"impact", "impact(确认后的实际影响)"},
{"recommendation", "recommendation(修复建议)"},
}
missing := make([]string, 0)
for _, item := range required {
if strings.TrimSpace(strArg(args, item.key)) == "" {
missing = append(missing, item.label)
}
}
return missing
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
@@ -163,18 +200,18 @@ func registerVulnerabilityTools(mcpServer *mcp.Server, db *database.DB, logger *
func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
tool := mcp.Tool{
Name: builtin.ToolRecordVulnerability,
Description: "记录发现的漏洞详情到漏洞管理系统。边渗透边记录:每验证出一条可复现漏洞(含 POC/影响)后立即调用,勿等会话结束。包括标题、描述、严重程度、类型、目标、证明、影响和建议等。记录前可先 list_vulnerabilities 避免重复。",
ShortDescription: "记录现的漏洞详情到漏洞管理系统",
Description: "记录发现的漏洞详情到漏洞管理系统。必须按“仅看本记录即可复现”的标准填写:目标、触发点、前置条件、复现步骤、证据/POC、实际影响、修复建议和复测方式。边渗透边记录:每验证出一条可复现漏洞后立即调用,勿等会话结束。记录前可先 list_vulnerabilities 避免重复。",
ShortDescription: "记录可复现的漏洞详情到漏洞管理系统",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"title": map[string]interface{}{
"type": "string",
"description": "漏洞标题(必需)",
"description": "漏洞标题(必需)。建议格式:<资产/接口> 存在 <漏洞类型>,例如“/api/login 存在 SQL 注入”。",
},
"description": map[string]interface{}{
"type": "string",
"description": "漏洞详细描述",
"description": "漏洞摘要与触发点(必需):说明哪个功能/参数/入口存在问题、为什么可被利用。不要只写结论。",
},
"severity": map[string]interface{}{
"type": "string",
@@ -183,26 +220,38 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
},
"vulnerability_type": map[string]interface{}{
"type": "string",
"description": "漏洞类型,如:SQL注入、XSS、CSRF、命令注入等",
"description": "漏洞类型,如:SQL注入、XSS、CSRF、命令注入等(必需)",
},
"target": map[string]interface{}{
"type": "string",
"description": "受影响的目标(URL、IP地址、服务等)",
"description": "受影响的目标(必需):尽量精确到 URL、IP:端口、服务名、接口路径和参数名。",
},
"proof": map[string]interface{}{
"preconditions": map[string]interface{}{
"type": "string",
"description": "漏洞证明(POC、截图、请求/响应等)",
"description": "前置条件:登录状态、权限、账号、Header/Cookie、特定数据、网络位置、环境/版本等;无前置条件写“无”。",
},
"reproduction_steps": map[string]interface{}{
"type": "string",
"description": "复现步骤(必需):按 1/2/3 编号,写清入口、参数、payload、执行命令、观察点。应让未参与对话的人照做即可复现。",
},
"evidence": map[string]interface{}{
"type": "string",
"description": "证据 / POC(必需):原始 HTTP 请求/响应、curl/工具命令、截图文字说明、日志、DNSLog/回连记录、数据库结果、文件路径、时间戳等。优先放最小可验证证据。",
},
"impact": map[string]interface{}{
"type": "string",
"description": "漏洞影响说明",
"description": "漏洞影响说明(必需):结合已验证事实说明可造成什么后果,避免泛泛而谈。",
},
"recommendation": map[string]interface{}{
"type": "string",
"description": "修复建议",
"description": "修复建议(必需):给出针对该触发点/参数/组件的具体修复和复测建议。",
},
"retest_notes": map[string]interface{}{
"type": "string",
"description": "复测方式:修复后如何验证漏洞已关闭,包括应返回的状态码、错误信息或访问控制结果。",
},
},
"required": []string{"title", "severity"},
"required": []string{"title", "description", "severity", "vulnerability_type", "target", "reproduction_steps", "evidence", "impact", "recommendation"},
},
}
@@ -231,6 +280,9 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
if !validSeverities[severity] {
return textResult(fmt.Sprintf("错误: severity 必须是 critical、high、medium、low 或 info 之一,当前值: %s", severity), true), nil
}
if missing := missingVulnerabilityReproFields(args); len(missing) > 0 {
return textResult("错误: 漏洞记录缺少复现所需信息,请补充后再记录:\n- "+strings.Join(missing, "\n- ")+"\n\n最佳实践:漏洞管理中的单条记录应独立包含目标、前置条件、复现步骤、证据/POC、影响和修复/复测方式。", true), nil
}
projectID := ""
if pid, perr := db.GetConversationProjectID(conversationID); perr == nil {
@@ -246,9 +298,12 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
Status: "open",
Type: strArg(args, "vulnerability_type"),
Target: strArg(args, "target"),
Proof: strArg(args, "proof"),
Preconditions: strArg(args, "preconditions"),
ReproSteps: strArg(args, "reproduction_steps"),
Evidence: strArg(args, "evidence"),
Impact: strArg(args, "impact"),
Recommendation: strArg(args, "recommendation"),
RetestNotes: strArg(args, "retest_notes"),
}
created, err := db.CreateVulnerability(vuln)
@@ -275,8 +330,8 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
func registerListVulnerabilitiesTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
tool := mcp.Tool{
Name: builtin.ToolListVulnerabilities,
Description: "列出当前授权范围内的漏洞(摘要)。默认:对话已绑定项目时列出该项目下全部漏洞;未绑项目时仅列出当前会话漏洞。可用 scope=conversation 仅看本会话。记录新漏洞前建议先调用以避免重复。",
Name: builtin.ToolListVulnerabilities,
Description: "列出当前授权范围内的漏洞(摘要)。默认:对话已绑定项目时列出该项目下全部漏洞;未绑项目时仅列出当前会话漏洞。可用 scope=conversation 仅看本会话。记录新漏洞前建议先调用以避免重复。",
ShortDescription: "列出漏洞(默认当前项目)",
InputSchema: map[string]interface{}{
"type": "object",
+74 -59
View File
@@ -30,7 +30,7 @@ type Config struct {
Monitor MonitorConfig `yaml:"monitor,omitempty" json:"monitor,omitempty"`
ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"`
Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"`
C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用
C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用
Robots RobotsConfig `yaml:"robots,omitempty" json:"robots,omitempty"` // 企业微信/钉钉/飞书等机器人配置
RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式)
Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色
@@ -79,7 +79,7 @@ func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
type MultiAgentConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
RobotDefaultAgentMode string `yaml:"robot_default_agent_mode,omitempty" json:"robot_default_agent_mode,omitempty"` // eino_single | deep | plan_execute | supervisor
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
// MaxIteration 已废弃:统一使用 agent.max_iterationsYAML 中保留字段仅为兼容旧配置,运行时不读取)。
@@ -87,10 +87,10 @@ type MultiAgentConfig struct {
// PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
// SubAgentMaxIterations 已废弃:子代理与主代理均使用 agent.max_iterationsMarkdown max_iterations>0 可覆盖)。
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations,omitempty" json:"sub_agent_max_iterations,omitempty"`
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations,omitempty" json:"sub_agent_max_iterations,omitempty"`
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
// OrchestratorInstructionPlanExecute plan_execute 主代理(规划侧)系统提示;非空且 agents/orchestrator-plan-execute.md 正文为空或未存在时生效。不与 Deep 的 orchestrator_instruction 混用。
OrchestratorInstructionPlanExecute string `yaml:"orchestrator_instruction_plan_execute,omitempty" json:"orchestrator_instruction_plan_execute,omitempty"`
// OrchestratorInstructionSupervisor supervisor 主代理系统提示(transfer/exit 说明仍由运行追加);非空且 agents/orchestrator-supervisor.md 正文为空或未存在时生效。
@@ -130,11 +130,11 @@ type MultiAgentEinoCallbacksConfig struct {
// MultiAgentEinoCallbacksOtelConfig OpenTelemetry for Eino callback spans (W3C trace in collector / stdout).
type MultiAgentEinoCallbacksOtelConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
ServiceName string `yaml:"service_name,omitempty" json:"service_name,omitempty"`
Exporter string `yaml:"exporter,omitempty" json:"exporter,omitempty"` // none | stdout | otlphttp
OTLPEndpoint string `yaml:"otlp_endpoint,omitempty" json:"otlp_endpoint,omitempty"` // host:port, e.g. localhost:4318 (path /v1/traces)
SampleRatio float64 `yaml:"sample_ratio,omitempty" json:"sample_ratio,omitempty"` // 01, default 1.0
Enabled bool `yaml:"enabled" json:"enabled"`
ServiceName string `yaml:"service_name,omitempty" json:"service_name,omitempty"`
Exporter string `yaml:"exporter,omitempty" json:"exporter,omitempty"` // none | stdout | otlphttp
OTLPEndpoint string `yaml:"otlp_endpoint,omitempty" json:"otlp_endpoint,omitempty"` // host:port, e.g. localhost:4318 (path /v1/traces)
SampleRatio float64 `yaml:"sample_ratio,omitempty" json:"sample_ratio,omitempty"` // 01, default 1.0
}
// EinoCallbacksModeEffective returns off | log_only | sse | full.
@@ -245,12 +245,12 @@ type MultiAgentEinoMiddlewareConfig struct {
// PlantaskRelDir relative to skills_dir for per-conversation task boards (default .eino/plantask).
PlantaskRelDir string `yaml:"plantask_rel_dir,omitempty" json:"plantask_rel_dir,omitempty"`
// Reduction truncates/offloads large tool outputs (requires eino local backend for Write).
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // 非空:落盘根目录(默认 tmp/reduction);其下按 projects/{id} 或 conversations/{id} 隔离
ReductionMaxLengthForTrunc int `yaml:"reduction_max_length_for_trunc,omitempty" json:"reduction_max_length_for_trunc,omitempty"` // default 12000
ReductionMaxTokensForClear int `yaml:"reduction_max_tokens_for_clear,omitempty" json:"reduction_max_tokens_for_clear,omitempty"` // default 50000
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // 非空:落盘根目录(默认 tmp/reduction);其下按 projects/{id} 或 conversations/{id} 隔离
ReductionMaxLengthForTrunc int `yaml:"reduction_max_length_for_trunc,omitempty" json:"reduction_max_length_for_trunc,omitempty"` // default 12000
ReductionMaxTokensForClear int `yaml:"reduction_max_tokens_for_clear,omitempty" json:"reduction_max_tokens_for_clear,omitempty"` // default 50000
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
// SummarizationTriggerRatio controls summarization trigger threshold as max_total_tokens * ratio (default 0.8).
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
@@ -398,13 +398,13 @@ type MultiAgentSubConfig struct {
// MultiAgentPublic 返回给前端的精简信息(不含子代理指令全文)。
type MultiAgentPublic struct {
Enabled bool `json:"enabled"`
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
SubAgentCount int `json:"sub_agent_count"`
Orchestration string `json:"orchestration,omitempty"`
PlanExecuteLoopMaxIterations int `json:"plan_execute_loop_max_iterations"`
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
Enabled bool `json:"enabled"`
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
SubAgentCount int `json:"sub_agent_count"`
Orchestration string `json:"orchestration,omitempty"`
PlanExecuteLoopMaxIterations int `json:"plan_execute_loop_max_iterations"`
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
}
@@ -445,10 +445,10 @@ func NormalizeMultiAgentOrchestration(s string) string {
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
type MultiAgentAPIUpdate struct {
Enabled bool `json:"enabled"`
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
Enabled bool `json:"enabled"`
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
}
@@ -464,14 +464,14 @@ type RobotsConfig struct {
// RobotWechatConfig 微信 iLink 机器人配置(个人微信 ClawBot / iLink 协议)
type RobotWechatConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
BotToken string `yaml:"bot_token,omitempty" json:"bot_token,omitempty"`
ILinkBotID string `yaml:"ilink_bot_id,omitempty" json:"ilink_bot_id,omitempty"`
ILinkUserID string `yaml:"ilink_user_id,omitempty" json:"ilink_user_id,omitempty"`
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://ilinkai.weixin.qq.com
BotType string `yaml:"bot_type,omitempty" json:"bot_type,omitempty"` // get_bot_qrcode 参数,默认 3
BotAgent string `yaml:"bot_agent,omitempty" json:"bot_agent,omitempty"` // base_info.bot_agent
GetUpdatesBuf string `yaml:"get_updates_buf,omitempty" json:"get_updates_buf,omitempty"` // 长轮询游标(运行时)
Enabled bool `yaml:"enabled" json:"enabled"`
BotToken string `yaml:"bot_token,omitempty" json:"bot_token,omitempty"`
ILinkBotID string `yaml:"ilink_bot_id,omitempty" json:"ilink_bot_id,omitempty"`
ILinkUserID string `yaml:"ilink_user_id,omitempty" json:"ilink_user_id,omitempty"`
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://ilinkai.weixin.qq.com
BotType string `yaml:"bot_type,omitempty" json:"bot_type,omitempty"` // get_bot_qrcode 参数,默认 3
BotAgent string `yaml:"bot_agent,omitempty" json:"bot_agent,omitempty"` // base_info.bot_agent
GetUpdatesBuf string `yaml:"get_updates_buf,omitempty" json:"get_updates_buf,omitempty"` // 长轮询游标(运行时)
}
// RobotSessionConfig 机器人会话隔离策略
@@ -510,19 +510,19 @@ func ValidateWecomConfig(w RobotWecomConfig) error {
// RobotDingtalkConfig 钉钉机器人配置
type RobotDingtalkConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
Enabled bool `yaml:"enabled" json:"enabled"`
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
AllowConversationIDFallback bool `yaml:"allow_conversation_id_fallback" json:"allow_conversation_id_fallback"` // sender_id 缺失时是否允许回退到会话 ID
}
// RobotLarkConfig 飞书机器人配置
type RobotLarkConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
AllowChatIDFallback bool `yaml:"allow_chat_id_fallback" json:"allow_chat_id_fallback"` // 用户 ID 缺失时是否允许回退到 chat_id
Enabled bool `yaml:"enabled" json:"enabled"`
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
AllowChatIDFallback bool `yaml:"allow_chat_id_fallback" json:"allow_chat_id_fallback"` // 用户 ID 缺失时是否允许回退到 chat_id
}
type ServerConfig struct {
@@ -621,8 +621,8 @@ type DatabaseConfig struct {
}
type AgentConfig struct {
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
// ShellNoOutputTimeoutSeconds execute/exec 无任何 stdout/stderr 时的空闲终止秒数(通用防挂死,不维护命令黑名单);0=默认 300(5 分钟);-1=关闭。
ShellNoOutputTimeoutSeconds int `yaml:"shell_no_output_timeout_seconds" json:"shell_no_output_timeout_seconds"`
// WorkspaceRootDir 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离。
@@ -643,6 +643,18 @@ type HitlConfig struct {
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
// DefaultReviewer 全局默认审批方(human | audit_agent);未选会话时切换会写入 config.yaml;新建会话无独立配置时沿用。
DefaultReviewer string `yaml:"default_reviewer,omitempty" json:"default_reviewer,omitempty"`
}
// EffectiveDefaultReviewer returns human or audit_agent; omitted or unknown values default to human.
func (h HitlConfig) EffectiveDefaultReviewer() string {
switch strings.ToLower(strings.TrimSpace(h.DefaultReviewer)) {
case "audit_agent", "agent", "ai":
return "audit_agent"
default:
return "human"
}
}
// RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
@@ -756,9 +768,9 @@ func (m MonitorConfig) RetentionDaysEffective() int {
// AuditConfig platform operation audit log settings (not chat/tool execution bodies).
type AuditConfig struct {
// Enabled nil or true enables persistence; explicit false disables.
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
RetentionDays int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
MaxDetailBytes int `yaml:"max_detail_bytes,omitempty" json:"max_detail_bytes,omitempty"`
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
RetentionDays int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
MaxDetailBytes int `yaml:"max_detail_bytes,omitempty" json:"max_detail_bytes,omitempty"`
// AuthFailureCooldownSeconds: per-IP cooldown for auth login/change_password failure audit rows; -1 disables; 0 uses default 60.
AuthFailureCooldownSeconds int `yaml:"auth_failure_cooldown_seconds,omitempty" json:"auth_failure_cooldown_seconds,omitempty"`
}
@@ -1436,8 +1448,8 @@ func Default() *Config {
},
Agent: AgentConfig{
MaxIterations: 30, // 默认最大迭代次数
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
ShellNoOutputTimeoutSeconds: 300, // execute/exec 无新输出空闲终止(秒);-1 关闭
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
ShellNoOutputTimeoutSeconds: 300, // execute/exec 无新输出空闲终止(秒);-1 关闭
},
Security: SecurityConfig{
Tools: []ToolConfig{}, // 工具配置应该从 config.yaml 或 tools/ 目录加载
@@ -1638,7 +1650,7 @@ type RetrievalConfig struct {
TopK int `yaml:"top_k" json:"top_k"` // 检索Top-K
SimilarityThreshold float64 `yaml:"similarity_threshold" json:"similarity_threshold"` // 余弦相似度阈值
// SubIndexFilter 非空时仅保留 sub_indexes 含该标签(逗号分隔之一)的行;sub_indexes 为空的旧行仍返回。
SubIndexFilter string `yaml:"sub_index_filter,omitempty" json:"sub_index_filter,omitempty"`
SubIndexFilter string `yaml:"sub_index_filter,omitempty" json:"sub_index_filter,omitempty"`
MultiQuery MultiQueryConfig `yaml:"multi_query" json:"multi_query"`
Rerank RerankConfig `yaml:"rerank" json:"rerank"`
// PostRetrieve 检索后处理(去重、预算截断);精排在 MultiQuery 融合后执行。
@@ -1653,11 +1665,14 @@ type RolesConfig struct {
// RoleConfig 单个角色配置
type RoleConfig struct {
Name string `yaml:"name" json:"name"` // 角色名称
Description string `yaml:"description" json:"description"` // 角色描述
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName"
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
Name string `yaml:"name" json:"name"` // 角色名称
Description string `yaml:"description" json:"description"` // 角色描述
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName"
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
WorkflowID string `yaml:"workflow_id,omitempty" json:"workflow_id,omitempty"` // 可选:绑定图编排流程 ID
WorkflowVersion string `yaml:"workflow_version,omitempty" json:"workflow_version,omitempty"` // latest 或具体版本号;空等同 latest
WorkflowPolicy string `yaml:"workflow_policy,omitempty" json:"workflow_policy,omitempty"` // auto | off;空且 workflow_id 非空时按 auto
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
}
+79 -5
View File
@@ -5,8 +5,8 @@ import (
"fmt"
"os"
"path/filepath"
"sync"
"strings"
"sync"
"time"
_ "github.com/mattn/go-sqlite3"
@@ -388,9 +388,12 @@ func (db *DB) initTables() error {
status TEXT NOT NULL DEFAULT 'open',
vulnerability_type TEXT,
target TEXT,
proof TEXT,
preconditions TEXT,
reproduction_steps TEXT,
evidence TEXT,
impact TEXT,
recommendation TEXT,
retest_notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
project_id TEXT,
@@ -584,6 +587,51 @@ func (db *DB) initTables() error {
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);`
createWorkflowDefinitionsTable := `
CREATE TABLE IF NOT EXISTS workflow_definitions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
version INTEGER NOT NULL DEFAULT 1,
graph_json TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);`
createWorkflowRunsTable := `
CREATE TABLE IF NOT EXISTS workflow_runs (
id TEXT PRIMARY KEY,
workflow_id TEXT NOT NULL,
workflow_version INTEGER NOT NULL DEFAULT 1,
conversation_id TEXT,
project_id TEXT,
role_id TEXT,
status TEXT NOT NULL,
input_json TEXT,
output_json TEXT,
error TEXT,
started_at DATETIME NOT NULL,
finished_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
);`
createWorkflowNodeRunsTable := `
CREATE TABLE IF NOT EXISTS workflow_node_runs (
id TEXT PRIMARY KEY,
run_id TEXT NOT NULL,
node_id TEXT NOT NULL,
status TEXT NOT NULL,
input_json TEXT,
output_json TEXT,
error TEXT,
started_at DATETIME NOT NULL,
finished_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);`
// 创建索引
createIndexes := `
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
@@ -642,6 +690,12 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit_logs(category);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
CREATE INDEX IF NOT EXISTS idx_audit_logs_result ON audit_logs(result);
CREATE INDEX IF NOT EXISTS idx_workflow_definitions_updated_at ON workflow_definitions(updated_at);
CREATE INDEX IF NOT EXISTS idx_workflow_definitions_enabled ON workflow_definitions(enabled);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow ON workflow_runs(workflow_id);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation ON workflow_runs(conversation_id);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run ON workflow_node_runs(run_id);
`
if _, err := db.Exec(createConversationsTable); err != nil {
@@ -727,6 +781,16 @@ func (db *DB) initTables() error {
return fmt.Errorf("创建audit_logs表失败: %w", err)
}
for tableName, ddl := range map[string]string{
"workflow_definitions": createWorkflowDefinitionsTable,
"workflow_runs": createWorkflowRunsTable,
"workflow_node_runs": createWorkflowNodeRunsTable,
} {
if _, err := db.Exec(ddl); err != nil {
return fmt.Errorf("创建%s表失败: %w", tableName, err)
}
}
for tableName, ddl := range map[string]string{
"c2_listeners": createC2ListenersTable,
"c2_sessions": createC2SessionsTable,
@@ -1224,9 +1288,12 @@ func (db *DB) migrateVulnerabilitiesConversationFK() error {
status TEXT NOT NULL DEFAULT 'open',
vulnerability_type TEXT,
target TEXT,
proof TEXT,
preconditions TEXT,
reproduction_steps TEXT,
evidence TEXT,
impact TEXT,
recommendation TEXT,
retest_notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
project_id TEXT,
@@ -1239,12 +1306,15 @@ func (db *DB) migrateVulnerabilitiesConversationFK() error {
const copyRows = `
INSERT INTO vulnerabilities_new (
id, conversation_id, conversation_tag, task_tag, title, description,
severity, status, vulnerability_type, target, proof, impact, recommendation,
severity, status, vulnerability_type, target, preconditions, reproduction_steps,
evidence, impact, recommendation, retest_notes,
created_at, updated_at, project_id
)
SELECT
id, conversation_id, conversation_tag, task_tag, title, description,
severity, status, vulnerability_type, target, proof, impact, recommendation,
severity, status, vulnerability_type, target,
COALESCE(preconditions, ''), COALESCE(reproduction_steps, ''),
COALESCE(evidence, ''), impact, recommendation, COALESCE(retest_notes, ''),
created_at, updated_at, project_id
FROM vulnerabilities;`
if _, err := tx.Exec(copyRows); err != nil {
@@ -1315,6 +1385,10 @@ 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"},
{name: "preconditions", stmt: "ALTER TABLE vulnerabilities ADD COLUMN preconditions TEXT"},
{name: "reproduction_steps", stmt: "ALTER TABLE vulnerabilities ADD COLUMN reproduction_steps TEXT"},
{name: "evidence", stmt: "ALTER TABLE vulnerabilities ADD COLUMN evidence TEXT"},
{name: "retest_notes", stmt: "ALTER TABLE vulnerabilities ADD COLUMN retest_notes TEXT"},
}
for _, col := range columns {
+24 -14
View File
@@ -72,14 +72,17 @@ func (f VulnerabilityListFilter) appendWhere(query string, args []interface{}) (
LOWER(COALESCE(description, '')) LIKE LOWER(?) OR
LOWER(COALESCE(vulnerability_type, '')) LIKE LOWER(?) OR
LOWER(COALESCE(target, '')) LIKE LOWER(?) OR
LOWER(COALESCE(proof, '')) LIKE LOWER(?) OR
LOWER(COALESCE(preconditions, '')) LIKE LOWER(?) OR
LOWER(COALESCE(reproduction_steps, '')) LIKE LOWER(?) OR
LOWER(COALESCE(evidence, '')) LIKE LOWER(?) OR
LOWER(COALESCE(impact, '')) LIKE LOWER(?) OR
LOWER(COALESCE(recommendation, '')) LIKE LOWER(?) OR
LOWER(COALESCE(retest_notes, '')) LIKE LOWER(?) OR
LOWER(COALESCE(conversation_id, '')) LIKE LOWER(?) OR
LOWER(COALESCE(conversation_tag, '')) LIKE LOWER(?) OR
LOWER(COALESCE(task_tag, '')) LIKE LOWER(?)
)`
for i := 0; i < 11; i++ {
for i := 0; i < 14; i++ {
args = append(args, pattern)
}
}
@@ -101,9 +104,12 @@ type Vulnerability struct {
Status string `json:"status"` // open, confirmed, fixed, false_positive, ignored
Type string `json:"type"`
Target string `json:"target"`
Proof string `json:"proof"`
Preconditions string `json:"preconditions"`
ReproSteps string `json:"reproduction_steps"`
Evidence string `json:"evidence"`
Impact string `json:"impact"`
Recommendation string `json:"recommendation"`
RetestNotes string `json:"retest_notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -131,16 +137,16 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
query := `
INSERT INTO vulnerabilities (
id, conversation_id, project_id, conversation_tag, task_tag, title, description, severity, status,
vulnerability_type, target, proof, impact, recommendation,
vulnerability_type, target, preconditions, reproduction_steps, evidence, impact, recommendation, retest_notes,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := db.Exec(
query,
vuln.ID, nullIfEmpty(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.Preconditions, vuln.ReproSteps, vuln.Evidence, vuln.Impact, vuln.Recommendation, vuln.RetestNotes,
vuln.CreatedAt, vuln.UpdatedAt,
)
if err != nil {
@@ -155,7 +161,9 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
var vuln Vulnerability
query := `
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status,
conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation,
conversation_tag, task_tag, vulnerability_type, target,
COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''),
impact, recommendation, COALESCE(retest_notes,''),
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,
created_at, updated_at
@@ -166,7 +174,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
err := db.QueryRow(query, id).Scan(
&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.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes,
&vuln.TaskID, &vuln.TaskQueueID,
&vuln.CreatedAt, &vuln.UpdatedAt,
)
@@ -184,7 +192,9 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
query := `
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
vulnerability_type, target, proof, impact, recommendation,
vulnerability_type, target,
COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''),
impact, recommendation, COALESCE(retest_notes,''),
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,
created_at, updated_at
@@ -209,7 +219,7 @@ func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFil
err := rows.Scan(
&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.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes,
&vuln.TaskID, &vuln.TaskQueueID,
&vuln.CreatedAt, &vuln.UpdatedAt,
)
@@ -245,16 +255,16 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
query := `
UPDATE vulnerabilities
SET project_id = ?, conversation_tag = ?, task_tag = ?, title = ?, description = ?, severity = ?, status = ?,
vulnerability_type = ?, target = ?, proof = ?, impact = ?,
recommendation = ?, updated_at = ?
vulnerability_type = ?, target = ?, preconditions = ?, reproduction_steps = ?, evidence = ?, impact = ?,
recommendation = ?, retest_notes = ?, updated_at = ?
WHERE id = ?
`
_, err := db.Exec(
query,
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,
vuln.Type, vuln.Target, vuln.Preconditions, vuln.ReproSteps, vuln.Evidence, vuln.Impact,
vuln.Recommendation, vuln.RetestNotes, vuln.UpdatedAt, id,
)
if err != nil {
return fmt.Errorf("更新漏洞失败: %w", err)
+254
View File
@@ -0,0 +1,254 @@
package database
import (
"database/sql"
"fmt"
"strings"
"time"
)
// WorkflowDefinition is a persisted user-defined graph/workflow template.
// graph_json intentionally remains opaque so users can define their own fields.
type WorkflowDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version int `json:"version"`
GraphJSON string `json:"graph_json"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WorkflowRun struct {
ID string `json:"id"`
WorkflowID string `json:"workflow_id"`
WorkflowVersion int `json:"workflow_version"`
ConversationID string `json:"conversation_id,omitempty"`
ProjectID string `json:"project_id,omitempty"`
RoleID string `json:"role_id,omitempty"`
Status string `json:"status"`
InputJSON string `json:"input_json,omitempty"`
OutputJSON string `json:"output_json,omitempty"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
type WorkflowNodeRun struct {
ID string `json:"id"`
RunID string `json:"run_id"`
NodeID string `json:"node_id"`
Status string `json:"status"`
InputJSON string `json:"input_json,omitempty"`
OutputJSON string `json:"output_json,omitempty"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
func scanWorkflowDefinition(scanner interface {
Scan(dest ...interface{}) error
}) (*WorkflowDefinition, error) {
var row WorkflowDefinition
var desc sql.NullString
var enabled int
if err := scanner.Scan(&row.ID, &row.Name, &desc, &row.Version, &row.GraphJSON, &enabled, &row.CreatedAt, &row.UpdatedAt); err != nil {
return nil, err
}
row.Description = desc.String
row.Enabled = enabled != 0
return &row, nil
}
const workflowDefinitionColumns = `id, name, description, version, graph_json, enabled, created_at, updated_at`
func (db *DB) ListWorkflowDefinitions(includeDisabled bool) ([]*WorkflowDefinition, error) {
query := "SELECT " + workflowDefinitionColumns + " FROM workflow_definitions"
if !includeDisabled {
query += " WHERE enabled = 1"
}
query += " ORDER BY updated_at DESC"
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("查询工作流列表失败: %w", err)
}
defer rows.Close()
var out []*WorkflowDefinition
for rows.Next() {
wf, err := scanWorkflowDefinition(rows)
if err != nil {
return nil, fmt.Errorf("扫描工作流失败: %w", err)
}
out = append(out, wf)
}
return out, rows.Err()
}
func (db *DB) GetWorkflowDefinition(id string) (*WorkflowDefinition, error) {
id = strings.TrimSpace(id)
if id == "" {
return nil, nil
}
wf, err := scanWorkflowDefinition(db.QueryRow("SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id = ?", id))
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("查询工作流失败: %w", err)
}
return wf, nil
}
func (db *DB) UpsertWorkflowDefinition(wf *WorkflowDefinition) error {
if wf == nil {
return fmt.Errorf("工作流为空")
}
wf.ID = strings.TrimSpace(wf.ID)
wf.Name = strings.TrimSpace(wf.Name)
if wf.ID == "" || wf.Name == "" {
return fmt.Errorf("工作流 id 和 name 不能为空")
}
if strings.TrimSpace(wf.GraphJSON) == "" {
wf.GraphJSON = `{"nodes":[],"edges":[],"config":{}}`
}
if wf.Version <= 0 {
wf.Version = 1
}
now := time.Now()
existing, err := db.GetWorkflowDefinition(wf.ID)
if err != nil {
return err
}
if existing == nil {
_, err = db.Exec(
`INSERT INTO workflow_definitions (id, name, description, version, graph_json, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
wf.ID, wf.Name, wf.Description, wf.Version, wf.GraphJSON, boolToInt(wf.Enabled), now, now,
)
} else {
nextVersion := existing.Version + 1
if wf.Version > existing.Version {
nextVersion = wf.Version
}
_, err = db.Exec(
`UPDATE workflow_definitions
SET name = ?, description = ?, version = ?, graph_json = ?, enabled = ?, updated_at = ?
WHERE id = ?`,
wf.Name, wf.Description, nextVersion, wf.GraphJSON, boolToInt(wf.Enabled), now, wf.ID,
)
}
if err != nil {
return fmt.Errorf("保存工作流失败: %w", err)
}
return nil
}
func (db *DB) DeleteWorkflowDefinition(id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("工作流 id 不能为空")
}
if _, err := db.Exec("DELETE FROM workflow_definitions WHERE id = ?", id); err != nil {
return fmt.Errorf("删除工作流失败: %w", err)
}
return nil
}
func (db *DB) CreateWorkflowRun(run *WorkflowRun) error {
if run == nil {
return fmt.Errorf("工作流运行为空")
}
if strings.TrimSpace(run.ID) == "" || strings.TrimSpace(run.WorkflowID) == "" {
return fmt.Errorf("工作流运行 id 和 workflow_id 不能为空")
}
if run.WorkflowVersion <= 0 {
run.WorkflowVersion = 1
}
if strings.TrimSpace(run.Status) == "" {
run.Status = "running"
}
if run.StartedAt.IsZero() {
run.StartedAt = time.Now()
}
_, err := db.Exec(
`INSERT INTO workflow_runs (id, workflow_id, workflow_version, conversation_id, project_id, role_id, status, input_json, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
run.ID, run.WorkflowID, run.WorkflowVersion, nullString(run.ConversationID), nullString(run.ProjectID), nullString(run.RoleID), run.Status, run.InputJSON, run.StartedAt,
)
if err != nil {
return fmt.Errorf("创建工作流运行失败: %w", err)
}
return nil
}
func (db *DB) FinishWorkflowRun(runID, status, outputJSON, errText string) error {
runID = strings.TrimSpace(runID)
if runID == "" {
return fmt.Errorf("工作流运行 id 不能为空")
}
if strings.TrimSpace(status) == "" {
status = "completed"
}
now := time.Now()
_, err := db.Exec(
`UPDATE workflow_runs SET status = ?, output_json = ?, error = ?, finished_at = ? WHERE id = ?`,
status, outputJSON, errText, now, runID,
)
if err != nil {
return fmt.Errorf("更新工作流运行失败: %w", err)
}
return nil
}
func (db *DB) CreateWorkflowNodeRun(n *WorkflowNodeRun) error {
if n == nil {
return fmt.Errorf("工作流节点运行为空")
}
if strings.TrimSpace(n.ID) == "" || strings.TrimSpace(n.RunID) == "" || strings.TrimSpace(n.NodeID) == "" {
return fmt.Errorf("节点运行 id、run_id 和 node_id 不能为空")
}
if strings.TrimSpace(n.Status) == "" {
n.Status = "running"
}
if n.StartedAt.IsZero() {
n.StartedAt = time.Now()
}
_, err := db.Exec(
`INSERT INTO workflow_node_runs (id, run_id, node_id, status, input_json, started_at)
VALUES (?, ?, ?, ?, ?, ?)`,
n.ID, n.RunID, n.NodeID, n.Status, n.InputJSON, n.StartedAt,
)
if err != nil {
return fmt.Errorf("创建工作流节点运行失败: %w", err)
}
return nil
}
func (db *DB) FinishWorkflowNodeRun(nodeRunID, status, outputJSON, errText string) error {
nodeRunID = strings.TrimSpace(nodeRunID)
if nodeRunID == "" {
return fmt.Errorf("节点运行 id 不能为空")
}
if strings.TrimSpace(status) == "" {
status = "completed"
}
now := time.Now()
_, err := db.Exec(
`UPDATE workflow_node_runs SET status = ?, output_json = ?, error = ?, finished_at = ? WHERE id = ?`,
status, outputJSON, errText, now, nodeRunID,
)
if err != nil {
return fmt.Errorf("更新工作流节点运行失败: %w", err)
}
return nil
}
func nullString(v string) interface{} {
v = strings.TrimSpace(v)
if v == "" {
return nil
}
return v
}
+20 -2
View File
@@ -185,8 +185,9 @@ type AgentHandler struct {
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
batchCronParser cron.Parser
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
hitlWhitelistSaver HitlToolWhitelistSaver
hitlStrategySaver HitlAuditStrategySaver
hitlWhitelistSaver HitlToolWhitelistSaver
hitlStrategySaver HitlAuditStrategySaver
hitlDefaultReviewerSaver HitlDefaultReviewerSaver
auditLLM *openai.Client
audit *audit.Service
}
@@ -288,6 +289,23 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
h.hitlWhitelistSaver = s
}
// HitlDefaultReviewerSaver 持久化全局默认审批方到 config.yaml。
type HitlDefaultReviewerSaver interface {
UpdateHitlDefaultReviewer(reviewer string) error
}
// SetHitlDefaultReviewerSaver 设置 HITL 默认审批方落盘。
func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) {
h.hitlDefaultReviewerSaver = s
}
func (h *AgentHandler) hitlEffectiveDefaultReviewer() string {
if h != nil && h.config != nil {
return normalizeHitlReviewer(h.config.Hitl.EffectiveDefaultReviewer())
}
return "human"
}
// HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。
func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool {
if h == nil || h.hitlManager == nil {
+13
View File
@@ -1802,10 +1802,23 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
hitlNode := ensureMap(root, "hitl")
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer())
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit)
}
// UpdateHitlDefaultReviewer 更新全局默认审批方并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error {
h.mu.Lock()
defer h.mu.Unlock()
h.config.Hitl.DefaultReviewer = config.HitlConfig{DefaultReviewer: reviewer}.EffectiveDefaultReviewer()
if err := h.saveConfig(); err != nil {
return err
}
h.logger.Info("HITL 全局默认审批方已写入配置文件", zap.String("default_reviewer", h.config.Hitl.DefaultReviewer))
return nil
}
// UpdateHitlAuditAgentStrategy 更新审批/审查编辑两套审计 Agent 提示词并写入 config.yaml。
func (h *ConfigHandler) UpdateHitlAuditAgentStrategy(approvalPrompt, reviewEditPrompt string) error {
h.mu.Lock()
+6
View File
@@ -116,6 +116,9 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
"userMessageId": prep.UserMessageID,
})
}
if h.runRoleWorkflowStreamIfBound(&req, prep, sendEvent) {
return
}
var cancelWithCause context.CancelCauseFunc
curFinalMessage := prep.FinalMessage
@@ -385,6 +388,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
if h.hitlManager != nil {
defer h.hitlManager.DeactivateConversation(prep.ConversationID)
}
if h.runRoleWorkflowJSONIfBound(c, &req, prep) {
return
}
var progressBuf strings.Builder
progressCallbackRaw := func(eventType, message string, data interface{}) {
+78 -3
View File
@@ -389,6 +389,18 @@ func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLReques
}, nil
}
func (m *HITLManager) HasConversationConfig(conversationID string) (bool, error) {
if strings.TrimSpace(conversationID) == "" {
return false, nil
}
var one int
err := m.db.QueryRow(`SELECT 1 FROM hitl_conversation_configs WHERE conversation_id = ? LIMIT 1`, conversationID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
func (m *HITLManager) waitDecision(ctx context.Context, p *pendingInterrupt, timeout time.Duration) (hitlDecision, error) {
defer func() {
m.mu.Lock()
@@ -427,14 +439,32 @@ func (h *AgentHandler) activateHITLForConversation(conversationID string, req *H
return
}
if req == nil {
cfg, err := h.hitlManager.LoadConversationConfig(conversationID)
cfg, err := h.loadHITLConversationConfig(conversationID)
if err == nil {
req = cfg
}
}
if req != nil && strings.TrimSpace(req.Reviewer) == "" {
req.Reviewer = h.hitlEffectiveDefaultReviewer()
}
h.hitlManager.ActivateConversation(conversationID, h.hitlRequestWithMergedConfigWhitelist(req))
}
func (h *AgentHandler) loadHITLConversationConfig(conversationID string) (*HITLRequest, error) {
cfg, err := h.hitlManager.LoadConversationConfig(conversationID)
if err != nil {
return nil, err
}
has, err := h.hitlManager.HasConversationConfig(conversationID)
if err != nil {
return nil, err
}
if !has {
cfg.Reviewer = h.hitlEffectiveDefaultReviewer()
}
return cfg, nil
}
func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID, toolName, toolCallID string, payload map[string]interface{}, sendEventFunc func(eventType, message string, data interface{})) (*hitlDecision, error) {
cfg, need := h.hitlManager.shouldInterrupt(conversationID, toolName)
if !need {
@@ -710,7 +740,7 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "conversationId is required"})
return
}
cfg, err := h.hitlManager.LoadConversationConfig(conversationID)
cfg, err := h.loadHITLConversationConfig(conversationID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -729,6 +759,7 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"conversationId": conversationID,
"hitl": cfg,
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
})
}
@@ -741,6 +772,9 @@ func (h *AgentHandler) UpsertHITLConversationConfig(c *gin.Context) {
}
req.Mode = normalizeHitlMode(req.Mode)
req.Reviewer = normalizeHitlReviewer(req.Reviewer)
if strings.TrimSpace(req.Reviewer) == "" {
req.Reviewer = h.hitlEffectiveDefaultReviewer()
}
if err := h.hitlManager.SaveConversationConfig(req.ConversationID, &req.HITLRequest); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -769,7 +803,48 @@ type setHitlGlobalWhitelistReq struct {
// GetHITLGlobalToolWhitelist 返回 config.yaml 中的全局免审批工具白名单。
func (h *AgentHandler) GetHITLGlobalToolWhitelist(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"toolWhitelist": h.hitlConfigGlobalToolWhitelist(),
"toolWhitelist": h.hitlConfigGlobalToolWhitelist(),
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
})
}
type setHitlDefaultReviewerReq struct {
Reviewer string `json:"reviewer"`
}
// GetHITLDefaultReviewer 返回 config.yaml 中的全局默认审批方。
func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
})
}
// UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。
func (h *AgentHandler) UpdateHITLDefaultReviewer(c *gin.Context) {
if h.hitlDefaultReviewerSaver == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
return
}
var req setHitlDefaultReviewerReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
reviewer := normalizeHitlReviewer(req.Reviewer)
if err := h.hitlDefaultReviewerSaver.UpdateHitlDefaultReviewer(reviewer); err != nil {
h.logger.Warn("写入 HITL 默认审批方到 config.yaml 失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.config != nil {
h.config.Hitl.DefaultReviewer = reviewer
}
if h.audit != nil {
h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil)
}
c.JSON(http.StatusOK, gin.H{
"ok": true,
"defaultReviewer": reviewer,
})
}
+6
View File
@@ -133,6 +133,9 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
"userMessageId": prep.UserMessageID,
})
}
if h.runRoleWorkflowStreamIfBound(&req, prep, sendEvent) {
return
}
var cancelWithCause context.CancelCauseFunc
curFinalMessage := prep.FinalMessage
@@ -407,6 +410,9 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
if h.hitlManager != nil {
defer h.hitlManager.DeactivateConversation(prep.ConversationID)
}
if h.runRoleWorkflowJSONIfBound(c, &req, prep) {
return
}
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
defer cancelWithCause(nil)
+23 -23
View File
@@ -506,7 +506,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
"CreateVulnerabilityRequest": map[string]interface{}{
"type": "object",
"required": []string{"conversation_id", "title", "severity"},
"required": []string{"conversation_id", "title", "description", "severity", "type", "target", "reproduction_steps", "evidence", "impact", "recommendation"},
"properties": map[string]interface{}{
"conversation_id": map[string]interface{}{
"type": "string",
@@ -538,10 +538,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
"description": "受影响的目标",
},
"proof": map[string]interface{}{
"type": "string",
"description": "漏洞证明",
},
"preconditions": map[string]interface{}{"type": "string", "description": "前置条件"},
"reproduction_steps": map[string]interface{}{"type": "string", "description": "复现步骤"},
"evidence": map[string]interface{}{"type": "string", "description": "证据/POC,包含请求响应、命令输出、截图说明、日志等"},
"impact": map[string]interface{}{
"type": "string",
"description": "影响",
@@ -550,6 +549,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
"description": "修复建议",
},
"retest_notes": map[string]interface{}{"type": "string", "description": "复测方式"},
},
},
"UpdateVulnerabilityRequest": map[string]interface{}{
@@ -581,10 +581,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
"description": "受影响的目标",
},
"proof": map[string]interface{}{
"type": "string",
"description": "漏洞证明",
},
"preconditions": map[string]interface{}{"type": "string", "description": "前置条件"},
"reproduction_steps": map[string]interface{}{"type": "string", "description": "复现步骤"},
"evidence": map[string]interface{}{"type": "string", "description": "证据/POC,包含请求响应、命令输出、截图说明、日志等"},
"impact": map[string]interface{}{
"type": "string",
"description": "影响",
@@ -593,6 +592,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
"description": "修复建议",
},
"retest_notes": map[string]interface{}{"type": "string", "description": "复测方式"},
},
},
"ListVulnerabilitiesResponse": map[string]interface{}{
@@ -805,18 +805,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "object",
"description": "视觉分析(analyze_image MCP 工具);enabled 且 model 非空时注册工具",
"properties": map[string]interface{}{
"enabled": map[string]interface{}{"type": "boolean", "description": "是否启用 analyze_image"},
"model": map[string]interface{}{"type": "string", "description": "视觉模型名(必填)", "example": "qwen-vl-max"},
"api_key": map[string]interface{}{"type": "string", "description": "API Key;留空复用 openai.api_key"},
"base_url": map[string]interface{}{"type": "string", "description": "Base URL;留空复用 openai.base_url"},
"provider": map[string]interface{}{"type": "string", "description": "提供商;留空复用 openai.provider"},
"timeout_seconds": map[string]interface{}{"type": "integer", "description": "VL 调用超时(秒)"},
"max_image_bytes": map[string]interface{}{"type": "integer", "description": "原始文件大小上限(字节)"},
"max_dimension": map[string]interface{}{"type": "integer", "description": "长边缩放像素"},
"jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"},
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
"skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"},
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
"enabled": map[string]interface{}{"type": "boolean", "description": "是否启用 analyze_image"},
"model": map[string]interface{}{"type": "string", "description": "视觉模型名(必填)", "example": "qwen-vl-max"},
"api_key": map[string]interface{}{"type": "string", "description": "API Key;留空复用 openai.api_key"},
"base_url": map[string]interface{}{"type": "string", "description": "Base URL;留空复用 openai.base_url"},
"provider": map[string]interface{}{"type": "string", "description": "提供商;留空复用 openai.provider"},
"timeout_seconds": map[string]interface{}{"type": "integer", "description": "VL 调用超时(秒)"},
"max_image_bytes": map[string]interface{}{"type": "integer", "description": "原始文件大小上限(字节)"},
"max_dimension": map[string]interface{}{"type": "integer", "description": "长边缩放像素"},
"jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"},
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
"skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"},
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
},
},
"AnalyzeImageToolCall": map[string]interface{}{
@@ -1432,7 +1432,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
{
"name": "id", "in": "path", "required": true,
"description": "对话ID",
"schema": map[string]interface{}{"type": "string"},
"schema": map[string]interface{}{"type": "string"},
},
},
"requestBody": map[string]interface{}{
@@ -2570,7 +2570,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"type": "object",
"required": []string{"source_fact_key", "target_fact_key", "edge_type"},
"properties": map[string]interface{}{
"source_fact_key": map[string]interface{}{"type": "string"},
+72 -39
View File
@@ -45,9 +45,12 @@ type CreateVulnerabilityRequest struct {
Status string `json:"status"`
Type string `json:"type"`
Target string `json:"target"`
Proof string `json:"proof"`
Preconditions string `json:"preconditions"`
ReproSteps string `json:"reproduction_steps"`
Evidence string `json:"evidence"`
Impact string `json:"impact"`
Recommendation string `json:"recommendation"`
RetestNotes string `json:"retest_notes"`
}
// CreateVulnerability 创建漏洞
@@ -69,9 +72,12 @@ func (h *VulnerabilityHandler) CreateVulnerability(c *gin.Context) {
Status: req.Status,
Type: req.Type,
Target: req.Target,
Proof: req.Proof,
Preconditions: req.Preconditions,
ReproSteps: req.ReproSteps,
Evidence: req.Evidence,
Impact: req.Impact,
Recommendation: req.Recommendation,
RetestNotes: req.RetestNotes,
}
created, err := h.db.CreateVulnerability(vuln)
@@ -118,7 +124,7 @@ func parseVulnerabilityListFilter(c *gin.Context) database.VulnerabilityListFilt
q = strings.TrimSpace(c.Query("search"))
}
return database.VulnerabilityListFilter{
ProjectID: c.Query("project_id"),
ProjectID: c.Query("project_id"),
ID: c.Query("id"),
Search: q,
ConversationID: c.Query("conversation_id"),
@@ -197,17 +203,20 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
// UpdateVulnerabilityRequest 更新漏洞请求
type UpdateVulnerabilityRequest struct {
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"`
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"`
Preconditions *string `json:"preconditions"`
ReproSteps *string `json:"reproduction_steps"`
Evidence *string `json:"evidence"`
Impact *string `json:"impact"`
Recommendation *string `json:"recommendation"`
RetestNotes *string `json:"retest_notes"`
}
// UpdateVulnerability 更新漏洞
@@ -231,38 +240,47 @@ func (h *VulnerabilityHandler) UpdateVulnerability(c *gin.Context) {
if req.ProjectID != nil {
existing.ProjectID = strings.TrimSpace(*req.ProjectID)
}
if req.ConversationTag != "" {
existing.ConversationTag = req.ConversationTag
if req.ConversationTag != nil {
existing.ConversationTag = *req.ConversationTag
}
if req.TaskTag != "" {
existing.TaskTag = req.TaskTag
if req.TaskTag != nil {
existing.TaskTag = *req.TaskTag
}
if req.Title != "" {
existing.Title = req.Title
if req.Title != nil {
existing.Title = *req.Title
}
if req.Description != "" {
existing.Description = req.Description
if req.Description != nil {
existing.Description = *req.Description
}
if req.Severity != "" {
existing.Severity = req.Severity
if req.Severity != nil {
existing.Severity = *req.Severity
}
if req.Status != "" {
existing.Status = req.Status
if req.Status != nil {
existing.Status = *req.Status
}
if req.Type != "" {
existing.Type = req.Type
if req.Type != nil {
existing.Type = *req.Type
}
if req.Target != "" {
existing.Target = req.Target
if req.Target != nil {
existing.Target = *req.Target
}
if req.Proof != "" {
existing.Proof = req.Proof
if req.Preconditions != nil {
existing.Preconditions = *req.Preconditions
}
if req.Impact != "" {
existing.Impact = req.Impact
if req.ReproSteps != nil {
existing.ReproSteps = *req.ReproSteps
}
if req.Recommendation != "" {
existing.Recommendation = req.Recommendation
if req.Evidence != nil {
existing.Evidence = *req.Evidence
}
if req.Impact != nil {
existing.Impact = *req.Impact
}
if req.Recommendation != nil {
existing.Recommendation = *req.Recommendation
}
if req.RetestNotes != nil {
existing.RetestNotes = *req.RetestNotes
}
if err := h.db.UpdateVulnerability(id, existing); err != nil {
@@ -495,9 +513,19 @@ func appendVulnerabilityMarkdown(b *strings.Builder, v *database.Vulnerability,
b.WriteString(v.Description)
b.WriteString("\n")
}
if v.Proof != "" {
b.WriteString("\n#### 证明(POC\n\n```\n")
b.WriteString(v.Proof)
if v.Preconditions != "" {
b.WriteString("\n#### 前置条件\n\n")
b.WriteString(v.Preconditions)
b.WriteString("\n")
}
if v.ReproSteps != "" {
b.WriteString("\n#### 复现步骤\n\n")
b.WriteString(v.ReproSteps)
b.WriteString("\n")
}
if v.Evidence != "" {
b.WriteString("\n#### 证据 / POC\n\n```\n")
b.WriteString(v.Evidence)
b.WriteString("\n```\n")
}
if v.Impact != "" {
@@ -510,6 +538,11 @@ func appendVulnerabilityMarkdown(b *strings.Builder, v *database.Vulnerability,
b.WriteString(v.Recommendation)
b.WriteString("\n")
}
if v.RetestNotes != "" {
b.WriteString("\n#### 复测方式\n\n")
b.WriteString(v.RetestNotes)
b.WriteString("\n")
}
b.WriteString("\n")
}
+142
View File
@@ -0,0 +1,142 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type WorkflowHandler struct {
db *database.DB
logger *zap.Logger
audit *audit.Service
}
func NewWorkflowHandler(db *database.DB, logger *zap.Logger) *WorkflowHandler {
return &WorkflowHandler{db: db, logger: logger}
}
func (h *WorkflowHandler) SetAudit(s *audit.Service) {
h.audit = s
}
type workflowSaveRequest struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Version int `json:"version,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Graph json.RawMessage `json:"graph,omitempty"`
GraphJSON json.RawMessage `json:"graph_json,omitempty"`
}
func (h *WorkflowHandler) List(c *gin.Context) {
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"workflows": items})
}
func (h *WorkflowHandler) Get(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
wf, err := h.db.GetWorkflowDefinition(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if wf == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "工作流不存在"})
return
}
c.JSON(http.StatusOK, gin.H{"workflow": wf})
}
func (h *WorkflowHandler) Create(c *gin.Context) {
h.save(c, "")
}
func (h *WorkflowHandler) Update(c *gin.Context) {
h.save(c, c.Param("id"))
}
func (h *WorkflowHandler) save(c *gin.Context, pathID string) {
var req workflowSaveRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
id := strings.TrimSpace(req.ID)
if strings.TrimSpace(pathID) != "" {
id = strings.TrimSpace(pathID)
}
name := strings.TrimSpace(req.Name)
if id == "" || name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "工作流 id 和 name 不能为空"})
return
}
graph := req.Graph
if len(graph) == 0 {
graph = req.GraphJSON
}
if len(graph) == 0 {
graph = []byte(`{"nodes":[],"edges":[],"config":{}}`)
}
if !json.Valid(graph) {
c.JSON(http.StatusBadRequest, gin.H{"error": "graph 必须是合法 JSON"})
return
}
var probe interface{}
if err := json.Unmarshal(graph, &probe); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "graph JSON 解析失败: " + err.Error()})
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
wf := &database.WorkflowDefinition{
ID: id,
Name: name,
Description: strings.TrimSpace(req.Description),
Version: req.Version,
GraphJSON: string(graph),
Enabled: enabled,
}
if err := h.db.UpsertWorkflowDefinition(wf); err != nil {
if h.logger != nil {
h.logger.Warn("保存工作流失败", zap.String("id", id), zap.Error(err))
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
saved, _ := h.db.GetWorkflowDefinition(id)
if h.audit != nil {
h.audit.RecordOK(c, "workflow", "save", "保存图编排流程", "workflow", id, map[string]interface{}{"name": name})
}
c.JSON(http.StatusOK, gin.H{"message": "工作流已保存", "workflow": saved})
}
func (h *WorkflowHandler) Delete(c *gin.Context) {
id := strings.TrimSpace(c.Param("id"))
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "工作流 id 不能为空"})
return
}
if err := h.db.DeleteWorkflowDefinition(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "workflow", "delete", "删除图编排流程", "workflow", id, nil)
}
c.JSON(http.StatusOK, gin.H{"message": "工作流已删除"})
}
+130
View File
@@ -0,0 +1,130 @@
package handler
import (
"context"
"net/http"
"strings"
"time"
"cyberstrike-ai/internal/config"
workflowrunner "cyberstrike-ai/internal/workflow"
"github.com/gin-gonic/gin"
)
func (h *AgentHandler) roleForWorkflow(req *ChatRequest) (config.RoleConfig, bool) {
if h == nil || h.config == nil || h.config.Roles == nil || req == nil {
return config.RoleConfig{}, false
}
roleName := strings.TrimSpace(req.Role)
if roleName == "" {
return config.RoleConfig{}, false
}
role, ok := h.config.Roles[roleName]
if !ok || !role.Enabled {
return config.RoleConfig{}, false
}
if role.Name == "" {
role.Name = roleName
}
if !workflowrunner.ShouldAutoRunRoleWorkflow(role) {
return config.RoleConfig{}, false
}
return role, true
}
func (h *AgentHandler) runRoleWorkflowStreamIfBound(
req *ChatRequest,
prep *multiAgentPrepared,
sendEvent func(eventType, message string, data interface{}),
) bool {
role, ok := h.roleForWorkflow(req)
if !ok || prep == nil {
return false
}
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
defer cancelWithCause(nil)
progress := h.createProgressCallback(baseCtx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, sendEvent)
result, err := workflowrunner.RunRoleBoundWorkflow(baseCtx, workflowrunner.RunArgs{
DB: h.db,
Logger: h.logger,
Role: role,
AppCfg: h.config,
Agent: h.agent,
ConversationID: prep.ConversationID,
ProjectID: h.conversationProjectID(prep.ConversationID),
UserMessage: prep.FinalMessage,
History: prep.History,
RoleTools: prep.RoleTools,
AgentsMarkdownDir: h.agentsMarkdownDir,
SystemPromptExtra: h.agentSessionContextBlock(prep.ConversationID),
AssistantMessageID: prep.AssistantMessageID,
Progress: progress,
})
if err != nil {
errMsg := "执行角色绑定流程失败: " + err.Error()
if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
_ = h.db.AddProcessDetail(prep.AssistantMessageID, prep.ConversationID, "error", errMsg, nil)
}
sendEvent("error", errMsg, map[string]interface{}{"conversationId": prep.ConversationID})
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
return true
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
}
sendEvent("response", result.Response, map[string]interface{}{
"conversationId": prep.ConversationID,
"messageId": prep.AssistantMessageID,
"agentMode": "workflow",
"workflowRunId": result.RunID,
})
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
return true
}
func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatRequest, prep *multiAgentPrepared) bool {
role, ok := h.roleForWorkflow(req)
if !ok || prep == nil {
return false
}
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
defer cancelWithCause(nil)
progress := h.createProgressCallback(baseCtx, cancelWithCause, prep.ConversationID, prep.AssistantMessageID, nil)
result, err := workflowrunner.RunRoleBoundWorkflow(baseCtx, workflowrunner.RunArgs{
DB: h.db,
Logger: h.logger,
Role: role,
AppCfg: h.config,
Agent: h.agent,
ConversationID: prep.ConversationID,
ProjectID: h.conversationProjectID(prep.ConversationID),
UserMessage: prep.FinalMessage,
History: prep.History,
RoleTools: prep.RoleTools,
AgentsMarkdownDir: h.agentsMarkdownDir,
SystemPromptExtra: h.agentSessionContextBlock(prep.ConversationID),
AssistantMessageID: prep.AssistantMessageID,
Progress: progress,
})
if err != nil {
errMsg := "执行角色绑定流程失败: " + err.Error()
if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": prep.ConversationID})
return true
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
}
c.JSON(http.StatusOK, gin.H{
"response": result.Response,
"conversationId": prep.ConversationID,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "workflow",
"workflowRunId": result.RunID,
})
return true
}
+944
View File
@@ -0,0 +1,944 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/multiagent"
"github.com/google/uuid"
"go.uber.org/zap"
)
type RunArgs struct {
DB *database.DB
Logger *zap.Logger
Role config.RoleConfig
AppCfg *config.Config
Agent *agent.Agent
ConversationID string
ProjectID string
UserMessage string
History []agent.ChatMessage
RoleTools []string
AgentsMarkdownDir string
SystemPromptExtra string
AssistantMessageID string
Progress agent.ProgressCallback
}
type RunResult struct {
Response string
RunID string
}
type graphDef struct {
Nodes []graphNode `json:"nodes"`
Edges []graphEdge `json:"edges"`
Config map[string]any `json:"config"`
}
type graphNode struct {
ID string `json:"id"`
Type string `json:"type"`
Label string `json:"label"`
Position graphPosition `json:"position"`
Config map[string]any `json:"config"`
}
type graphEdge struct {
ID string `json:"id"`
Source string `json:"source"`
Target string `json:"target"`
Label string `json:"label"`
Config map[string]any `json:"config"`
}
type graphPosition struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type workflowExecState struct {
inputs map[string]any
outputs map[string]any
nodeOutputs map[string]map[string]any
lastOutput map[string]any
executed []string
skipped []string
workflowRunID string
// 图编排内多个 Agent 节点各自从第 1 轮上报 iteration;累计偏移避免对话页迭代序号回跳与流式条目复用错乱。
mainIterationOffset int
segmentMaxIteration int
}
// ShouldAutoRunRoleWorkflow returns true when a role explicitly binds a workflow
// and does not turn it off. Empty policy defaults to auto to keep role UX simple.
func ShouldAutoRunRoleWorkflow(role config.RoleConfig) bool {
if strings.TrimSpace(role.WorkflowID) == "" {
return false
}
policy := strings.ToLower(strings.TrimSpace(role.WorkflowPolicy))
return policy == "" || policy == "auto"
}
// RunRoleBoundWorkflow executes the persisted role-bound workflow graph.
// Control nodes are interpreted locally, tool nodes call the existing MCP bridge,
// and agent nodes reuse the existing Eino ADK runners so role-bound flows share
// the same model/tool/session behavior as the chat page.
func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) {
if args.DB == nil {
return nil, fmt.Errorf("workflow db is nil")
}
workflowID := strings.TrimSpace(args.Role.WorkflowID)
if workflowID == "" {
return nil, fmt.Errorf("角色未绑定工作流")
}
wf, err := args.DB.GetWorkflowDefinition(workflowID)
if err != nil {
return nil, err
}
if wf == nil {
return nil, fmt.Errorf("角色绑定的工作流不存在: %s", workflowID)
}
if !wf.Enabled {
return nil, fmt.Errorf("角色绑定的工作流已禁用: %s", workflowID)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
runID := uuid.NewString()
input := map[string]interface{}{
"message": args.UserMessage,
"conversationId": args.ConversationID,
"projectId": args.ProjectID,
"role": args.Role.Name,
"workflowId": wf.ID,
"workflowVersion": wf.Version,
}
inputJSON, _ := json.Marshal(input)
run := &database.WorkflowRun{
ID: runID,
WorkflowID: wf.ID,
WorkflowVersion: wf.Version,
ConversationID: args.ConversationID,
ProjectID: args.ProjectID,
RoleID: args.Role.Name,
Status: "running",
InputJSON: string(inputJSON),
StartedAt: time.Now(),
}
if err := args.DB.CreateWorkflowRun(run); err != nil {
return nil, err
}
if args.Progress != nil {
args.Progress("workflow_start", fmt.Sprintf("开始运行流程「%s」", wf.Name), map[string]interface{}{
"workflowId": wf.ID,
"workflowName": wf.Name,
"workflowVersion": wf.Version,
"workflowRunId": runID,
"conversationId": args.ConversationID,
})
}
graph, err := parseGraph(wf.GraphJSON)
if err != nil {
_ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error())
return nil, err
}
state := &workflowExecState{
inputs: input,
outputs: make(map[string]any),
nodeOutputs: make(map[string]map[string]any),
workflowRunID: runID,
}
if err := executeGraph(ctx, args, runID, graph, state); err != nil {
_ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error())
return nil, err
}
output := map[string]interface{}{
"workflowId": wf.ID,
"workflowName": wf.Name,
"workflowVersion": wf.Version,
"workflowRunId": runID,
"status": "completed",
"outputs": state.outputs,
"executedNodes": state.executed,
"skippedNodes": state.skipped,
}
outputJSON, _ := json.Marshal(output)
response := renderWorkflowResponse(args.Role.Name, wf.Name, wf.Version, runID, state)
if err := args.DB.FinishWorkflowRun(runID, "completed", string(outputJSON), ""); err != nil {
return nil, err
}
if args.Progress != nil {
args.Progress("workflow_done", fmt.Sprintf("流程「%s」运行完成", wf.Name), map[string]interface{}{
"workflowRunId": runID,
"workflowId": wf.ID,
"outputs": state.outputs,
"response": response,
})
}
if args.Logger != nil {
args.Logger.Info("role-bound workflow completed",
zap.String("workflow_id", wf.ID),
zap.String("workflow_run_id", runID),
zap.String("conversation_id", args.ConversationID),
zap.String("role", args.Role.Name),
)
}
return &RunResult{Response: response, RunID: runID}, nil
}
func parseGraph(raw string) (*graphDef, error) {
var g graphDef
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &g); err != nil {
return nil, fmt.Errorf("解析工作流图失败: %w", err)
}
if len(g.Nodes) == 0 {
return nil, fmt.Errorf("工作流没有节点")
}
if g.Config == nil {
g.Config = make(map[string]any)
}
return &g, nil
}
func executeGraph(ctx context.Context, args RunArgs, runID string, g *graphDef, state *workflowExecState) error {
nodes := make(map[string]graphNode, len(g.Nodes))
inDegree := make(map[string]int, len(g.Nodes))
outgoing := make(map[string][]graphEdge)
for _, node := range g.Nodes {
node.ID = strings.TrimSpace(node.ID)
if node.ID == "" {
continue
}
if strings.TrimSpace(node.Type) == "" {
node.Type = "tool"
}
if node.Config == nil {
node.Config = make(map[string]any)
}
nodes[node.ID] = node
inDegree[node.ID] = 0
}
for _, edge := range g.Edges {
if _, ok := nodes[edge.Source]; !ok {
continue
}
if _, ok := nodes[edge.Target]; !ok {
continue
}
outgoing[edge.Source] = append(outgoing[edge.Source], edge)
inDegree[edge.Target]++
}
for source := range outgoing {
sort.SliceStable(outgoing[source], func(i, j int) bool {
a := nodes[outgoing[source][i].Target]
b := nodes[outgoing[source][j].Target]
if a.Position.Y != b.Position.Y {
return a.Position.Y < b.Position.Y
}
if a.Position.X != b.Position.X {
return a.Position.X < b.Position.X
}
return outgoing[source][i].Target < outgoing[source][j].Target
})
}
var queue []string
for id, node := range nodes {
if strings.EqualFold(node.Type, "start") {
queue = append(queue, id)
}
}
if len(queue) == 0 {
for id, deg := range inDegree {
if deg == 0 {
queue = append(queue, id)
}
}
}
sortNodeIDsByCanvas(queue, nodes)
seen := make(map[string]bool)
remainingIncoming := make(map[string]int, len(inDegree))
for id, deg := range inDegree {
remainingIncoming[id] = deg
}
for len(queue) > 0 {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
id := queue[0]
queue = queue[1:]
if seen[id] {
continue
}
seen[id] = true
node := nodes[id]
result, proceed, err := executeNode(ctx, args, runID, node, state)
if err != nil {
return err
}
state.nodeOutputs[id] = result
state.lastOutput = result
if proceed {
edges := outgoing[id]
if strings.EqualFold(node.Type, "condition") {
emitConditionBranchProgress(args, runID, node, edges, nodes, state)
}
for edgeIdx, edge := range edges {
if !edgeAllowed(edge, node, edgeIdx, state) {
continue
}
remainingIncoming[edge.Target]--
if remainingIncoming[edge.Target] > 0 {
continue
}
queue = append(queue, edge.Target)
}
sortNodeIDsByCanvas(queue, nodes)
}
}
return nil
}
func sortNodeIDsByCanvas(ids []string, nodes map[string]graphNode) {
sort.SliceStable(ids, func(i, j int) bool {
a := nodes[ids[i]]
b := nodes[ids[j]]
if a.Position.Y != b.Position.Y {
return a.Position.Y < b.Position.Y
}
if a.Position.X != b.Position.X {
return a.Position.X < b.Position.X
}
return ids[i] < ids[j]
})
}
func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode, state *workflowExecState) (map[string]any, bool, error) {
label := node.Label
if strings.TrimSpace(label) == "" {
label = node.ID
}
nodeRunID := uuid.NewString()
input := map[string]any{
"nodeId": node.ID,
"nodeType": node.Type,
"label": label,
"inputs": state.inputs,
"previous": state.lastOutput,
}
inputJSON, _ := json.Marshal(input)
if err := args.DB.CreateWorkflowNodeRun(&database.WorkflowNodeRun{
ID: nodeRunID,
RunID: runID,
NodeID: node.ID,
Status: "running",
InputJSON: string(inputJSON),
StartedAt: time.Now(),
}); err != nil {
return nil, false, err
}
if args.Progress != nil {
args.Progress("workflow_node_start", fmt.Sprintf("开始节点:%s", label), map[string]any{
"workflowRunId": runID,
"nodeRunId": nodeRunID,
"nodeId": node.ID,
"nodeType": node.Type,
"label": label,
})
}
result, proceed, status, errText := runBuiltinNode(ctx, args, node, state)
outputJSON, _ := json.Marshal(result)
if err := args.DB.FinishWorkflowNodeRun(nodeRunID, status, string(outputJSON), errText); err != nil {
return nil, false, err
}
if status == "skipped" {
state.skipped = append(state.skipped, label)
} else {
state.executed = append(state.executed, label)
}
if args.Progress != nil {
progressData := map[string]any{
"workflowRunId": runID,
"nodeRunId": nodeRunID,
"nodeId": node.ID,
"nodeType": node.Type,
"label": label,
"status": status,
"output": result,
}
progressMsg := fmt.Sprintf("节点完成:%s%s", label, status)
if strings.EqualFold(node.Type, "condition") {
matched := false
if v, ok := result["matched"].(bool); ok {
matched = v
}
expr := cfgString(node.Config, "expression")
if matched {
progressMsg = fmt.Sprintf("条件判断:%s → 是", label)
} else {
progressMsg = fmt.Sprintf("条件判断:%s → 否", label)
}
progressData["expression"] = expr
progressData["matched"] = matched
}
args.Progress("workflow_node_result", progressMsg, progressData)
}
return result, proceed, nil
}
func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *workflowExecState) (map[string]any, bool, string, string) {
cfg := node.Config
switch strings.ToLower(strings.TrimSpace(node.Type)) {
case "start":
out := map[string]any{
"output": state.inputs["message"],
"message": state.inputs["message"],
"conversationId": state.inputs["conversationId"],
"projectId": state.inputs["projectId"],
}
return out, true, "completed", ""
case "condition":
expr := cfgString(cfg, "expression")
ok := evalCondition(expr, state)
out := map[string]any{"output": ok, "condition": expr, "matched": ok}
// 条件节点始终继续,由出边条件(或连线标签/顺序)决定走「是/否」分支。
return out, true, "completed", ""
case "output":
key := cfgString(cfg, "output_key")
if key == "" {
key = "result"
}
value := resolveTemplate(cfgString(cfg, "source"), state)
state.outputs[key] = value
return map[string]any{"output": value, "outputs": map[string]any{key: value}}, true, "completed", ""
case "end":
value := resolveTemplate(cfgString(cfg, "result_template"), state)
return map[string]any{"output": value}, false, "completed", ""
case "tool":
return runToolNode(ctx, args, node, state)
case "agent":
return runAgentNode(ctx, args, node, state)
case "hitl":
return runHITLNode(args, node, state)
default:
reason := "未知节点类型"
return map[string]any{"output": "", "skipped": true, "reason": reason, "node_type": node.Type}, true, "skipped", reason
}
}
func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *workflowExecState) (map[string]any, bool, string, string) {
toolName := cfgString(node.Config, "tool_name")
if toolName == "" {
errText := "工具节点未选择 MCP 工具"
return map[string]any{"output": "", "error": errText}, false, "failed", errText
}
if args.Agent == nil {
errText := "工具节点执行失败:Agent 为空"
return map[string]any{"output": "", "tool_name": toolName, "error": errText}, false, "failed", errText
}
toolArgs, err := parseToolArguments(cfgString(node.Config, "arguments"), state)
if err != nil {
errText := fmt.Sprintf("工具参数不是合法 JSON%v", err)
return map[string]any{"output": "", "tool_name": toolName, "error": errText}, false, "failed", errText
}
if args.Progress != nil {
args.Progress("workflow_tool_start", fmt.Sprintf("调用工具:%s", toolName), map[string]any{
"nodeId": node.ID,
"tool": toolName,
"args": toolArgs,
})
}
result, err := args.Agent.ExecuteMCPToolForConversation(ctx, args.ConversationID, toolName, toolArgs)
if err != nil {
errText := err.Error()
return map[string]any{"output": "", "tool_name": toolName, "arguments": toolArgs, "error": errText}, false, "failed", errText
}
output := ""
executionID := ""
isError := false
if result != nil {
output = result.Result
executionID = result.ExecutionID
isError = result.IsError
}
out := map[string]any{
"output": output,
"tool_name": toolName,
"arguments": toolArgs,
"execution_id": executionID,
"is_error": isError,
}
if key := cfgString(node.Config, "output_key"); key != "" {
state.outputs[key] = output
}
if isError {
errText := strings.TrimSpace(output)
if errText == "" {
errText = "工具返回错误"
}
return out, false, "failed", errText
}
return out, true, "completed", ""
}
func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *workflowExecState) (map[string]any, bool, string, string) {
if args.AppCfg == nil || args.Agent == nil {
errText := "Agent 节点执行失败:应用配置或 Agent 为空"
return map[string]any{"output": "", "error": errText}, false, "failed", errText
}
mode := strings.ToLower(cfgString(node.Config, "agent_mode"))
if mode == "" {
mode = "eino_single"
}
inputSource := cfgString(node.Config, "input_source")
if inputSource == "" {
inputSource = "{{previous.output}}"
}
upstreamInput := strings.TrimSpace(resolveTemplate(inputSource, state))
message := buildAgentNodeMessage(node, state)
var result *multiagent.RunResult
var err error
state.segmentMaxIteration = 0
agentProgress := workflowAgentProgress(args.Progress, state, node)
switch mode {
case "eino_single", "single", "chat":
result, err = multiagent.RunEinoSingleChatModelAgent(
ctx,
args.AppCfg,
&args.AppCfg.MultiAgent,
args.Agent,
args.DB,
args.Logger,
args.ConversationID,
args.ProjectID,
message,
args.History,
args.RoleTools,
agentProgress,
nil,
args.SystemPromptExtra,
)
default:
result, err = multiagent.RunDeepAgent(
ctx,
args.AppCfg,
&args.AppCfg.MultiAgent,
args.Agent,
args.DB,
args.Logger,
args.ConversationID,
args.ProjectID,
message,
args.History,
args.RoleTools,
agentProgress,
args.AgentsMarkdownDir,
mode,
nil,
args.SystemPromptExtra,
)
}
if err != nil {
errText := err.Error()
state.mainIterationOffset += state.segmentMaxIteration
return map[string]any{"output": "", "mode": mode, "error": errText}, false, "failed", errText
}
state.mainIterationOffset += state.segmentMaxIteration
response := ""
mcpIDs := []string{}
if result != nil {
response = result.Response
mcpIDs = result.MCPExecutionIDs
}
if args.Progress != nil {
args.Progress("workflow_agent_output", response, map[string]any{
"nodeId": node.ID,
"label": firstNonEmpty(node.Label, node.ID),
"mode": mode,
"inputSource": inputSource,
"inputPreview": truncateWorkflowPreview(upstreamInput, 500),
"mcpExecutionIds": mcpIDs,
})
}
if key := cfgString(node.Config, "output_key"); key != "" {
state.outputs[key] = response
}
return map[string]any{
"output": response,
"mode": mode,
"mcp_execution_ids": mcpIDs,
}, true, "completed", ""
}
func buildAgentNodeMessage(node graphNode, state *workflowExecState) string {
instruction := strings.TrimSpace(resolveTemplate(cfgString(node.Config, "instruction"), state))
inputSource := cfgString(node.Config, "input_source")
if inputSource == "" {
inputSource = "{{previous.output}}"
}
upstreamInput := strings.TrimSpace(resolveTemplate(inputSource, state))
if instruction == "" {
if upstreamInput != "" {
return fmt.Sprintf("请基于上游节点输出继续处理:\n%s", upstreamInput)
}
return fmt.Sprintf("请基于上游节点输出继续处理:\n%v", state.lastOutput["output"])
}
if upstreamInput == "" {
return instruction
}
return strings.TrimSpace(fmt.Sprintf("上游输入:\n%s\n\n节点指令:\n%s", upstreamInput, instruction))
}
func workflowAgentProgress(progress agent.ProgressCallback, state *workflowExecState, node graphNode) agent.ProgressCallback {
if progress == nil {
return nil
}
return func(eventType, message string, data interface{}) {
switch eventType {
case "response_start", "response_delta", "response", "done":
return
default:
enrichWorkflowAgentEventData(data, state, node)
if eventType == "iteration" {
applyWorkflowMainIterationOffset(data, state)
}
progress(eventType, message, data)
}
}
}
func enrichWorkflowAgentEventData(data interface{}, state *workflowExecState, node graphNode) {
m, ok := data.(map[string]interface{})
if !ok || m == nil {
return
}
if node.ID != "" {
m["workflowNodeId"] = node.ID
}
if state != nil && strings.TrimSpace(state.workflowRunID) != "" {
m["workflowRunId"] = state.workflowRunID
}
}
func applyWorkflowMainIterationOffset(data interface{}, state *workflowExecState) {
if state == nil {
return
}
m, ok := data.(map[string]interface{})
if !ok || m == nil {
return
}
scope, _ := m["einoScope"].(string)
if strings.TrimSpace(scope) != "main" {
return
}
raw := iterationNumberFromProgressData(m)
if raw <= 0 {
return
}
if raw > state.segmentMaxIteration {
state.segmentMaxIteration = raw
}
m["iteration"] = raw + state.mainIterationOffset
}
func iterationNumberFromProgressData(m map[string]interface{}) int {
switch v := m["iteration"].(type) {
case int:
return v
case int32:
return int(v)
case int64:
return int(v)
case float64:
return int(v)
case float32:
return int(v)
default:
return 0
}
}
func runHITLNode(args RunArgs, node graphNode, state *workflowExecState) (map[string]any, bool, string, string) {
prompt := resolveTemplate(cfgString(node.Config, "prompt"), state)
reviewer := cfgString(node.Config, "reviewer")
if args.Progress != nil {
args.Progress("workflow_hitl_checkpoint", "人工确认节点已记录", map[string]any{
"nodeId": node.ID,
"prompt": prompt,
"reviewer": reviewer,
"mode": "record_only",
})
}
return map[string]any{
"output": prompt,
"prompt": prompt,
"reviewer": reviewer,
"approved": true,
"mode": "record_only",
}, true, "completed", ""
}
func parseToolArguments(raw string, state *workflowExecState) (map[string]interface{}, error) {
if raw == "" {
return map[string]interface{}{}, nil
}
raw = strings.TrimSpace(resolveTemplate(raw, state))
if raw == "" {
return map[string]interface{}{}, nil
}
var args map[string]interface{}
if err := json.Unmarshal([]byte(raw), &args); err != nil {
return nil, err
}
if args == nil {
args = map[string]interface{}{}
}
return args, nil
}
func edgeAllowed(edge graphEdge, sourceNode graphNode, edgeIndex int, state *workflowExecState) bool {
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
if cond != "" {
return evalCondition(cond, state)
}
if strings.EqualFold(strings.TrimSpace(sourceNode.Type), "condition") {
return conditionBranchAllowed(edge, edgeIndex, state)
}
return true
}
func conditionBranchAllowed(edge graphEdge, edgeIndex int, state *workflowExecState) bool {
matched := conditionMatched(state)
if branch := conditionBranchHint(edge); branch != "" {
return (branch == "true" && matched) || (branch == "false" && !matched)
}
switch edgeIndex {
case 0:
return matched
case 1:
return !matched
default:
return false
}
}
func conditionMatched(state *workflowExecState) bool {
v := strings.ToLower(cleanComparable(fmt.Sprint(valueFromPath("previous.matched", state))))
return v == "true" || v == "1"
}
func conditionBranchHint(edge graphEdge) string {
if edge.Config != nil {
switch strings.ToLower(strings.TrimSpace(cfgString(edge.Config, "branch"))) {
case "true", "yes", "y", "是":
return "true"
case "false", "no", "n", "否":
return "false"
}
}
switch strings.ToLower(strings.TrimSpace(edge.Label)) {
case "true", "yes", "y", "是":
return "true"
case "false", "no", "n", "否":
return "false"
}
return ""
}
func emitConditionBranchProgress(args RunArgs, runID string, node graphNode, edges []graphEdge, nodes map[string]graphNode, state *workflowExecState) {
if args.Progress == nil || len(edges) == 0 {
return
}
for edgeIdx, edge := range edges {
allowed := edgeAllowed(edge, node, edgeIdx, state)
target := nodes[edge.Target]
targetLabel := strings.TrimSpace(target.Label)
if targetLabel == "" {
targetLabel = edge.Target
}
branchLabel := strings.TrimSpace(edge.Label)
if branchLabel == "" {
switch edgeIdx {
case 0:
branchLabel = "是"
case 1:
branchLabel = "否"
default:
branchLabel = fmt.Sprintf("分支 %d", edgeIdx+1)
}
}
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
eventType := "workflow_branch_skipped"
msg := fmt.Sprintf("跳过分支「%s」→ %s", branchLabel, targetLabel)
if allowed {
eventType = "workflow_branch_taken"
msg = fmt.Sprintf("执行分支「%s」→ %s", branchLabel, targetLabel)
}
args.Progress(eventType, msg, map[string]any{
"workflowRunId": runID,
"nodeId": node.ID,
"nodeType": node.Type,
"label": node.Label,
"branchLabel": branchLabel,
"targetId": edge.Target,
"targetLabel": targetLabel,
"edgeCondition": cond,
"matched": conditionMatched(state),
})
}
}
func cfgString(cfg map[string]any, key string) string {
if cfg == nil {
return ""
}
if v, ok := cfg[key]; ok {
return strings.TrimSpace(fmt.Sprint(v))
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if s := strings.TrimSpace(value); s != "" {
return s
}
}
return ""
}
func truncateWorkflowPreview(s string, limit int) string {
s = strings.TrimSpace(s)
if limit <= 0 || len([]rune(s)) <= limit {
return s
}
runes := []rune(s)
return string(runes[:limit]) + "..."
}
var templateVarRe = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}`)
func resolveTemplate(s string, state *workflowExecState) string {
if strings.TrimSpace(s) == "" {
return fmt.Sprint(valueFromPath("previous.output", state))
}
return templateVarRe.ReplaceAllStringFunc(s, func(match string) string {
m := templateVarRe.FindStringSubmatch(match)
if len(m) != 2 {
return match
}
return fmt.Sprint(valueFromPath(m[1], state))
})
}
func valueFromPath(path string, state *workflowExecState) any {
parts := strings.Split(path, ".")
if len(parts) == 0 {
return ""
}
var cur any
switch parts[0] {
case "inputs", "input":
cur = state.inputs
case "previous", "prev":
cur = state.lastOutput
case "outputs":
cur = state.outputs
default:
if v, ok := state.inputs[parts[0]]; ok {
cur = v
} else if v, ok := state.nodeOutputs[parts[0]]; ok {
cur = v
} else {
return ""
}
}
for _, p := range parts[1:] {
m, ok := cur.(map[string]any)
if !ok {
return ""
}
cur = m[p]
}
if cur == nil {
return ""
}
return cur
}
func evalCondition(expr string, state *workflowExecState) bool {
expr = strings.TrimSpace(expr)
if expr == "" {
return true
}
resolved := strings.TrimSpace(resolveTemplate(expr, state))
switch {
case strings.Contains(resolved, "!="):
parts := strings.SplitN(resolved, "!=", 2)
return cleanComparable(parts[0]) != cleanComparable(parts[1])
case strings.Contains(resolved, "=="):
parts := strings.SplitN(resolved, "==", 2)
return cleanComparable(parts[0]) == cleanComparable(parts[1])
default:
v := strings.ToLower(cleanComparable(resolved))
return v != "" && v != "false" && v != "0" && v != "null"
}
}
func cleanComparable(s string) string {
s = strings.TrimSpace(s)
s = strings.Trim(s, `"'`)
return s
}
func renderWorkflowResponse(roleName, workflowName string, version int, runID string, state *workflowExecState) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("角色「%s」已完成工作流「%s」(版本 %d)。\n\n", roleName, workflowName, version))
sb.WriteString(fmt.Sprintf("运行 ID%s\n", runID))
sb.WriteString(fmt.Sprintf("已执行节点:%d", len(state.executed)))
if len(state.skipped) > 0 {
sb.WriteString(fmt.Sprintf(",跳过节点:%d", len(state.skipped)))
}
sb.WriteString("\n\n")
if len(state.outputs) > 0 {
sb.WriteString("输出:\n")
keys := make([]string, 0, len(state.outputs))
for k := range state.outputs {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
sb.WriteString(fmt.Sprintf("- %s%v\n", k, state.outputs[k]))
}
} else {
sb.WriteString("暂无输出。请检查是否配置了输出节点,或条件分支是否命中。\n")
}
if len(state.skipped) > 0 {
sb.WriteString("\n未执行的节点类型仍会保留运行记录:")
sb.WriteString(strings.Join(state.skipped, "、"))
sb.WriteString("。")
}
return strings.TrimSpace(sb.String())
}
+855 -15
View File
File diff suppressed because it is too large Load Diff
+25 -13
View File
@@ -683,7 +683,7 @@
"hitl": {
"pageTitle": "HITL approvals",
"pageReviewerLabel": "Current reviewer",
"pageReviewerHint": "Applies to the selected conversation. Without a conversation, saved locally for new chats. Takes effect immediately.",
"pageReviewerHint": "Applies to the selected conversation. Without a conversation, saved to config.yaml as the global default for new chats. Takes effect immediately.",
"pageReviewerSaved": "Reviewer saved.",
"whitelistLabel": "Tool whitelist (no approval)",
"whitelistHint": "One per line or comma-separated. Saved to config.yaml global whitelist and takes effect immediately (synced with chat sidebar).",
@@ -1915,7 +1915,7 @@
},
"chatFilesPage": {
"title": "File Management",
"intro": "Files uploaded in chat appear here. Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.",
"intro": "Files uploaded in chat appear here. Drag files into the list below, or click Upload to pick files (multiple allowed). Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.",
"upload": "Upload",
"conversationFilter": "Conversation ID",
"conversationPlaceholder": "Leave empty for all",
@@ -2027,7 +2027,7 @@
"exportNoResults": "No vulnerabilities match the current filters",
"exportStarted": "Started downloading {{count}} file(s)",
"exportFailed": "Export failed",
"saveRequiredFields": "Please fill in conversation ID, title, and severity",
"saveRequiredFields": "Please fill in conversation ID, title, description, severity, type, target, reproduction steps, evidence/POC, impact, and remediation",
"saveFailed": "Save failed",
"fetchFailed": "Failed to fetch vulnerability",
"deleteFailed": "Delete failed",
@@ -2046,9 +2046,12 @@
"detailTaskQueueId": "Task queue ID",
"detailConversationTag": "Conversation tag",
"detailTaskTag": "Task tag",
"detailProof": "Proof",
"detailPreconditions": "Preconditions",
"detailReproductionSteps": "Reproduction steps",
"detailEvidence": "Evidence / POC",
"detailImpact": "Impact",
"detailRecommendation": "Remediation",
"detailRetestNotes": "Retest method",
"downloadOkTitle": "Downloaded",
"exportFailedMessage": "Export failed",
"downloadFailed": "Download failed"
@@ -2800,9 +2803,9 @@
"taskTag": "Task tag",
"taskTagPlaceholder": "e.g. batch scan Q2, retest",
"title": "Title",
"titlePlaceholder": "Vulnerability title",
"titlePlaceholder": "/api/login is vulnerable to SQL injection",
"description": "Description",
"descriptionPlaceholder": "Detailed description",
"descriptionPlaceholder": "Describe the summary, trigger point, observed abnormal behavior, and why it is exploitable.",
"severity": "Severity",
"pleaseSelect": "Please select",
"severityCritical": "Critical",
@@ -2819,13 +2822,19 @@
"type": "Vulnerability type",
"typePlaceholder": "e.g. SQL injection, XSS, CSRF",
"target": "Target",
"targetPlaceholder": "Affected target (URL, IP, etc.)",
"proof": "Proof (POC)",
"proofPlaceholder": "Proof: request/response, screenshots, etc.",
"targetPlaceholder": "Be specific: URL, IP:port, endpoint path, and parameter name.",
"preconditions": "Preconditions",
"preconditionsPlaceholder": "Login state, permissions, account, headers/cookies, required data, environment/version; write none if not needed.",
"reproductionSteps": "Reproduction steps",
"reproductionStepsPlaceholder": "Number the steps and include entry point, parameter, payload, command, and observation point.",
"evidence": "Evidence / POC",
"evidencePlaceholder": "Raw request/response, curl/tool command, screenshot notes, logs, DNSLog/callback records, database results, file paths, timestamps, etc.",
"impact": "Impact",
"impactPlaceholder": "Impact description",
"impactPlaceholder": "Describe the verified real-world impact, such as which data can be read or changed.",
"recommendation": "Recommendation",
"recommendationPlaceholder": "Remediation"
"recommendationPlaceholder": "Write the concrete fix and retest criteria.",
"retestNotes": "Retest method",
"retestNotesPlaceholder": "How to verify the fix, including expected status code, error message, or access-control result."
},
"vulnerabilityMd": {
"headingBasic": "Basic information",
@@ -2842,9 +2851,12 @@
"labelCreated": "Created at",
"labelUpdated": "Updated at",
"headingDescription": "Description",
"headingProof": "Proof (POC)",
"headingPreconditions": "Preconditions",
"headingReproductionSteps": "Reproduction steps",
"headingEvidence": "Evidence / POC",
"headingImpact": "Impact",
"headingRecommendation": "Remediation"
"headingRecommendation": "Remediation",
"headingRetestNotes": "Retest method"
},
"roleModal": {
"addRole": "Add role",
+25 -13
View File
@@ -671,7 +671,7 @@
"hitl": {
"pageTitle": "人机协同审批",
"pageReviewerLabel": "当前审批方",
"pageReviewerHint": "作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。",
"pageReviewerHint": "作用于当前选中会话;未选会话时写入 config.yaml 作为全局默认,新建会话时沿用。切换后立即生效。",
"pageReviewerSaved": "审批方已保存。",
"whitelistLabel": "免审批工具白名单",
"whitelistHint": "每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。",
@@ -1903,7 +1903,7 @@
},
"chatFilesPage": {
"title": "文件管理",
"intro": "管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
"intro": "管理在对话中上传的文件。可将文件拖拽到下方列表区域,或点击「上传文件」选择文件(支持多选)。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
"upload": "上传文件",
"conversationFilter": "会话 ID",
"conversationPlaceholder": "留空表示全部",
@@ -2015,7 +2015,7 @@
"exportNoResults": "当前筛选条件下无可导出漏洞",
"exportStarted": "已开始下载 {{count}} 份报告",
"exportFailed": "导出失败",
"saveRequiredFields": "请填写必填字段:会话ID、标题和严重程度",
"saveRequiredFields": "请填写必填字段:会话ID、标题、描述、严重程度、漏洞类型、目标、复现步骤、证据/POC、影响和修复建议",
"saveFailed": "保存失败",
"fetchFailed": "获取漏洞失败",
"deleteFailed": "删除失败",
@@ -2034,9 +2034,12 @@
"detailTaskQueueId": "任务队列ID",
"detailConversationTag": "对话标签",
"detailTaskTag": "任务标签",
"detailProof": "证明",
"detailPreconditions": "前置条件",
"detailReproductionSteps": "复现步骤",
"detailEvidence": "证据 / POC",
"detailImpact": "影响",
"detailRecommendation": "修复建议",
"detailRetestNotes": "复测方式",
"downloadOkTitle": "下载成功",
"exportFailedMessage": "导出失败",
"downloadFailed": "下载失败"
@@ -2788,9 +2791,9 @@
"taskTag": "任务标签",
"taskTagPlaceholder": "如:批量扫描Q2、专项复测",
"title": "标题",
"titlePlaceholder": "漏洞标题",
"titlePlaceholder": "/api/login 存在 SQL 注入",
"description": "描述",
"descriptionPlaceholder": "漏洞详细描述",
"descriptionPlaceholder": "说明漏洞摘要、触发点、异常现象和为什么可被利用。",
"severity": "严重程度",
"pleaseSelect": "请选择",
"severityCritical": "严重",
@@ -2807,13 +2810,19 @@
"type": "漏洞类型",
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
"target": "目标",
"targetPlaceholder": "受影响的目标(URLIP地址等)",
"proof": "证明(POC",
"proofPlaceholder": "漏洞证明,如请求/响应、截图等",
"targetPlaceholder": "精确到 URL/IP:端口/接口路径/参数名",
"preconditions": "前置条件",
"preconditionsPlaceholder": "登录状态、权限、账号、Header/Cookie、特定数据、环境/版本;无则写无。",
"reproductionSteps": "复现步骤",
"reproductionStepsPlaceholder": "按 1/2/3 编号,写清入口、参数、payload、执行命令、观察点。",
"evidence": "证据 / POC",
"evidencePlaceholder": "原始请求/响应、curl/工具命令、截图说明、日志、DNSLog/回连记录、数据库结果、文件路径、时间戳等。",
"impact": "影响",
"impactPlaceholder": "漏洞影响说明",
"impactPlaceholder": "结合已验证事实说明实际影响,例如越权读取哪些数据。",
"recommendation": "修复建议",
"recommendationPlaceholder": "修复建议"
"recommendationPlaceholder": "写具体修复点和复测标准。",
"retestNotes": "复测方式",
"retestNotesPlaceholder": "修复后如何验证漏洞已关闭,包括应返回的状态码、错误信息或访问控制结果。"
},
"vulnerabilityMd": {
"headingBasic": "基本信息",
@@ -2830,9 +2839,12 @@
"labelCreated": "创建时间",
"labelUpdated": "更新时间",
"headingDescription": "描述",
"headingProof": "证明(POC",
"headingPreconditions": "前置条件",
"headingReproductionSteps": "复现步骤",
"headingEvidence": "证据 / POC",
"headingImpact": "影响",
"headingRecommendation": "修复建议"
"headingRecommendation": "修复建议",
"headingRetestNotes": "复测方式"
},
"roleModal": {
"addRole": "添加角色",
+83 -12
View File
@@ -84,6 +84,7 @@ function initChatFilesPage() {
/* ignore */
}
}
setupChatFilesDragDrop();
loadChatFilesPage();
}
@@ -1226,21 +1227,31 @@ function chatFilesUploadToFolderClick(ev, btn) {
if (inp) inp.click();
}
async function onChatFilesUploadPick(ev) {
const input = ev.target;
const file = input && input.files && input.files[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
function chatFilesResolveUploadTarget() {
const pendingDir = chatFilesPendingUploadDir;
chatFilesPendingUploadDir = '';
if (pendingDir) {
form.append('relativeDir', pendingDir);
} else {
const conv = document.getElementById('chat-files-filter-conv');
if (conv && conv.value.trim()) {
form.append('conversationId', conv.value.trim());
}
return { relativeDir: pendingDir };
}
if (chatFilesGetGroupByMode() === 'folder') {
const dir = chatFilesBrowsePath.join('/');
return dir ? { relativeDir: dir } : {};
}
const conv = document.getElementById('chat-files-filter-conv');
if (conv && conv.value.trim()) {
return { conversationId: conv.value.trim() };
}
return {};
}
async function chatFilesUploadFile(file, target) {
if (!file || chatFilesXHRUploadBusy) return false;
const form = new FormData();
form.append('file', file);
if (target && target.relativeDir) {
form.append('relativeDir', target.relativeDir);
} else if (target && target.conversationId) {
form.append('conversationId', target.conversationId);
}
chatFilesSetUploadBusy(true);
chatFilesSetUploadProgressUI(true, 0, file.name);
@@ -1265,15 +1276,75 @@ async function onChatFilesUploadPick(ev) {
: '上传成功。在列表中点击「复制路径」即可粘贴到对话中引用。';
chatFilesShowToast(msg);
}
return true;
} catch (e) {
alert((e && e.message) ? e.message : String(e));
return false;
} finally {
chatFilesSetUploadBusy(false);
chatFilesSetUploadProgressUI(false);
}
}
async function chatFilesUploadFiles(fileList) {
if (!fileList || !fileList.length || chatFilesXHRUploadBusy) return;
const files = Array.from(fileList).filter(function (f) {
return f && (f.name || f.size > 0);
});
if (!files.length) return;
const target = chatFilesResolveUploadTarget();
for (let i = 0; i < files.length; i++) {
const ok = await chatFilesUploadFile(files[i], target);
if (!ok) break;
}
}
async function onChatFilesUploadPick(ev) {
const input = ev.target;
const files = input && input.files;
if (!files || !files.length) return;
try {
await chatFilesUploadFiles(files);
} finally {
input.value = '';
}
}
let chatFilesDragDropBound = false;
function setupChatFilesDragDrop() {
if (chatFilesDragDropBound) return;
const wrap = document.getElementById('chat-files-list-wrap');
if (!wrap) return;
chatFilesDragDropBound = true;
wrap.addEventListener('dragover', function (e) {
e.preventDefault();
e.stopPropagation();
if (chatFilesXHRUploadBusy) return;
this.classList.add('drag-over');
});
wrap.addEventListener('dragleave', function (e) {
e.preventDefault();
e.stopPropagation();
if (!this.contains(e.relatedTarget)) {
this.classList.remove('drag-over');
}
});
wrap.addEventListener('drop', function (e) {
e.preventDefault();
e.stopPropagation();
this.classList.remove('drag-over');
if (chatFilesXHRUploadBusy) return;
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length) {
chatFilesUploadFiles(files).catch(function (err) {
if (err) alert((err && err.message) ? err.message : String(err));
});
}
});
}
// 语言切换后重新渲染列表:表头与「更多」菜单由 JS 拼接,无 data-i18n,需用当前语言的 t() 再生成一遍
document.addEventListener('languagechange', function () {
if (typeof window.currentPage !== 'function') return;
+74 -20
View File
@@ -137,9 +137,12 @@ function normalizeHitlMode(mode) {
}
function defaultHitlConfig() {
const serverReviewer = (typeof window !== 'undefined' && window.csaiHitlDefaultReviewer)
? window.csaiHitlDefaultReviewer
: 'human';
return {
mode: HITL_MODE_OFF,
reviewer: 'human',
reviewer: normalizeHitlReviewer(serverReviewer),
sensitiveTools: '',
updatedAt: ''
};
@@ -315,16 +318,18 @@ async function onHitlReviewerChanged(reviewer) {
const cfg = readHitlConfigFromForm();
const cid = typeof currentConversationId === 'string' ? currentConversationId.trim() : '';
saveHitlConfigForConversation(cid, cfg, { syncGlobalLast: true });
if (cid && typeof window.saveHitlConversationConfig === 'function') {
try {
try {
if (cid && typeof window.saveHitlConversationConfig === 'function') {
await window.saveHitlConversationConfig(cid, cfg);
const ok = typeof window.t === 'function' ? window.t('hitl.pageReviewerSaved') : '审批方已保存。';
showChatToast(ok, 'success');
} catch (e) {
console.warn('onHitlReviewerChanged', e);
const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败';
showChatToast(prefix, 'error');
} else if (typeof window.putHitlDefaultReviewer === 'function') {
await window.putHitlDefaultReviewer(cfg.reviewer);
}
const ok = typeof window.t === 'function' ? window.t('hitl.pageReviewerSaved') : '审批方已保存。';
showChatToast(ok, 'success');
} catch (e) {
console.warn('onHitlReviewerChanged', e);
const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败';
showChatToast(prefix, 'error');
}
}
@@ -507,6 +512,7 @@ function chatAgentModeNormalizeStored(stored, cfg) {
if (typeof window !== 'undefined') {
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
window.csaiChatAgentMode = {
EINO_MODES: CHAT_AGENT_EINO_MODES,
EINO_SINGLE: CHAT_AGENT_MODE_EINO_SINGLE,
@@ -518,6 +524,7 @@ if (typeof window !== 'undefined') {
window.applyHitlSidebarConfig = applyHitlSidebarConfig;
window.readHitlConfigFromForm = readHitlConfigFromForm;
window.applyHitlConfigToUI = applyHitlConfigToUI;
window.refreshHitlConfigByCurrentConversation = refreshHitlConfigByCurrentConversation;
window.saveHitlConfigForConversation = saveHitlConfigForConversation;
window.getHitlConfigForConversation = getHitlConfigForConversation;
bindHitlSidebarModeListener();
@@ -2020,6 +2027,19 @@ function refreshSystemReadyMessageBubbles() {
});
}
function createMessageAvatar(role) {
const avatar = document.createElement('div');
avatar.className = 'message-avatar';
if (role === 'user') {
avatar.innerHTML = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
} else if (role === 'assistant') {
avatar.innerHTML = '<img src="/static/logo.png" alt="" class="message-avatar-img">';
} else {
avatar.textContent = 'S';
}
return avatar;
}
// 添加消息(options.systemReadyMessage 为 true 时,语言切换会刷新该条文案)
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) {
const messagesDiv = document.getElementById('chat-messages');
@@ -2030,16 +2050,7 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
messageDiv.className = 'message ' + role;
// 创建头像
const avatar = document.createElement('div');
avatar.className = 'message-avatar';
if (role === 'user') {
avatar.textContent = 'U';
} else if (role === 'assistant') {
avatar.textContent = 'A';
} else {
avatar.textContent = 'S';
}
messageDiv.appendChild(avatar);
messageDiv.appendChild(createMessageAvatar(role));
// 创建消息内容容器
const contentWrapper = document.createElement('div');
@@ -2400,6 +2411,15 @@ function processDetailRowFingerprint(d) {
return et + '\0' + msg + '\0' + dataKey;
}
function compactWorkflowProcessDetails(details) {
if (!Array.isArray(details) || details.length === 0) return details || [];
return details.filter((detail) => {
const eventType = detail && detail.eventType ? String(detail.eventType) : '';
// workflow_node_start 已经表达了节点进入;这些事件只用于实时状态,落到详情里会让 Agent 节点看起来重复启动。
return eventType !== 'workflow_agent_start';
});
}
// 渲染过程详情
// options.append=true 时分页追加;options.markLoaded=false 时保留 lazy 标记(分页加载中)
function renderProcessDetails(messageId, processDetails, options) {
@@ -2491,6 +2511,7 @@ function renderProcessDetails(messageId, processDetails, options) {
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
}
processDetails = compactWorkflowProcessDetails(processDetails);
// 如果没有processDetails或为空,显示空状态
if (!processDetails || processDetails.length === 0) {
if (!appendMode) {
@@ -2518,7 +2539,40 @@ function renderProcessDetails(messageId, processDetails, options) {
const agPx = processDetailAgentPrefix(data);
let itemTitle = title;
if (eventType === 'iteration') {
if (eventType === 'workflow_start') {
const name = data.workflowName || data.workflowId || '';
itemTitle = '🧭 工作流开始' + (name ? (' · ' + name) : '');
} else if (eventType === 'workflow_done') {
const name = data.workflowName || data.workflowId || '';
itemTitle = '✅ 工作流完成' + (name ? (' · ' + name) : '');
} else if (eventType === 'workflow_node_start') {
const label = data.label || title || data.nodeId || '';
itemTitle = '▶ 节点开始' + (label ? (' · ' + label) : '');
} else if (eventType === 'workflow_node_result') {
const label = data.label || data.nodeId || '';
const status = data.status || '';
const nodeType = data.nodeType != null ? String(data.nodeType).toLowerCase() : '';
if (nodeType === 'condition') {
const matched = data.matched === true || data.matched === 'true' || (data.output && (data.output.matched === true || data.output.matched === 'true'));
itemTitle = (matched ? '✅' : '🔀') + ' 条件判断' + (label ? (' · ' + label) : '') + ' → ' + (matched ? '是' : '否');
} else {
const icon = status === 'failed' ? '❌' : (status === 'skipped' ? '⏭️' : '✅');
itemTitle = icon + ' 节点完成' + (label ? (' · ' + label) : '') + (status ? ('' + status + '') : '');
}
} else if (eventType === 'workflow_branch_taken' || eventType === 'workflow_branch_skipped') {
const branch = data.branchLabel || '';
const target = data.targetLabel || data.targetId || '';
const taken = eventType === 'workflow_branch_taken';
itemTitle = (taken ? '➡️' : '⏭️') + (taken ? ' 执行分支' : ' 跳过分支') + (branch ? (' · ' + branch) : '') + (target ? (' → ' + target) : '');
} else if (eventType === 'workflow_tool_start') {
const tool = data.tool || data.toolName || '';
itemTitle = '🔧 工具节点' + (tool ? (' · ' + tool) : '');
} else if (eventType === 'workflow_agent_output') {
const label = data.label || data.nodeId || '';
itemTitle = '🤖 Agent 输出' + (label ? (' · ' + label) : '');
} else if (eventType === 'workflow_hitl_checkpoint') {
itemTitle = '🧑‍⚖️ 人工确认检查点';
} else if (eventType === 'iteration') {
const n = data.iteration || 1;
if (data.orchestration === 'plan_execute' && data.einoScope === 'main') {
const phase = typeof window.translatePlanExecuteAgentName === 'function'
+62
View File
@@ -231,10 +231,66 @@ async function fetchHitlConversationConfig(conversationId) {
if (!data || !data.hitl) return null;
return {
hitl: data.hitl,
defaultReviewer: hitlReviewerNormalize(data.defaultReviewer || 'human'),
hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : []
};
}
function applyHitlDefaultReviewerFromServer(reviewer) {
const v = hitlReviewerNormalize(reviewer);
if (typeof window !== 'undefined') {
window.csaiHitlDefaultReviewer = v;
}
if (typeof window.saveHitlLastGlobalConfig === 'function' && typeof window.getHitlLastGlobalConfig === 'function') {
const gl = window.getHitlLastGlobalConfig();
const base = gl && typeof gl === 'object'
? gl
: { mode: 'off', sensitiveTools: '', updatedAt: '' };
window.saveHitlLastGlobalConfig(Object.assign({}, base, {
reviewer: v,
updatedAt: new Date().toISOString()
}));
}
return v;
}
async function fetchHitlDefaultReviewer() {
const resp = await hitlApiFetch('/api/hitl/default-reviewer', { credentials: 'same-origin' });
if (!resp.ok) {
return applyHitlDefaultReviewerFromServer('human');
}
const data = await resp.json();
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
}
async function putHitlDefaultReviewer(reviewer) {
const normalized = hitlReviewerNormalize(reviewer);
const resp = await hitlApiFetch('/api/hitl/default-reviewer', {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reviewer: normalized })
});
if (!resp.ok) {
const msg = await readHitlApiError(resp);
throw new Error(msg || ('HTTP ' + resp.status));
}
const data = await resp.json();
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
}
async function initHitlDefaultReviewerFromServer() {
try {
await fetchHitlDefaultReviewer();
if (!getCurrentConversationIdForHitl() && typeof window.refreshHitlConfigByCurrentConversation === 'function') {
window.refreshHitlConfigByCurrentConversation();
}
refreshHitlPageReviewerBar();
} catch (e) {
console.warn('initHitlDefaultReviewerFromServer', e);
}
}
/** 无会话时:将免审批工具合并进服务端 config.yaml,返回更新后的全局白名单数组 */
async function mergeHitlGlobalToolWhitelist(sensitiveTools) {
const list = Array.isArray(sensitiveTools) ? sensitiveTools : [];
@@ -462,6 +518,9 @@ async function syncHitlConfigFromServer(conversationId) {
const pack = await fetchHitlConversationConfig(conversationId);
if (!pack || !pack.hitl) return;
const cfg = pack.hitl;
if (pack.defaultReviewer) {
applyHitlDefaultReviewerFromServer(pack.defaultReviewer);
}
const globalWL = pack.hitlGlobalToolWhitelist || [];
if (typeof window !== 'undefined') {
window.csaiHitlGlobalToolWhitelist = globalWL;
@@ -1460,6 +1519,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (typeof window.bindHitlReviewerToggleListeners === 'function') {
window.bindHitlReviewerToggleListeners();
}
initHitlDefaultReviewerFromServer();
setTimeout(reconcileHitlUiState, 0);
});
@@ -1478,3 +1538,5 @@ window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist;
// 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发
window.syncHitlConfigFromServer = syncHitlConfigFromServer;
window.fetchHitlDefaultReviewer = fetchHitlDefaultReviewer;
window.putHitlDefaultReviewer = putHitlDefaultReviewer;
+11 -11
View File
@@ -338,8 +338,8 @@ function showFofaParseModal(nlText, parsed) {
const explanation = parsed?.explanation != null ? String(parsed.explanation) : '';
const warningsHtml = warnings.length
? `<ul style="margin: 8px 0 0 18px;">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
: '<div class="muted" style="margin-top: 8px;">' + _t('infoCollect.none') + '</div>';
? `<ul class="info-collect-parse-warnings-list">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
: '<div class="muted info-collect-parse-warnings-empty">' + _t('infoCollect.none') + '</div>';
const modal = document.createElement('div');
modal.id = 'fofa-parse-modal';
@@ -348,37 +348,37 @@ function showFofaParseModal(nlText, parsed) {
openAppModal(modal, { focus: false });
deferModalContent(function () {
modal.innerHTML = `
<div class="modal-content" style="max-width: 900px;">
<div class="modal-content info-collect-parse-modal-content" style="max-width: 900px;">
<div class="modal-header">
<h2>${_t('infoCollect.parseResultTitle')}</h2>
<span class="modal-close" id="fofa-parse-modal-close" title="${_t('common.close')}">&times;</span>
</div>
<div style="padding: 18px 28px; overflow: auto;">
<div class="info-collect-parse-modal-body">
<div class="form-group">
<label>${_t('infoCollect.naturalLanguageLabel')}</label>
<div class="muted" style="margin-top: 6px; white-space: pre-wrap;">${safeNL || '-'}</div>
<div class="muted info-collect-parse-nl-text">${safeNL || '-'}</div>
</div>
<div class="form-group" style="margin-top: 14px;">
<div class="form-group info-collect-parse-form-group">
<label for="fofa-parse-query">${_t('infoCollect.fofaQueryEditable')}</label>
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${_t('infoCollect.queryPlaceholder')}"></textarea>
<small class="form-hint">${_t('infoCollect.confirmBeforeQuery')}</small>
</div>
<div class="form-group" style="margin-top: 14px;">
<div class="form-group info-collect-parse-form-group">
<label>${_t('infoCollect.reminder')}</label>
<div style="background: #fff8e1; border: 1px solid #ffe8a3; border-radius: 10px; padding: 10px 12px;">
<div class="info-collect-parse-warnings">
${warningsHtml}
</div>
</div>
${explanation ? `
<div class="form-group" style="margin-top: 14px;">
<div class="form-group info-collect-parse-form-group">
<label>${_t('infoCollect.explanation')}</label>
<pre style="margin-top: 8px; white-space: pre-wrap; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; padding: 10px 12px; font-size: 13px;">${escapeHtml(explanation)}</pre>
<pre class="info-collect-parse-explanation">${escapeHtml(explanation)}</pre>
</div>` : ''}
</div>
<div class="modal-footer" style="padding: 18px 28px;">
<div class="modal-footer info-collect-parse-modal-footer">
<button class="btn-secondary" type="button" id="fofa-parse-cancel">${_t('infoCollect.parseModalCancel')}</button>
<button class="btn-secondary" type="button" id="fofa-parse-apply">${_t('infoCollect.parseModalApply')}</button>
<button class="btn-primary" type="button" id="fofa-parse-apply-run">${_t('infoCollect.parseModalApplyRun')}</button>
+3
View File
@@ -34,6 +34,9 @@
if (el.classList.contains('info-collect-cell-modal')) {
return 'flex';
}
if (el.classList.contains('chat-files-form-modal')) {
return 'flex';
}
if (FLEX_MODAL_IDS.has(el.id)) {
return 'flex';
}
+188 -8
View File
@@ -333,6 +333,19 @@ const responseStreamStateByProgressId = new Map();
// 主通道当前迭代轮次缓存:progressId -> { iteration, orchestration }
const mainIterationStateByProgressId = new Map();
/** 图编排多 Agent 节点切换时清空流式聚合,避免推理/输出条目覆盖上一节点内容 */
function clearTimelineStreamStates(progressId) {
responseStreamStateByProgressId.delete(progressId);
thinkingStreamStateByProgressId.delete(progressId);
einoAgentReplyStreamStateByProgressId.delete(progressId);
const prefix = String(progressId) + '::';
for (const key of Array.from(toolResultStreamStateByKey.keys())) {
if (String(key).startsWith(prefix)) {
toolResultStreamStateByKey.delete(key);
}
}
}
/** 同一段主通道流式输出(Eino 可能重复 response_start */
function sameMainResponseStreamMeta(a, b) {
if (!a || !b) return false;
@@ -341,7 +354,10 @@ function sameMainResponseStreamMeta(a, b) {
if (!agentA || agentA !== agentB) return false;
const orchA = String(a.orchestration != null ? a.orchestration : '').trim();
const orchB = String(b.orchestration != null ? b.orchestration : '').trim();
return orchA === orchB;
if (orchA !== orchB) return false;
const nodeA = String(a.workflowNodeId != null ? a.workflowNodeId : '').trim();
const nodeB = String(b.workflowNodeId != null ? b.workflowNodeId : '').trim();
return nodeA === nodeB;
}
function resolveMainIterationTag(progressId, responseData) {
@@ -366,7 +382,8 @@ function buildMainResponseStreamIdentity(progressId, responseData) {
const agent = String(d.einoAgent != null ? d.einoAgent : '').trim();
const orch = String(d.orchestration != null ? d.orchestration : '').trim();
const iterTag = resolveMainIterationTag(progressId, d);
return agent + '|' + orch + '|iter=' + iterTag;
const nodeId = String(d.workflowNodeId != null ? d.workflowNodeId : '').trim();
return agent + '|' + orch + '|iter=' + iterTag + '|wfNode=' + nodeId;
}
function extractIterationTagFromStreamIdentity(identity) {
@@ -1747,13 +1764,18 @@ function handleStreamEvent(event, progressElement, progressId,
if (scope !== 'sub') {
const prevMainIter = mainIterationStateByProgressId.get(String(progressId));
const prevN = prevMainIter && prevMainIter.iteration != null ? prevMainIter.iteration : null;
const prevNode = prevMainIter && prevMainIter.workflowNodeId != null
? String(prevMainIter.workflowNodeId).trim()
: '';
const curNode = d.workflowNodeId != null ? String(d.workflowNodeId).trim() : '';
mainIterationStateByProgressId.set(String(progressId), {
iteration: n,
orchestration: d.orchestration != null ? d.orchestration : ''
orchestration: d.orchestration != null ? d.orchestration : '',
workflowNodeId: curNode
});
// 主通道进入新轮次后不复用上一轮的「执行输出」时间线条目
if (prevN != null && prevN !== n) {
responseStreamStateByProgressId.delete(progressId);
// 主通道进入新轮次或图编排切换到新 Agent 节点后,不复用上一段的流式时间线条目
if (prevN != null && (n < prevN || prevN !== n || (curNode && prevNode && curNode !== prevNode))) {
clearTimelineStreamStates(progressId);
}
}
let iterTitle;
@@ -1785,6 +1807,109 @@ function handleStreamEvent(event, progressElement, progressId,
break;
}
case 'workflow_start': {
const d = event.data || {};
const name = d.workflowName || d.workflowId || '';
addTimelineItem(timeline, 'workflow_start', {
title: '🧭 工作流开始' + (name ? (' · ' + name) : ''),
message: event.message || '',
data: d
});
break;
}
case 'workflow_done': {
const d = event.data || {};
const name = d.workflowName || d.workflowId || '';
addTimelineItem(timeline, 'workflow_done', {
title: '✅ 工作流完成' + (name ? (' · ' + name) : ''),
message: event.message || '',
data: d
});
break;
}
case 'workflow_node_start': {
const d = event.data || {};
const label = d.label || d.nodeId || '';
const nodeType = d.nodeType != null ? String(d.nodeType).toLowerCase() : '';
if (nodeType === 'agent') {
clearTimelineStreamStates(progressId);
}
addTimelineItem(timeline, 'workflow_node_start', {
title: '▶ 节点开始' + (label ? (' · ' + label) : ''),
message: event.message || '',
data: d
});
break;
}
case 'workflow_node_result': {
const d = event.data || {};
const label = d.label || d.nodeId || '';
const status = d.status || '';
const nodeType = d.nodeType != null ? String(d.nodeType).toLowerCase() : '';
let title;
if (nodeType === 'condition') {
const matched = d.matched === true || d.matched === 'true' || (d.output && (d.output.matched === true || d.output.matched === 'true'));
title = (matched ? '✅' : '🔀') + ' 条件判断' + (label ? (' · ' + label) : '') + ' → ' + (matched ? '是' : '否');
} else {
const icon = status === 'failed' ? '❌' : (status === 'skipped' ? '⏭️' : '✅');
title = icon + ' 节点完成' + (label ? (' · ' + label) : '') + (status ? ('' + status + '') : '');
}
addTimelineItem(timeline, 'workflow_node_result', {
title: title,
message: event.message || '',
data: d
});
break;
}
case 'workflow_branch_taken':
case 'workflow_branch_skipped': {
const d = event.data || {};
const branch = d.branchLabel || '';
const target = d.targetLabel || d.targetId || '';
const taken = event.type === 'workflow_branch_taken';
addTimelineItem(timeline, event.type, {
title: (taken ? '➡️' : '⏭️') + (taken ? ' 执行分支' : ' 跳过分支') + (branch ? (' · ' + branch) : '') + (target ? (' → ' + target) : ''),
message: event.message || '',
data: d
});
break;
}
case 'workflow_tool_start': {
const d = event.data || {};
const tool = d.tool || d.toolName || '';
addTimelineItem(timeline, 'workflow_tool_start', {
title: '🔧 工具节点' + (tool ? (' · ' + tool) : ''),
message: event.message || '',
data: d
});
break;
}
case 'workflow_agent_output': {
const d = event.data || {};
const label = d.label || d.nodeId || '';
addTimelineItem(timeline, 'workflow_agent_output', {
title: '🤖 Agent 输出' + (label ? (' · ' + label) : ''),
message: event.message || '',
data: d
});
break;
}
case 'workflow_hitl_checkpoint': {
addTimelineItem(timeline, 'workflow_hitl_checkpoint', {
title: '🧑‍⚖️ 人工确认检查点',
message: event.message || '',
data: event.data || {}
});
break;
}
case 'eino_trace_run':
case 'eino_trace_start':
case 'eino_trace_end':
@@ -3262,6 +3387,34 @@ function updateToolCallStatus(progressId, toolCallId, status) {
}
// 添加时间线项目
function buildWorkflowConditionResultHtml(data) {
const output = (data && data.output) || {};
const expr = (data && data.expression) || output.condition || '';
const matched = (data && (data.matched === true || data.matched === 'true'))
|| output.matched === true || output.matched === 'true';
const branchText = matched ? '是(true' : '否(false';
const branchClass = matched ? 'is-true' : 'is-false';
return `<div class="timeline-item-content workflow-condition-result">
<div class="workflow-condition-row">
<span class="workflow-agent-io-label">表达式</span>
<code>${escapeHtml(String(expr || '(空)'))}</code>
</div>
<div class="workflow-condition-row">
<span class="workflow-agent-io-label">结果</span>
<span class="workflow-condition-branch ${branchClass}">${escapeHtml(branchText)}</span>
</div>
</div>`;
}
function buildWorkflowBranchDetailHtml(data) {
const cond = (data && data.edgeCondition) || '';
if (!cond) return '';
return `<div class="timeline-item-content workflow-branch-detail">
<span class="workflow-agent-io-label">连线条件</span>
<code>${escapeHtml(cond)}</code>
</div>`;
}
function addTimelineItem(timeline, type, options) {
const item = document.createElement('div');
// 生成唯一ID
@@ -3382,8 +3535,35 @@ function addTimelineItem(timeline, type, options) {
</div>
</div>
`;
} else if (type === 'eino_agent_reply' && options.message) {
content += `<div class="timeline-item-content">${formatMarkdown(options.message, timelineMarkdownOpts)}</div>`;
} else if ((type === 'eino_agent_reply' || type === 'workflow_agent_output') && options.message) {
let prefix = '';
if (type === 'workflow_agent_output' && options.data) {
const source = options.data.inputSource || '';
const preview = options.data.inputPreview || '';
if (source || preview) {
const previewText = String(preview || '').trim();
const summaryPreview = previewText.length > 80 ? (previewText.slice(0, 80) + '...') : previewText;
prefix = `<details class="workflow-agent-input">
<summary>
<span class="workflow-agent-io-label">输入</span>
${source ? `<code>${escapeHtml(source)}</code>` : ''}
${summaryPreview ? `<span class="workflow-agent-input-summary">${escapeHtml(summaryPreview)}</span>` : ''}
</summary>
${previewText ? `<pre>${escapeHtml(previewText)}</pre>` : '<div class="workflow-agent-empty">暂无输入预览</div>'}
</details>`;
}
}
const body = type === 'workflow_agent_output'
? `<div class="workflow-agent-output">
<div class="workflow-agent-io-label">输出</div>
<div class="workflow-agent-output-body">${formatMarkdown(options.message, timelineMarkdownOpts)}</div>
</div>`
: formatMarkdown(options.message, timelineMarkdownOpts);
content += `<div class="timeline-item-content workflow-agent-io">${prefix}${body}</div>`;
} else if (type === 'workflow_node_result' && options.data && String(options.data.nodeType || '').toLowerCase() === 'condition') {
content += buildWorkflowConditionResultHtml(options.data);
} else if ((type === 'workflow_branch_taken' || type === 'workflow_branch_skipped') && options.data) {
content += buildWorkflowBranchDetailHtml(options.data);
} else if (type === 'tool_result' && options.data) {
const data = options.data;
const isError = data.isError || !data.success;
+22 -1
View File
@@ -1058,6 +1058,13 @@ async function showAddRoleModal() {
document.getElementById('role-icon').value = '';
document.getElementById('role-user-prompt').value = '';
document.getElementById('role-enabled').checked = true;
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
await loadWorkflowOptionsForRoleModal('');
}
const workflowPolicy = document.getElementById('role-workflow-policy');
if (workflowPolicy) {
workflowPolicy.value = 'auto';
}
// 添加角色时:显示工具选择界面,隐藏默认角色提示
const toolsSection = document.getElementById('role-tools-section');
@@ -1144,6 +1151,13 @@ async function editRole(roleName) {
document.getElementById('role-icon').value = iconValue;
document.getElementById('role-user-prompt').value = role.user_prompt || '';
document.getElementById('role-enabled').checked = role.enabled !== false;
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
await loadWorkflowOptionsForRoleModal(role.workflow_id || '');
}
const workflowPolicy = document.getElementById('role-workflow-policy');
if (workflowPolicy) {
workflowPolicy.value = role.workflow_policy || 'auto';
}
// 检查是否为默认角色
const isDefaultRole = roleName === '默认';
@@ -1398,6 +1412,10 @@ async function saveRole() {
}
const userPrompt = document.getElementById('role-user-prompt').value.trim();
const enabled = document.getElementById('role-enabled').checked;
const workflowIdEl = document.getElementById('role-workflow-id');
const workflowPolicyEl = document.getElementById('role-workflow-policy');
const workflowId = workflowIdEl ? workflowIdEl.value.trim() : '';
const workflowPolicy = workflowPolicyEl ? workflowPolicyEl.value.trim() : 'auto';
const isEdit = document.getElementById('role-name').disabled;
@@ -1504,7 +1522,10 @@ async function saveRole() {
icon: icon || undefined, // 如果为空字符串,则不发送该字段
user_prompt: userPrompt,
tools: tools, // 默认角色为空数组,表示使用所有工具
enabled: enabled
enabled: enabled,
workflow_id: workflowId || undefined,
workflow_version: workflowId ? 'latest' : undefined,
workflow_policy: workflowId ? (workflowPolicy || 'auto') : undefined
};
const url = isEdit ? `/api/roles/${encodeURIComponent(name)}` : '/api/roles';
const method = isEdit ? 'PUT' : 'POST';
+7 -3
View File
@@ -58,7 +58,7 @@ function initRouter() {
const hashParts = hash.split('?');
let pageId = hashParts[0];
if (pageId === 'c2') pageId = 'c2-listeners';
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-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', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(500);
@@ -449,6 +449,11 @@ async function initPage(pageId) {
});
}
break;
case 'workflows':
if (typeof refreshWorkflows === 'function') {
refreshWorkflows();
}
break;
case 'skills-monitor':
// 初始化Skills状态监控页面
if (typeof loadSkillsMonitor === 'function') {
@@ -510,7 +515,7 @@ document.addEventListener('DOMContentLoaded', function() {
let pageId = hashParts[0];
if (pageId === 'c2') pageId = 'c2-listeners';
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(200);
@@ -569,4 +574,3 @@ function initConversationSidebarState() {
// 导出函数供其他脚本使用(与上方尽早绑定保持一致,便于外部脚本探测)
window.currentPage = function() { return currentPage; };
+62 -16
View File
@@ -1303,9 +1303,14 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
${vuln.conversation_tag ? vulnDetailField(vulnT('vulnerabilityPage.detailConversationTag'), vuln.conversation_tag, false) : ''}
${vuln.task_tag ? vulnDetailField(vulnT('vulnerabilityPage.detailTaskTag'), vuln.task_tag, false) : ''}
</div>
${vuln.proof ? `<div class="vulnerability-proof"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailProof'))}:</strong><pre>${escapeHtml(vuln.proof)}</pre></div>` : ''}
${vuln.impact ? `<div class="vulnerability-impact"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailImpact'))}:</strong> ${escapeHtml(vuln.impact)}</div>` : ''}
${vuln.recommendation ? `<div class="vulnerability-recommendation"><strong>${escapeHtml(vulnT('vulnerabilityPage.detailRecommendation'))}:</strong> ${escapeHtml(vuln.recommendation)}</div>` : ''}
<div class="vulnerability-repro">
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailPreconditions'), vuln.preconditions)}
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailReproductionSteps'), vuln.reproduction_steps)}
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailEvidence'), vuln.evidence, { code: true })}
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailImpact'), vuln.impact)}
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRecommendation'), vuln.recommendation)}
${vulnNarrativeSection(vulnT('vulnerabilityPage.detailRetestNotes'), vuln.retest_notes)}
</div>
<div class="vulnerability-related-facts" id="vuln-related-facts-${vuln.id}" data-project-id="${escapeHtml(vuln.project_id || '')}" data-vuln-id="${escapeHtml(vuln.id)}" hidden></div>
</div>
</div>
@@ -1467,9 +1472,12 @@ async function showAddVulnerabilityModal() {
document.getElementById('vulnerability-status').value = 'open';
document.getElementById('vulnerability-type').value = '';
document.getElementById('vulnerability-target').value = '';
document.getElementById('vulnerability-proof').value = '';
document.getElementById('vulnerability-preconditions').value = '';
document.getElementById('vulnerability-reproduction-steps').value = '';
document.getElementById('vulnerability-evidence').value = '';
document.getElementById('vulnerability-impact').value = '';
document.getElementById('vulnerability-recommendation').value = '';
document.getElementById('vulnerability-retest-notes').value = '';
openAppModal('vulnerability-modal');
}
@@ -1493,9 +1501,12 @@ async function editVulnerability(id) {
document.getElementById('vulnerability-status').value = vuln.status || 'open';
document.getElementById('vulnerability-type').value = vuln.type || '';
document.getElementById('vulnerability-target').value = vuln.target || '';
document.getElementById('vulnerability-proof').value = vuln.proof || '';
document.getElementById('vulnerability-preconditions').value = vuln.preconditions || '';
document.getElementById('vulnerability-reproduction-steps').value = vuln.reproduction_steps || '';
document.getElementById('vulnerability-evidence').value = vuln.evidence || '';
document.getElementById('vulnerability-impact').value = vuln.impact || '';
document.getElementById('vulnerability-recommendation').value = vuln.recommendation || '';
document.getElementById('vulnerability-retest-notes').value = vuln.retest_notes || '';
await populateVulnerabilityModalProjectSelect(vuln.project_id || '');
document.getElementById('vulnerability-title')?.focus();
});
@@ -1510,9 +1521,16 @@ async function editVulnerability(id) {
async function saveVulnerability() {
const conversationId = document.getElementById('vulnerability-conversation-id').value.trim();
const title = document.getElementById('vulnerability-title').value.trim();
const description = document.getElementById('vulnerability-description').value.trim();
const severity = document.getElementById('vulnerability-severity').value;
const type = document.getElementById('vulnerability-type').value.trim();
const target = document.getElementById('vulnerability-target').value.trim();
const reproductionSteps = document.getElementById('vulnerability-reproduction-steps').value.trim();
const evidence = document.getElementById('vulnerability-evidence').value.trim();
const impact = document.getElementById('vulnerability-impact').value.trim();
const recommendation = document.getElementById('vulnerability-recommendation').value.trim();
if (!conversationId || !title || !severity) {
if (!conversationId || !title || !description || !severity || !type || !target || !reproductionSteps || !evidence || !impact || !recommendation) {
alert(vulnT('vulnerabilityPage.saveRequiredFields'));
return;
}
@@ -1525,14 +1543,17 @@ async function saveVulnerability() {
conversation_tag: document.getElementById('vulnerability-conversation-tag').value.trim(),
task_tag: document.getElementById('vulnerability-task-tag').value.trim(),
title: title,
description: document.getElementById('vulnerability-description').value.trim(),
description: description,
severity: severity,
status: document.getElementById('vulnerability-status').value,
type: document.getElementById('vulnerability-type').value.trim(),
target: document.getElementById('vulnerability-target').value.trim(),
proof: document.getElementById('vulnerability-proof').value.trim(),
impact: document.getElementById('vulnerability-impact').value.trim(),
recommendation: document.getElementById('vulnerability-recommendation').value.trim()
type: type,
target: target,
preconditions: document.getElementById('vulnerability-preconditions').value.trim(),
reproduction_steps: reproductionSteps,
evidence: evidence,
impact: impact,
recommendation: recommendation,
retest_notes: document.getElementById('vulnerability-retest-notes').value.trim()
};
try {
@@ -1553,9 +1574,12 @@ async function saveVulnerability() {
status: data.status,
type: data.type,
target: data.target,
proof: data.proof,
preconditions: data.preconditions,
reproduction_steps: data.reproduction_steps,
evidence: data.evidence,
impact: data.impact,
recommendation: data.recommendation,
retest_notes: data.retest_notes,
};
}
@@ -1864,6 +1888,17 @@ function vulnDetailField(label, value, asCode) {
</div>`;
}
function vulnNarrativeSection(label, value, options) {
if (value === undefined || value === null || String(value).trim() === '') return '';
const opts = options || {};
const tag = opts.code ? 'pre' : 'div';
const cls = opts.code ? 'vulnerability-section-body vulnerability-section-body--code' : 'vulnerability-section-body';
return `<section class="vulnerability-section">
<strong>${escapeHtml(label)}</strong>
<${tag} class="${cls}">${escapeHtml(String(value))}</${tag}>
</section>`;
}
// 将漏洞格式化为Markdown(章节标题随界面语言)
function formatVulnerabilityAsMarkdown(vuln) {
const severityText = vulnSeverityLabel(vuln.severity);
@@ -1905,8 +1940,16 @@ function formatVulnerabilityAsMarkdown(vuln) {
markdown += `## ${L('headingDescription')}\n\n${vuln.description}\n\n`;
}
if (vuln.proof) {
markdown += `## ${L('headingProof')}\n\n\`\`\`\n${vuln.proof}\n\`\`\`\n\n`;
if (vuln.preconditions) {
markdown += `## ${L('headingPreconditions')}\n\n${vuln.preconditions}\n\n`;
}
if (vuln.reproduction_steps) {
markdown += `## ${L('headingReproductionSteps')}\n\n${vuln.reproduction_steps}\n\n`;
}
if (vuln.evidence) {
markdown += `## ${L('headingEvidence')}\n\n\`\`\`\n${vuln.evidence}\n\`\`\`\n\n`;
}
if (vuln.impact) {
@@ -1917,6 +1960,10 @@ function formatVulnerabilityAsMarkdown(vuln) {
markdown += `## ${L('headingRecommendation')}\n\n${vuln.recommendation}\n\n`;
}
if (vuln.retest_notes) {
markdown += `## ${L('headingRetestNotes')}\n\n${vuln.retest_notes}\n\n`;
}
return markdown;
}
@@ -2194,4 +2241,3 @@ window.setVulnerabilityIdFilter = setVulnerabilityIdFilter;
window.bindVulnerabilityProject = bindVulnerabilityProject;
window.buildVulnerabilityProjectOptionsHtml = buildVulnerabilityProjectOptionsHtml;
window.changeVulnerabilityStatus = changeVulnerabilityStatus;
+891
View File
@@ -0,0 +1,891 @@
(function () {
'use strict';
let workflows = [];
let currentWorkflowId = '';
let cy = null;
let nodeSeq = 1;
let edgeSeq = 1;
let connectMode = false;
let connectSourceId = '';
let selectedElement = null;
let workflowToolOptions = [];
let workflowToolsLoaded = false;
const NODE_LABELS = {
start: '开始',
tool: '工具',
agent: 'Agent',
condition: '条件',
hitl: '审批',
output: '输出',
end: '结束'
};
const AGENT_MODES = ['eino_single', 'deep', 'plan_execute', 'supervisor'];
function esc(text) {
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function defaultGraph() {
return { nodes: [], edges: [], config: {} };
}
function defaultConfigForType(type) {
switch (type) {
case 'start':
return { input_keys: 'message, conversationId, projectId' };
case 'tool':
return { tool_name: '', arguments: '{}', timeout_seconds: '' };
case 'agent':
return { agent_mode: 'eino_single', input_source: '{{previous.output}}', instruction: '', output_key: 'agent_result' };
case 'condition':
return { expression: '{{previous.output}} != ""' };
case 'hitl':
return { prompt: '请审批该步骤是否继续执行', reviewer: 'human' };
case 'output':
return { output_key: 'result', source: '{{previous.output}}' };
case 'end':
return { result_template: '{{outputs.result}}' };
default:
return {};
}
}
function configWithDefaults(type, config) {
return Object.assign(defaultConfigForType(type), config && typeof config === 'object' ? config : {});
}
function parseGraph(raw) {
if (!raw) return defaultGraph();
let graph = raw;
if (typeof raw === 'string') {
try {
graph = JSON.parse(raw);
} catch (_) {
return defaultGraph();
}
}
return {
nodes: Array.isArray(graph.nodes) ? graph.nodes : [],
edges: Array.isArray(graph.edges) ? graph.edges : [],
config: graph.config && typeof graph.config === 'object' ? graph.config : {}
};
}
function graphToElements(graph) {
const nodes = (graph.nodes || []).map((node, index) => ({
group: 'nodes',
data: {
id: node.id || `node-${index + 1}`,
label: node.label || NODE_LABELS[node.type] || node.id || `节点 ${index + 1}`,
type: node.type || 'tool',
config: configWithDefaults(node.type || 'tool', node.config)
},
position: node.position || { x: 120 + index * 80, y: 120 + index * 40 }
}));
const edges = (graph.edges || []).map((edge, index) => ({
group: 'edges',
data: {
id: edge.id || `edge-${index + 1}`,
source: edge.source,
target: edge.target,
label: edge.label || '',
config: edge.config && typeof edge.config === 'object' ? edge.config : {}
}
})).filter(edge => edge.data.source && edge.data.target);
return nodes.concat(edges);
}
function elementsToGraph() {
if (!cy) return defaultGraph();
return {
nodes: cy.nodes().map(node => ({
id: node.id(),
type: node.data('type') || 'tool',
label: node.data('label') || '',
position: node.position(),
config: node.data('config') || {}
})),
edges: cy.edges().map(edge => ({
id: edge.id(),
source: edge.source().id(),
target: edge.target().id(),
label: edge.data('label') || '',
config: edge.data('config') || {}
})),
config: { schema_version: 1 }
};
}
function updateEmptyState() {
const empty = document.getElementById('workflow-canvas-empty');
if (!empty || !cy) return;
empty.style.display = cy.nodes().length ? 'none' : 'flex';
}
function initCy() {
const container = document.getElementById('workflow-canvas');
if (!container || typeof cytoscape !== 'function') return;
if (cy) {
cy.resize();
return;
}
cy = cytoscape({
container,
elements: [],
wheelSensitivity: 0.18,
style: [
{
selector: 'node',
style: {
'shape': 'round-rectangle',
'width': 150,
'height': 52,
'background-color': '#1d4ed8',
'border-width': 1,
'border-color': '#60a5fa',
'label': 'data(label)',
'color': '#e5edff',
'font-size': 13,
'font-weight': 700,
'text-valign': 'center',
'text-halign': 'center',
'text-wrap': 'wrap',
'text-max-width': 132
}
},
{ selector: 'node[type="start"]', style: { 'background-color': '#047857', 'border-color': '#34d399' } },
{ selector: 'node[type="tool"]', style: { 'background-color': '#1d4ed8', 'border-color': '#60a5fa' } },
{ selector: 'node[type="agent"]', style: { 'background-color': '#7c3aed', 'border-color': '#c4b5fd' } },
{ selector: 'node[type="condition"]', style: { 'shape': 'diamond', 'background-color': '#b45309', 'border-color': '#fbbf24', 'width': 118, 'height': 86 } },
{ selector: 'node[type="hitl"]', style: { 'background-color': '#0f766e', 'border-color': '#5eead4' } },
{ selector: 'node[type="output"]', style: { 'background-color': '#4338ca', 'border-color': '#a5b4fc' } },
{ selector: 'node[type="end"]', style: { 'background-color': '#be123c', 'border-color': '#fb7185' } },
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#64748b',
'target-arrow-color': '#64748b',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
'label': 'data(label)',
'font-size': 11,
'color': '#cbd5e1',
'text-background-color': '#0f172a',
'text-background-opacity': 0.8,
'text-background-padding': 3
}
},
{
selector: ':selected',
style: {
'border-width': 3,
'border-color': '#93c5fd',
'line-color': '#93c5fd',
'target-arrow-color': '#93c5fd'
}
},
{
selector: '.connect-source',
style: {
'border-width': 4,
'border-color': '#fbbf24'
}
}
],
layout: { name: 'preset' }
});
cy.on('tap', 'node', event => {
if (connectMode) {
handleConnectTap(event.target);
return;
}
selectWorkflowElement(event.target);
});
cy.on('tap', 'edge', event => {
selectWorkflowElement(event.target);
});
cy.on('tap', event => {
if (event.target === cy) {
if (connectMode) clearConnectSource();
selectWorkflowElement(null);
}
});
cy.on('add remove', updateEmptyState);
document.addEventListener('keydown', event => {
const active = document.activeElement;
const editing = active && ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName);
if (editing) return;
if (typeof currentPage !== 'undefined' && currentPage !== 'workflows') return;
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
deleteWorkflowSelection();
}
});
}
async function loadWorkflows(includeDisabled) {
const response = await apiFetch(`/api/workflows?includeDisabled=${includeDisabled ? 'true' : 'false'}`);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || '加载工作流失败');
}
const data = await response.json();
workflows = data.workflows || [];
return workflows;
}
async function loadWorkflowTools() {
if (workflowToolsLoaded) return workflowToolOptions;
const collected = [];
const seen = new Set();
let page = 1;
let totalPages = 1;
while (page <= totalPages && page <= 20) {
const response = await apiFetch(`/api/config/tools?page=${page}&page_size=100`);
if (!response.ok) break;
const data = await response.json();
totalPages = data.total_pages || 1;
(data.tools || []).forEach(tool => {
if (!tool || !tool.name) return;
const key = tool.is_external && tool.external_mcp ? `${tool.external_mcp}::${tool.name}` : tool.name;
if (seen.has(key)) return;
seen.add(key);
collected.push({ key, name: tool.name, enabled: tool.enabled !== false });
});
page += 1;
}
workflowToolOptions = collected;
workflowToolsLoaded = true;
return workflowToolOptions;
}
function renderWorkflowList() {
const list = document.getElementById('workflow-list');
if (!list) return;
if (!workflows.length) {
list.innerHTML = '<div class="empty-state">暂无图编排流程</div>';
return;
}
list.innerHTML = workflows.map(wf => `
<button type="button" class="workflow-list-item ${wf.id === currentWorkflowId ? 'is-active' : ''}" onclick="selectWorkflow(decodeURIComponent('${encodeURIComponent(wf.id)}'))">
<span class="workflow-list-title">${esc(wf.name || wf.id)}</span>
<span class="workflow-list-meta">${esc(wf.id)} · v${wf.version || 1} · ${wf.enabled ? '启用' : '禁用'}</span>
</button>
`).join('');
}
function nextNodeId(type) {
while (cy && cy.getElementById(`node-${nodeSeq}`).length) nodeSeq += 1;
const id = `node-${nodeSeq}`;
nodeSeq += 1;
return id;
}
function nextEdgeId() {
while (cy && cy.getElementById(`edge-${edgeSeq}`).length) edgeSeq += 1;
const id = `edge-${edgeSeq}`;
edgeSeq += 1;
return id;
}
function resetSequences(graph) {
nodeSeq = 1;
edgeSeq = 1;
(graph.nodes || []).forEach(node => {
const m = String(node.id || '').match(/^node-(\d+)$/);
if (m) nodeSeq = Math.max(nodeSeq, Number(m[1]) + 1);
});
(graph.edges || []).forEach(edge => {
const m = String(edge.id || '').match(/^edge-(\d+)$/);
if (m) edgeSeq = Math.max(edgeSeq, Number(m[1]) + 1);
});
}
function fillWorkflowForm(wf) {
initCy();
const idEl = document.getElementById('workflow-id');
const nameEl = document.getElementById('workflow-name');
const descEl = document.getElementById('workflow-description');
const enabledEl = document.getElementById('workflow-enabled');
if (!idEl || !nameEl || !descEl || !enabledEl || !cy) return;
idEl.value = wf.id || '';
idEl.disabled = !!wf.id;
nameEl.value = wf.name || '';
descEl.value = wf.description || '';
enabledEl.checked = wf.enabled !== false;
currentWorkflowId = wf.id || '';
const graph = parseGraph(wf.graph_json || wf.graph || defaultGraph());
resetSequences(graph);
cy.elements().remove();
cy.add(graphToElements(graph));
if (cy.nodes().length) {
layoutWorkflowGraph(false);
}
selectWorkflowElement(null);
updateEmptyState();
renderWorkflowList();
setTimeout(() => cy && cy.resize(), 0);
}
function selectWorkflowElement(ele) {
selectedElement = ele && ele.length ? ele : null;
const empty = document.getElementById('workflow-property-empty');
const form = document.getElementById('workflow-property-form');
const title = document.getElementById('workflow-property-title');
const deleteBtn = document.getElementById('workflow-property-delete-btn');
if (!empty || !form) return;
if (!selectedElement) {
empty.hidden = false;
form.hidden = true;
if (title) title.textContent = '属性';
if (deleteBtn) deleteBtn.hidden = true;
return;
}
cy.elements().unselect();
selectedElement.select();
empty.hidden = true;
form.hidden = false;
if (title) title.textContent = selectedElement.isNode() ? '节点属性' : '连线属性';
if (deleteBtn) {
deleteBtn.hidden = false;
deleteBtn.textContent = selectedElement.isNode() ? '删除节点' : '删除连线';
}
const typeWrap = document.getElementById('workflow-prop-type-wrap');
const label = document.getElementById('workflow-prop-label');
const type = document.getElementById('workflow-prop-type');
label.value = selectedElement.data('label') || '';
if (selectedElement.isNode()) {
typeWrap.style.display = '';
type.value = selectedElement.data('type') || 'tool';
} else {
typeWrap.style.display = 'none';
}
renderTypedConfig(selectedElement);
renderCustomFields(stripTypedConfig(selectedElement));
}
function typedKeysForType(type) {
return new Set(Object.keys(defaultConfigForType(type)));
}
function stripTypedConfig(ele) {
const cfg = Object.assign({}, ele.data('config') || {});
const typed = ele.isNode() ? typedKeysForType(ele.data('type') || 'tool') : new Set(['condition']);
typed.forEach(key => delete cfg[key]);
return cfg;
}
function typedField(id, label, value, placeholder) {
return `
<div class="form-group">
<label for="${id}">${label}</label>
<input type="text" id="${id}" class="form-input" value="${esc(value || '')}" placeholder="${esc(placeholder || '')}" oninput="updateWorkflowTypedConfig()">
</div>
`;
}
function typedTextarea(id, label, value, placeholder) {
return `
<div class="form-group">
<label for="${id}">${label}</label>
<textarea id="${id}" class="form-input" rows="4" placeholder="${esc(placeholder || '')}" oninput="updateWorkflowTypedConfig()">${esc(value || '')}</textarea>
</div>
`;
}
function renderTypedConfig(ele) {
const wrap = document.getElementById('workflow-typed-config');
if (!wrap || !ele) return;
const cfg = configWithDefaults(ele.isNode() ? ele.data('type') : 'edge', ele.data('config') || {});
if (!ele.isNode()) {
const sourceType = ele.source().data('type') || '';
const edgeHint = sourceType === 'condition'
? '{{previous.matched}} == "true"(是)或 == "false"(否)'
: '例如: {{previous.output}} == "ok"';
wrap.innerHTML = `
${typedField('workflow-edge-condition', '连线条件', cfg.condition || '', edgeHint)}
${sourceType === 'condition' ? '<p class="workflow-config-hint">从条件节点连出的第一条线默认为「是」分支,第二条为「否」分支;也可在此自定义条件。</p>' : ''}
`;
return;
}
const type = ele.data('type') || 'tool';
switch (type) {
case 'start':
wrap.innerHTML = typedField('workflow-start-input-keys', '输入变量', cfg.input_keys, 'message, projectId');
break;
case 'tool':
wrap.innerHTML = `
<div class="form-group">
<label for="workflow-tool-name">MCP 工具</label>
<select id="workflow-tool-name" onchange="updateWorkflowTypedConfig()">
<option value="">请选择工具</option>
${workflowToolOptions.map(tool => `<option value="${esc(tool.key)}" ${tool.key === cfg.tool_name ? 'selected' : ''}>${esc(tool.key)}${tool.enabled ? '' : '(未启用)'}</option>`).join('')}
</select>
</div>
${typedTextarea('workflow-tool-arguments', '参数模板', cfg.arguments, '{"target":"{{inputs.target}}"}')}
${typedField('workflow-tool-timeout', '超时秒数', cfg.timeout_seconds, '可选')}
`;
if (!workflowToolsLoaded) {
loadWorkflowTools().then(() => {
if (selectedElement === ele) renderTypedConfig(ele);
});
}
break;
case 'agent':
wrap.innerHTML = `
<div class="form-group">
<label for="workflow-agent-mode">Agent 模式</label>
<select id="workflow-agent-mode" onchange="updateWorkflowTypedConfig()">
${AGENT_MODES.map(mode => `<option value="${mode}" ${mode === cfg.agent_mode ? 'selected' : ''}>${mode}</option>`).join('')}
</select>
</div>
${typedField('workflow-agent-input-source', '输入来源', cfg.input_source, '{{previous.output}}')}
${typedTextarea('workflow-agent-instruction', '节点指令', cfg.instruction, '描述该节点要完成的任务')}
${typedField('workflow-agent-output-key', '输出变量名', cfg.output_key, 'agent_result')}
`;
break;
case 'condition':
wrap.innerHTML = `
${typedField('workflow-condition-expression', '条件表达式', cfg.expression, '{{previous.output}} != ""')}
<p class="workflow-config-hint">节点会计算 matchedtrue/false由出边决定分支第一条线为第二条为也可在连线上写 <code>{{previous.matched}} == "true"</code></p>
`;
break;
case 'hitl':
wrap.innerHTML = `
${typedTextarea('workflow-hitl-prompt', '审批提示', cfg.prompt, '请审批是否继续')}
<div class="form-group">
<label for="workflow-hitl-reviewer">审批方</label>
<select id="workflow-hitl-reviewer" onchange="updateWorkflowTypedConfig()">
<option value="human" ${cfg.reviewer === 'human' ? 'selected' : ''}>human</option>
<option value="audit_agent" ${cfg.reviewer === 'audit_agent' ? 'selected' : ''}>audit_agent</option>
</select>
</div>
`;
break;
case 'output':
wrap.innerHTML = `
${typedField('workflow-output-key', '输出变量名', cfg.output_key, 'result')}
${typedField('workflow-output-source', '变量来源', cfg.source, '{{previous.output}}')}
`;
break;
case 'end':
wrap.innerHTML = typedTextarea('workflow-end-template', '结束摘要模板', cfg.result_template, '{{outputs.result}}');
break;
default:
wrap.innerHTML = '';
}
}
function renderCustomFields(config) {
const wrap = document.getElementById('workflow-custom-fields');
if (!wrap) return;
const entries = Object.entries(config || {});
if (!entries.length) {
wrap.innerHTML = '<div class="workflow-property-empty workflow-property-empty--compact">暂无自定义字段</div>';
return;
}
wrap.innerHTML = entries.map(([key, value], index) => `
<div class="workflow-custom-field" data-index="${index}">
<input type="text" value="${esc(key)}" data-field-key oninput="updateWorkflowCustomFields()">
<input type="text" value="${esc(String(value == null ? '' : value))}" data-field-value oninput="updateWorkflowCustomFields()">
<button type="button" onclick="removeWorkflowCustomField(${index})">×</button>
</div>
`).join('');
}
function readCustomFields() {
const out = {};
document.querySelectorAll('#workflow-custom-fields .workflow-custom-field').forEach(row => {
const key = row.querySelector('[data-field-key]').value.trim();
const value = row.querySelector('[data-field-value]').value;
if (key) out[key] = value;
});
return out;
}
function readTypedConfig(ele) {
if (!ele) return {};
if (!ele.isNode()) {
return { condition: (document.getElementById('workflow-edge-condition') || {}).value || '' };
}
const type = ele.data('type') || 'tool';
switch (type) {
case 'start':
return { input_keys: (document.getElementById('workflow-start-input-keys') || {}).value || '' };
case 'tool':
return {
tool_name: (document.getElementById('workflow-tool-name') || {}).value || '',
arguments: (document.getElementById('workflow-tool-arguments') || {}).value || '{}',
timeout_seconds: (document.getElementById('workflow-tool-timeout') || {}).value || ''
};
case 'agent':
return {
agent_mode: (document.getElementById('workflow-agent-mode') || {}).value || 'eino_single',
input_source: (document.getElementById('workflow-agent-input-source') || {}).value || '{{previous.output}}',
instruction: (document.getElementById('workflow-agent-instruction') || {}).value || '',
output_key: (document.getElementById('workflow-agent-output-key') || {}).value || 'agent_result'
};
case 'condition':
return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '' };
case 'hitl':
return {
prompt: (document.getElementById('workflow-hitl-prompt') || {}).value || '',
reviewer: (document.getElementById('workflow-hitl-reviewer') || {}).value || 'human'
};
case 'output':
return {
output_key: (document.getElementById('workflow-output-key') || {}).value || 'result',
source: (document.getElementById('workflow-output-source') || {}).value || ''
};
case 'end':
return { result_template: (document.getElementById('workflow-end-template') || {}).value || '' };
default:
return {};
}
}
function mergeVisibleConfig() {
if (!selectedElement) return;
selectedElement.data('config', Object.assign({}, readCustomFields(), readTypedConfig(selectedElement)));
}
function handleConnectTap(node) {
if (!connectSourceId) {
connectSourceId = node.id();
node.addClass('connect-source');
return;
}
if (connectSourceId === node.id()) {
clearConnectSource();
return;
}
const duplicate = cy.edges().some(edge => edge.source().id() === connectSourceId && edge.target().id() === node.id());
if (duplicate) {
if (typeof showNotification === 'function') {
showNotification('这两个节点之间已经有连线', 'warning');
}
clearConnectSource();
return;
}
const sourceNode = cy.getElementById(connectSourceId);
const sourceType = sourceNode.data('type') || '';
let edgeLabel = '';
let edgeConfig = {};
if (sourceType === 'condition') {
const siblingCount = cy.edges().filter(edge => edge.source().id() === connectSourceId).length;
if (siblingCount === 0) {
edgeLabel = '是';
edgeConfig = { condition: '{{previous.matched}} == "true"', branch: 'true' };
} else if (siblingCount === 1) {
edgeLabel = '否';
edgeConfig = { condition: '{{previous.matched}} == "false"', branch: 'false' };
} else {
edgeConfig = { condition: '' };
}
}
cy.add({
group: 'edges',
data: {
id: nextEdgeId(),
source: connectSourceId,
target: node.id(),
label: edgeLabel,
config: edgeConfig
}
});
clearConnectSource();
}
function clearConnectSource() {
if (cy) cy.nodes().removeClass('connect-source');
connectSourceId = '';
}
function addNode(type, position) {
initCy();
if (!cy) return;
const node = cy.add({
group: 'nodes',
data: {
id: nextNodeId(type),
type,
label: NODE_LABELS[type] || '节点',
config: defaultConfigForType(type)
},
position: position || { x: 180 + cy.nodes().length * 28, y: 160 + cy.nodes().length * 28 }
});
selectWorkflowElement(node);
updateEmptyState();
}
window.refreshWorkflows = async function () {
initCy();
const list = document.getElementById('workflow-list');
if (list) list.innerHTML = '<div class="loading-spinner">加载中...</div>';
try {
await loadWorkflows(true);
renderWorkflowList();
if (!currentWorkflowId && workflows.length) {
fillWorkflowForm(workflows[0]);
} else if (!workflows.length) {
newWorkflowDraft();
}
} catch (error) {
if (list) list.innerHTML = `<div class="empty-state">${esc(error.message)}</div>`;
if (typeof showNotification === 'function') showNotification(error.message, 'error');
}
};
window.newWorkflowDraft = function () {
fillWorkflowForm({
id: '',
name: '',
description: '',
enabled: true,
graph_json: defaultGraph()
});
};
window.selectWorkflow = function (id) {
const wf = workflows.find(item => item.id === id);
if (wf) fillWorkflowForm(wf);
};
function validateWorkflowGraph(graph) {
const errors = [];
const nodes = graph.nodes || [];
const edges = graph.edges || [];
const ids = new Set(nodes.map(node => node.id));
const starts = nodes.filter(node => node.type === 'start');
const outputs = nodes.filter(node => node.type === 'output');
if (!starts.length) errors.push('至少需要一个开始节点');
if (!outputs.length) errors.push('至少需要一个输出节点');
edges.forEach(edge => {
if (edge.source === edge.target) errors.push(`连线 ${edge.id} 不能指向自身`);
if (!ids.has(edge.source)) errors.push(`连线 ${edge.id} 的源节点不存在`);
if (!ids.has(edge.target)) errors.push(`连线 ${edge.id} 的目标节点不存在`);
});
starts.forEach(node => {
if (edges.some(edge => edge.target === node.id)) errors.push(`开始节点 ${node.label || node.id} 不应有入边`);
});
outputs.forEach(node => {
if (edges.some(edge => edge.source === node.id)) errors.push(`输出节点 ${node.label || node.id} 不应有出边`);
});
nodes.filter(node => node.type === 'tool').forEach(node => {
if (!String((node.config || {}).tool_name || '').trim()) {
errors.push(`工具节点 ${node.label || node.id} 需要选择 MCP 工具`);
}
});
nodes.filter(node => node.type === 'condition').forEach(node => {
if (!String((node.config || {}).expression || '').trim()) {
errors.push(`条件节点 ${node.label || node.id} 需要条件表达式`);
}
const outEdges = edges.filter(edge => edge.source === node.id);
if (outEdges.length === 0) {
errors.push(`条件节点 ${node.label || node.id} 至少需要一条出边(是/否分支)`);
} else if (outEdges.length > 2) {
errors.push(`条件节点 ${node.label || node.id} 建议最多两条出边(是/否);第三条及以后需配置连线条件`);
}
});
nodes.filter(node => node.type === 'output').forEach(node => {
if (!String((node.config || {}).output_key || '').trim()) {
errors.push(`输出节点 ${node.label || node.id} 需要输出变量名`);
}
});
return errors;
}
window.saveWorkflowDraft = async function () {
initCy();
const id = document.getElementById('workflow-id').value.trim();
const name = document.getElementById('workflow-name').value.trim();
const description = document.getElementById('workflow-description').value.trim();
const enabled = document.getElementById('workflow-enabled').checked;
if (!id || !name) {
showNotification('工作流 ID 和名称不能为空', 'error');
return;
}
const graph = elementsToGraph();
const errors = validateWorkflowGraph(graph);
if (errors.length) {
showNotification(errors.slice(0, 4).join(''), 'error');
return;
}
const method = currentWorkflowId ? 'PUT' : 'POST';
const url = currentWorkflowId ? `/api/workflows/${encodeURIComponent(currentWorkflowId)}` : '/api/workflows';
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name, description, enabled, graph })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showNotification(err.error || '保存工作流失败', 'error');
return;
}
const data = await response.json();
currentWorkflowId = data.workflow && data.workflow.id ? data.workflow.id : id;
showNotification('工作流已保存', 'success');
await refreshWorkflows();
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
await loadWorkflowOptionsForRoleModal();
}
};
window.deleteCurrentWorkflow = async function () {
const id = currentWorkflowId || document.getElementById('workflow-id').value.trim();
if (!id) {
showNotification('请选择要删除的工作流', 'warning');
return;
}
if (!confirm(`确定删除工作流 ${id}`)) return;
const response = await apiFetch(`/api/workflows/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showNotification(err.error || '删除工作流失败', 'error');
return;
}
currentWorkflowId = '';
showNotification('工作流已删除', 'success');
newWorkflowDraft();
await refreshWorkflows();
};
window.workflowPaletteDragStart = function (event) {
const type = event.currentTarget.dataset.nodeType || 'tool';
event.dataTransfer.setData('application/x-workflow-node', type);
event.dataTransfer.setData('text/plain', type);
event.dataTransfer.effectAllowed = 'copy';
};
window.workflowCanvasDragOver = function (event) {
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
};
window.workflowCanvasDrop = function (event) {
event.preventDefault();
const type = event.dataTransfer.getData('application/x-workflow-node') || event.dataTransfer.getData('text/plain') || 'tool';
const rect = document.getElementById('workflow-canvas').getBoundingClientRect();
const pan = cy.pan();
const zoom = cy.zoom();
addNode(type, {
x: (event.clientX - rect.left - pan.x) / zoom,
y: (event.clientY - rect.top - pan.y) / zoom
});
};
window.addWorkflowNodeFromPalette = function (type) {
addNode(type || 'tool');
};
window.toggleWorkflowConnectMode = function () {
connectMode = !connectMode;
clearConnectSource();
const btn = document.getElementById('workflow-connect-btn');
if (btn) {
btn.classList.toggle('active', connectMode);
btn.textContent = connectMode ? '连线中' : '连线';
}
if (typeof showNotification === 'function') {
showNotification(connectMode ? '连线模式:依次点击源节点和目标节点' : '已退出连线模式', 'info');
}
};
window.deleteWorkflowSelection = function () {
if (!cy) return;
const selected = selectedElement && selectedElement.length ? selectedElement : cy.$(':selected');
if (!selected.length) return;
selected.remove();
selectWorkflowElement(null);
updateEmptyState();
};
window.layoutWorkflowGraph = function (animate) {
if (!cy || !cy.nodes().length) return;
cy.layout({
name: 'breadthfirst',
directed: true,
padding: 40,
spacingFactor: 1.25,
animate: animate !== false,
animationDuration: 250
}).run();
cy.fit(undefined, 40);
};
window.updateWorkflowSelectedProperty = function () {
if (!selectedElement) return;
const label = document.getElementById('workflow-prop-label').value.trim();
selectedElement.data('label', label);
if (selectedElement.isNode()) {
const type = document.getElementById('workflow-prop-type').value || 'tool';
const prevType = selectedElement.data('type') || 'tool';
selectedElement.data('type', type);
if (type !== prevType) {
selectedElement.data('config', defaultConfigForType(type));
selectedElement.data('label', label || NODE_LABELS[type] || '节点');
document.getElementById('workflow-prop-label').value = selectedElement.data('label') || '';
renderTypedConfig(selectedElement);
renderCustomFields({});
}
}
};
window.addWorkflowCustomField = function () {
if (!selectedElement) return;
const cfg = Object.assign({}, selectedElement.data('config') || {});
let i = 1;
while (Object.prototype.hasOwnProperty.call(cfg, `field_${i}`)) i += 1;
cfg[`field_${i}`] = '';
selectedElement.data('config', cfg);
renderCustomFields(cfg);
};
window.updateWorkflowCustomFields = function () {
if (!selectedElement) return;
mergeVisibleConfig();
};
window.updateWorkflowTypedConfig = function () {
if (!selectedElement) return;
mergeVisibleConfig();
};
window.removeWorkflowCustomField = function (index) {
if (!selectedElement) return;
const entries = Object.entries(stripTypedConfig(selectedElement));
entries.splice(index, 1);
const next = {};
entries.forEach(([key, value]) => {
if (key) next[key] = value;
});
selectedElement.data('config', Object.assign({}, next, readTypedConfig(selectedElement)));
renderCustomFields(next);
};
window.loadWorkflowOptionsForRoleModal = async function (selectedId) {
try {
await loadWorkflows(true);
} catch (_) {
workflows = [];
}
const select = document.getElementById('role-workflow-id');
if (!select) return;
const current = selectedId !== undefined ? selectedId : select.value;
select.innerHTML = '<option value="">不绑定流程</option>' + workflows.map(wf => (
`<option value="${esc(wf.id)}">${esc(wf.name || wf.id)}${wf.enabled ? '' : '(已禁用)'}</option>`
)).join('');
select.value = current || '';
};
})();
+147 -18
View File
@@ -201,6 +201,16 @@
<span data-i18n="nav.tasks">任务管理</span>
</div>
</div>
<div class="nav-item" data-page="workflows">
<div class="nav-item-content" data-title="图编排" onclick="switchPage('workflows')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="8" height="8" rx="2"/>
<path d="M7 11v4a2 2 0 0 0 2 2h4"/>
<rect x="13" y="13" width="8" height="8" rx="2"/>
</svg>
<span>图编排</span>
</div>
</div>
<div class="nav-item" data-page="projects">
<div class="nav-item-content" data-title="项目管理" onclick="switchPage('projects')" data-i18n="nav.projects" 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">
@@ -1537,10 +1547,13 @@
</div>
</div>
</label>
<div class="search-box">
<input type="text" id="knowledge-search" data-i18n="knowledgePage.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索知识..." oninput="handleKnowledgeSearchInput()" onkeydown="if(event.key==='Enter') searchKnowledgeItems()" />
<button class="btn-search" onclick="searchKnowledgeItems()" data-i18n="common.search" data-i18n-attr="title" title="搜索">🔍</button>
</div>
<label class="knowledge-search-field">
<span data-i18n="common.search">搜索</span>
<div class="search-box">
<input type="text" id="knowledge-search" data-i18n="knowledgePage.searchPlaceholder" data-i18n-attr="placeholder" placeholder="搜索知识..." oninput="handleKnowledgeSearchInput()" onkeydown="if(event.key==='Enter') searchKnowledgeItems()" />
<button class="btn-search" onclick="searchKnowledgeItems()" data-i18n="common.search" data-i18n-attr="title" title="搜索">🔍</button>
</div>
</label>
</div>
</div>
<div id="knowledge-items-list" class="knowledge-items-list">
@@ -2287,12 +2300,12 @@
<h2 data-i18n="chatFilesPage.title">文件管理</h2>
<div class="page-header-actions">
<button type="button" class="btn-primary" id="chat-files-header-upload-btn" onclick="chatFilesOpenUploadPicker()" data-i18n="chatFilesPage.upload">上传文件</button>
<input type="file" id="chat-files-upload-input" style="display:none" onchange="onChatFilesUploadPick(event)" />
<input type="file" id="chat-files-upload-input" style="display:none" multiple onchange="onChatFilesUploadPick(event)" />
<button type="button" class="btn-secondary" id="chat-files-refresh-btn" onclick="loadChatFilesPage()" data-i18n="common.refresh">刷新</button>
</div>
</div>
<div class="page-content">
<p class="chat-files-intro" data-i18n="chatFilesPage.intro">管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可。</p>
<p class="chat-files-intro" data-i18n="chatFilesPage.intro">管理在对话中上传的文件。可将文件拖拽到下方列表区域,或点击「上传文件」选择文件(支持多选)。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可。</p>
<div class="tasks-filters chat-files-filters">
<label>
<span data-i18n="chatFilesPage.conversationFilter">会话 ID</span>
@@ -2534,6 +2547,95 @@
</div>
</div>
<!-- 图编排页面 -->
<div id="page-workflows" class="page">
<div class="page-header">
<h2>图编排</h2>
<div class="page-header-actions">
<button class="btn-secondary" onclick="refreshWorkflows()">刷新</button>
<button class="btn-primary" onclick="newWorkflowDraft()">新建图</button>
</div>
</div>
<div class="page-content workflow-page-content">
<aside class="workflow-sidebar">
<div class="workflow-panel">
<div class="workflow-panel-header">
<h3>流程库</h3>
</div>
<div id="workflow-list" class="workflow-list">
<div class="loading-spinner">加载中...</div>
</div>
</div>
<div class="workflow-panel">
<div class="workflow-panel-header">
<h3>节点库</h3>
</div>
<div class="workflow-node-palette">
<button type="button" draggable="true" data-node-type="start" ondragstart="workflowPaletteDragStart(event)" onclick="addWorkflowNodeFromPalette('start')">开始</button>
<button type="button" draggable="true" data-node-type="tool" ondragstart="workflowPaletteDragStart(event)" onclick="addWorkflowNodeFromPalette('tool')">工具</button>
<button type="button" draggable="true" data-node-type="agent" ondragstart="workflowPaletteDragStart(event)" onclick="addWorkflowNodeFromPalette('agent')">Agent</button>
<button type="button" draggable="true" data-node-type="condition" ondragstart="workflowPaletteDragStart(event)" onclick="addWorkflowNodeFromPalette('condition')">条件</button>
<button type="button" draggable="true" data-node-type="hitl" ondragstart="workflowPaletteDragStart(event)" onclick="addWorkflowNodeFromPalette('hitl')">审批</button>
<button type="button" draggable="true" data-node-type="output" ondragstart="workflowPaletteDragStart(event)" onclick="addWorkflowNodeFromPalette('output')">输出</button>
<button type="button" draggable="true" data-node-type="end" ondragstart="workflowPaletteDragStart(event)" onclick="addWorkflowNodeFromPalette('end')">结束</button>
</div>
</div>
</aside>
<main class="workflow-main">
<section class="workflow-meta-bar">
<div class="workflow-meta-fields">
<label>ID <input type="text" id="workflow-id" placeholder="web-scan-basic" autocomplete="off"></label>
<label>名称 <input type="text" id="workflow-name" placeholder="基础 Web 扫描" autocomplete="off"></label>
<label>描述 <input type="text" id="workflow-description" placeholder="可选" autocomplete="off"></label>
<label class="workflow-enabled-toggle"><input type="checkbox" id="workflow-enabled" checked> 启用</label>
</div>
<div class="workflow-toolbar">
<button class="btn-secondary btn-small" type="button" onclick="toggleWorkflowConnectMode()" id="workflow-connect-btn">连线</button>
<button class="btn-secondary btn-small" type="button" onclick="deleteWorkflowSelection()">删除选中</button>
<button class="btn-secondary btn-small" type="button" onclick="layoutWorkflowGraph()">自动布局</button>
<button class="btn-secondary btn-small" onclick="deleteCurrentWorkflow()">删除</button>
<button class="btn-primary btn-small" onclick="saveWorkflowDraft()">保存</button>
</div>
</section>
<section class="workflow-canvas-wrap" ondragover="workflowCanvasDragOver(event)" ondrop="workflowCanvasDrop(event)">
<div id="workflow-canvas"></div>
<div id="workflow-canvas-empty" class="workflow-canvas-empty">从左侧拖拽节点到画布,或点击节点按钮快速添加</div>
</section>
</main>
<aside class="workflow-properties">
<div class="workflow-panel-header">
<h3 id="workflow-property-title">属性</h3>
<button type="button" id="workflow-property-delete-btn" class="btn-secondary btn-small" onclick="deleteWorkflowSelection()" hidden>删除</button>
</div>
<div id="workflow-property-empty" class="workflow-property-empty">选择一个节点或连线后编辑属性</div>
<div id="workflow-property-form" class="workflow-property-form" hidden>
<div class="form-group">
<label for="workflow-prop-label">名称</label>
<input type="text" id="workflow-prop-label" class="form-input" oninput="updateWorkflowSelectedProperty()">
</div>
<div class="form-group" id="workflow-prop-type-wrap">
<label for="workflow-prop-type">类型</label>
<select id="workflow-prop-type" onchange="updateWorkflowSelectedProperty()">
<option value="start">开始</option>
<option value="tool">工具</option>
<option value="agent">Agent</option>
<option value="condition">条件</option>
<option value="hitl">审批</option>
<option value="output">输出</option>
<option value="end">结束</option>
</select>
</div>
<div id="workflow-typed-config" class="workflow-typed-config"></div>
<div class="workflow-custom-fields-head">
<span>自定义字段</span>
<button type="button" class="btn-secondary btn-small" onclick="addWorkflowCustomField()">添加字段</button>
</div>
<div id="workflow-custom-fields" class="workflow-custom-fields"></div>
</div>
</aside>
</div>
</div>
<!-- 角色管理页面 -->
<div id="page-roles-management" class="page">
<div class="page-header">
@@ -4382,11 +4484,11 @@
</div>
<div class="form-group">
<label for="vulnerability-title"><span data-i18n="vulnerabilityModal.title">标题</span> <span style="color: red;">*</span></label>
<input type="text" id="vulnerability-title" data-i18n="vulnerabilityModal.titlePlaceholder" data-i18n-attr="placeholder" placeholder="漏洞标题" required />
<input type="text" id="vulnerability-title" data-i18n="vulnerabilityModal.titlePlaceholder" data-i18n-attr="placeholder" placeholder="/api/login 存在 SQL 注入" required />
</div>
<div class="form-group">
<label for="vulnerability-description" data-i18n="vulnerabilityModal.description">描述</label>
<textarea id="vulnerability-description" rows="5" data-i18n="vulnerabilityModal.descriptionPlaceholder" data-i18n-attr="placeholder" placeholder="漏洞详细描述"></textarea>
<label for="vulnerability-description"><span data-i18n="vulnerabilityModal.description">描述</span> <span style="color: red;">*</span></label>
<textarea id="vulnerability-description" rows="7" data-i18n="vulnerabilityModal.descriptionPlaceholder" data-i18n-attr="placeholder" placeholder="建议包含:摘要、测试环境与范围、前置条件、复现步骤、预期结果、实际结果。"></textarea>
</div>
<div class="form-group">
<label for="vulnerability-severity"><span data-i18n="vulnerabilityModal.severity">严重程度</span> <span style="color: red;">*</span></label>
@@ -4410,24 +4512,36 @@
</select>
</div>
<div class="form-group">
<label for="vulnerability-type" data-i18n="vulnerabilityModal.type">漏洞类型</label>
<label for="vulnerability-type"><span data-i18n="vulnerabilityModal.type">漏洞类型</span> <span style="color: red;">*</span></label>
<input type="text" id="vulnerability-type" data-i18n="vulnerabilityModal.typePlaceholder" data-i18n-attr="placeholder" placeholder="如:SQL注入、XSS、CSRF等" />
</div>
<div class="form-group">
<label for="vulnerability-target" data-i18n="vulnerabilityModal.target">目标</label>
<input type="text" id="vulnerability-target" data-i18n="vulnerabilityModal.targetPlaceholder" data-i18n-attr="placeholder" placeholder="受影响的目标(URLIP地址等)" />
<label for="vulnerability-target"><span data-i18n="vulnerabilityModal.target">目标</span> <span style="color: red;">*</span></label>
<input type="text" id="vulnerability-target" data-i18n="vulnerabilityModal.targetPlaceholder" data-i18n-attr="placeholder" placeholder="精确到 URL/IP:端口/接口路径/参数名" />
</div>
<div class="form-group">
<label for="vulnerability-proof" data-i18n="vulnerabilityModal.proof">证明(POC</label>
<textarea id="vulnerability-proof" rows="5" data-i18n="vulnerabilityModal.proofPlaceholder" data-i18n-attr="placeholder" placeholder="漏洞证明,如请求/响应、截图等"></textarea>
<label for="vulnerability-preconditions" data-i18n="vulnerabilityModal.preconditions">前置条件</label>
<textarea id="vulnerability-preconditions" rows="3" data-i18n="vulnerabilityModal.preconditionsPlaceholder" data-i18n-attr="placeholder" placeholder="登录状态、权限、账号、Header/Cookie、特定数据、环境/版本;无则写无。"></textarea>
</div>
<div class="form-group">
<label for="vulnerability-impact" data-i18n="vulnerabilityModal.impact">影响</label>
<textarea id="vulnerability-impact" rows="3" data-i18n="vulnerabilityModal.impactPlaceholder" data-i18n-attr="placeholder" placeholder="漏洞影响说明"></textarea>
<label for="vulnerability-reproduction-steps"><span data-i18n="vulnerabilityModal.reproductionSteps">复现步骤</span> <span style="color: red;">*</span></label>
<textarea id="vulnerability-reproduction-steps" rows="6" data-i18n="vulnerabilityModal.reproductionStepsPlaceholder" data-i18n-attr="placeholder" placeholder="按 1/2/3 编号,写清入口、参数、payload、执行命令、观察点。"></textarea>
</div>
<div class="form-group">
<label for="vulnerability-recommendation" data-i18n="vulnerabilityModal.recommendation">修复建议</label>
<textarea id="vulnerability-recommendation" rows="3" data-i18n="vulnerabilityModal.recommendationPlaceholder" data-i18n-attr="placeholder" placeholder="修复建议"></textarea>
<label for="vulnerability-evidence"><span data-i18n="vulnerabilityModal.evidence">证据 / POC</span> <span style="color: red;">*</span></label>
<textarea id="vulnerability-evidence" rows="8" data-i18n="vulnerabilityModal.evidencePlaceholder" data-i18n-attr="placeholder" placeholder="原始请求/响应、curl/工具命令、截图说明、日志、DNSLog/回连记录、数据库结果、文件路径、时间戳等。"></textarea>
</div>
<div class="form-group">
<label for="vulnerability-impact"><span data-i18n="vulnerabilityModal.impact">影响</span> <span style="color: red;">*</span></label>
<textarea id="vulnerability-impact" rows="3" data-i18n="vulnerabilityModal.impactPlaceholder" data-i18n-attr="placeholder" placeholder="结合已验证事实说明实际影响,例如越权读取哪些数据。"></textarea>
</div>
<div class="form-group">
<label for="vulnerability-recommendation"><span data-i18n="vulnerabilityModal.recommendation">修复建议</span> <span style="color: red;">*</span></label>
<textarea id="vulnerability-recommendation" rows="3" data-i18n="vulnerabilityModal.recommendationPlaceholder" data-i18n-attr="placeholder" placeholder="写具体修复点和复测标准。"></textarea>
</div>
<div class="form-group">
<label for="vulnerability-retest-notes" data-i18n="vulnerabilityModal.retestNotes">复测方式</label>
<textarea id="vulnerability-retest-notes" rows="3" data-i18n="vulnerabilityModal.retestNotesPlaceholder" data-i18n-attr="placeholder" placeholder="修复后如何验证漏洞已关闭,包括应返回的状态码、错误信息或访问控制结果。"></textarea>
</div>
</div>
<div class="modal-footer">
@@ -4546,6 +4660,20 @@
<textarea id="role-user-prompt" rows="10" data-i18n="roleModal.userPromptPlaceholder" data-i18n-attr="placeholder" placeholder="输入用户提示词,会在用户消息前追加此提示词..."></textarea>
<small class="form-hint" data-i18n="roleModal.userPromptHint">此提示词会追加到用户消息前,用于指导AI的行为。注意:这不会修改系统提示词。</small>
</div>
<div class="form-group">
<label for="role-workflow-id">绑定图编排流程</label>
<select id="role-workflow-id">
<option value="">不绑定流程</option>
</select>
<small class="form-hint">选中流程后,对话页使用该角色会自动触发绑定图;流程字段由图定义 JSON 自由配置。</small>
</div>
<div class="form-group">
<label for="role-workflow-policy">流程触发策略</label>
<select id="role-workflow-policy">
<option value="auto">自动触发</option>
<option value="off">关闭</option>
</select>
</div>
<div class="form-group" id="role-tools-section">
<label data-i18n="roleModal.relatedTools">关联的工具(可选)</label>
<div id="role-tools-default-hint" class="role-tools-default-hint" style="display: none;">
@@ -4764,6 +4892,7 @@
<script src="/static/js/webshell.js"></script>
<script src="/static/js/chat-files.js"></script>
<script src="/static/js/tasks.js"></script>
<script src="/static/js/workflows.js"></script>
<script src="/static/js/roles.js"></script>
<script src="/static/js/c2.js"></script>
</body>