Compare commits

..
31 Commits
Author SHA1 Message Date
公明andGitHub 3bb8ec57fd Update config.example.yaml 2026-08-06 17:42:04 +08:00
公明andGitHub 95c8e3b1a2 Add files via upload 2026-08-06 17:16:50 +08:00
公明andGitHub ed47ee202e Add files via upload 2026-08-06 17:14:06 +08:00
公明andGitHub d3714c9913 Add files via upload 2026-08-06 17:12:21 +08:00
公明andGitHub 30513dbbfd Add files via upload 2026-08-06 10:50:34 +08:00
RuoJi6andGitHub aff9c9301f feat: refine HITL audit policy defaults (#236) 2026-08-05 18:02:48 +08:00
公明andGitHub f7ba7070ca Add files via upload 2026-08-04 11:13:14 +08:00
公明andGitHub 5d1f5d2886 Add files via upload 2026-07-31 21:24:09 +08:00
公明andGitHub 0f92817261 Add files via upload 2026-07-31 21:21:47 +08:00
公明andGitHub 2ec5953416 Add files via upload 2026-07-31 21:19:50 +08:00
公明andGitHub b4fe2e795e Add files via upload 2026-07-31 21:18:21 +08:00
公明andGitHub 904d860797 Add files via upload 2026-07-31 21:15:42 +08:00
公明andGitHub dc08199af6 Add files via upload 2026-07-31 21:14:10 +08:00
公明andGitHub 84e99220ff Add files via upload 2026-07-31 21:12:01 +08:00
公明andGitHub 86f1d10a8b Add files via upload 2026-07-31 21:09:45 +08:00
公明andGitHub 5c643a1606 Add files via upload 2026-07-31 21:06:22 +08:00
公明andGitHub b9854192c6 Add files via upload 2026-07-31 21:04:11 +08:00
公明andGitHub 5a5762e1d1 Add files via upload 2026-07-29 17:50:38 +08:00
公明andGitHub 8882d70393 Add files via upload 2026-07-29 11:43:53 +08:00
公明andGitHub 5747ebc612 Add files via upload 2026-07-29 11:42:23 +08:00
公明andGitHub a6b3773f00 Add files via upload 2026-07-28 18:35:32 +08:00
公明andGitHub c2b950ad53 Add files via upload 2026-07-28 18:32:15 +08:00
公明andGitHub 0283fff743 Add files via upload 2026-07-28 18:30:28 +08:00
公明andGitHub 018835d6b8 Add files via upload 2026-07-28 18:28:41 +08:00
公明andGitHub 4b7df4e0f3 Add files via upload 2026-07-28 18:26:33 +08:00
公明andGitHub 0324b41a01 Add files via upload 2026-07-28 18:25:11 +08:00
公明andGitHub 4a19620137 Add files via upload 2026-07-28 18:22:30 +08:00
公明andGitHub f8110413c0 Add files via upload 2026-07-28 18:20:17 +08:00
公明andGitHub 59dc7cf858 Add files via upload 2026-07-28 18:17:47 +08:00
公明andGitHub 52595e07e5 Add files via upload 2026-07-28 18:15:54 +08:00
公明andGitHub e0965594bb Add files via upload 2026-07-28 18:14:06 +08:00
91 changed files with 5294 additions and 713 deletions
+80 -26
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.10"
version: "v1.7.12"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -138,7 +138,7 @@ hitl:
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
retention_days: 90
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
tool_whitelist: [read_file, list_dir, glob, grep, tool_search]
tool_whitelist: [read_file, list_dir, glob, grep, tool_search, upsert_project_fact]
# audit_agent_prompt: | # 审批模式;留空使用内置默认,可在「人机协同」页编辑
# audit_agent_prompt_review_edit: | # 审查编辑模式;留空使用内置默认
@@ -147,47 +147,101 @@ hitl:
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
规则优先级:
- 必须 reject 的规则
- 必须 approve 的规则
- 默认放行规则
规则匹配要求:
- 如果同时命中 reject 和 approve,必须 reject。
- 每次裁决必须给出一个规则编号,不得输出“命中规则:无”。
- 未命中明确 reject 或 approve 规则,但未发现明确破坏行为时,命中 D1:默认放行。
- 不得仅凭“本地执行”“设置超时”“文件名包含 test”判断脚本无害;必须检查实际 command、代码或请求参数。
- 如果脚本具体内容不可见,应表述为“当前参数未显示明确高危操作”,不能断言“无持久化影响”。
裁决基调(默认放行):
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
- 仅在「可能对系统造成实质影响」时 → reject
- 绝大多数渗透测试操作(包括各类攻击 payload)都应放行
- 在裁决之前需要判断出:1、此请求的是干什么(理解请求内容)?2、此请求造成什么后果?
- 仅在「明确会直接破坏目标业务系统正常运行,或造成真实用户数据丢失/不可逆篡改」时 → reject
- 信息不足、参数略宽泛、仅为探测意图时 → approve
- 拿不准时 → approve(宁可放过可恢复的测试,不要误拦合法攻击)
必须 reject 的高危情形(示例,非穷举):
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
- 明显越权:与任务/授权目标无关的破坏性操作
- 修改/重置任意用户或管理员密码、强制下线真实用户
- 修改服务器或服务配置(系统配置、Web/数据库参数、防火墙、启动项、计划任务)
- 修改/创建/删除用户、角色、权限(账户体系篡改)
- 删除或大规模破坏数据/文件(DROP/TRUNCATE/DELETE 全表、rm 重要文件、清空数据库)
- 停止、禁用、重启业务服务(systemctl stop、kill 关键进程、shutdown、reboot
- 大规模压测或高并发造成 DoS
不应单独作为 reject 理由的情形
- 常规 nmap/curl/grep/读文件/枚举类命令本身
- 参数略显宽泛但无明确破坏意图
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点
以下情形必须 approve(即使看起来“攻击性强”或包含写操作)
- 各类漏洞探测与利用 payload(SQL 注入含读写型、命令注入、反序列化、文件包含、路径遍历、SSRF、SSTI、XXE 等)
- 文件上传测试,包括上传 webshell、写入 webshell 文件
- 命令执行探测、反弹 Shell、dnslog / 数据外带探测
- 数据库命令执行、写文件、写计划任务、内存马注入等 getshell 利用
- 常规速率的口令爆破(非 DoS 级)
- 信息收集、端口/目录扫描、枚举、抓取
- 读取、查询、探测类操作(含敏感文件)
- 攻陷后无害侦察命令(whoami/id/uname/ls/cat 等)
- 参数宽泛但无明确破坏意图的请求
判定原则:
- “写”本身不是拦截理由——渗透测试大量依赖写操作(注入、上传、爆破、getshell)
- 关键看是否破坏「业务可用性 / 关键数据完整性」
- 只有明确命中上方 reject 清单才 reject
仅输出一行 JSON,不要 markdown 代码块:
{"decision":"approve"|"reject","comment":"简要理由"}
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:..."}
audit_agent_prompt_review_edit: |-
你是 CyberStrikeAI 人机协同审计 Agent。审查 Agent 即将执行的工具调用是否会对系统造成实质性损害。
你会收到 JSON,包含 hitlMode、toolName、arguments/argumentsObj、userMessage、thinking、reasoningChain、planning 等字段。
规则优先级:
- 必须 reject 的规则
- 必须 approve 的规则
- 默认放行规则
规则匹配要求:
- 如果同时命中 reject 和 approve,必须 reject。
- 每次裁决必须给出一个规则编号,不得输出“命中规则:无”。
- 未命中明确 reject 或 approve 规则,但未发现明确破坏行为时,命中 D1:默认放行。
- 不得仅凭“本地执行”“设置超时”“文件名包含 test”判断脚本无害;必须检查实际 command、代码或请求参数。
- 如果脚本具体内容不可见,应表述为“当前参数未显示明确高危操作”,不能断言“无持久化影响”。
裁决基调(默认放行):
- 常规、低风险的渗透测试操作 → approve(如信息收集、端口/服务扫描、目录枚举、只读查询、无害探测命令)
- 与用户授权、当前任务目标一致,且未见明确高危迹象 → approve
- 仅在「可能对系统造成实质影响」时 → reject;参数可安全收窄时优先 approve + editedArguments
- 绝大多数渗透测试操作(包括各类攻击 payload)都应放行
- 在裁决之前需要判断出:1、此请求的是干什么(理解请求内容)?2、此请求造成什么后果?
- 仅在「明确会直接破坏目标业务系统正常运行,或造成真实用户数据丢失/不可逆篡改」时 → reject
- 信息不足、参数略宽泛、仅为探测意图时 → approve
- 拿不准时 → approve(宁可放过可恢复的测试,不要误拦合法攻击)
必须 reject 的高危情形(示例,非穷举):
- 删库、清表、批量删除数据、格式化磁盘、不可逆破坏
- 修改/重置密码、创建或篡改管理员账号、持久化后门、开机自启
- 向生产环境写入恶意载荷、勒索加密、停止关键服务、修改系统核心配置
- 明显越权:与任务/授权目标无关的破坏性操作
- 修改/重置任意用户或管理员密码、强制下线真实用户
- 修改服务器或服务配置(系统配置、Web/数据库参数、防火墙、启动项、计划任务)
- 修改/创建/删除用户、角色、权限(账户体系篡改)
- 删除或大规模破坏数据/文件(DROP/TRUNCATE/DELETE 全表、rm 重要文件、清空数据库)
- 停止、禁用、重启业务服务(systemctl stop、kill 关键进程、shutdown、reboot
- 大规模压测或高并发造成 DoS
不应单独作为 reject 理由的情形
- 常规 nmap/curl/grep/读文件/枚举类命令本身
- 参数略显宽泛但无明确破坏意图(应收窄参数后 approve)
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点
以下情形必须 approve(即使看起来“攻击性强”或包含写操作)
- 各类漏洞探测与利用 payload(SQL 注入含读写型、命令注入、反序列化、文件包含、路径遍历、SSRF、SSTI、XXE 等)
- 文件上传测试,包括上传 webshell、写入 webshell 文件
- 命令执行探测、反弹 Shell、dnslog / 数据外带探测
- 数据库命令执行、写文件、写计划任务、内存马注入等 getshell 利用
- 常规速率的口令爆破(非 DoS 级)
- 信息收集、端口/目录扫描、枚举、抓取
- 读取、查询、探测类操作(含敏感文件)
- 攻陷后无害侦察命令(whoami/id/uname/ls/cat 等)
- 参数宽泛但无明确破坏意图的请求
判定原则:
- “写”本身不是拦截理由——渗透测试大量依赖写操作(注入、上传、爆破、getshell)
- 关键看是否破坏「业务可用性 / 关键数据完整性」
- 只有明确命中上方 reject 清单才 reject
仅输出一行 JSON,不要 markdown 代码块:
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:...","editedArguments":{...}}
editedArguments 规则(仅 approve 且需要改参时填写,否则省略该字段):
- 提供完整替换后的工具参数对象,键名与 argumentsObj 一致
+43 -11
View File
@@ -34,7 +34,39 @@ Saved workflows can be bound to a role under **Role Management**. When `workflow
---
## 3. Execution model (read this before configuring)
## 3. Natural-language Draft Generation
The Workflows page provides a **Create from natural language** entry point. After a user describes a security operations goal, CyberStrikeAI generates an editable draft and returns a structured audit result:
- Draft generation does not save, dry-run, or execute tools automatically.
- The server endpoint is `POST /api/workflows/generate-draft`, protected by `workflow:write`.
- The response includes `graph`, `meta`, `capabilities`, `audit`, and `stats`; after applying the draft to the canvas, normal save validation still runs.
- High-risk language such as executing scripts, isolating hosts, blocking, deleting, or exploitation is marked as `high_risk` and defaults to HITL approval or `requires_human_confirmation`.
- Tool capabilities are matched against the available tool list; unmatched capabilities fall back to Agent draft nodes and are surfaced in `audit.missing_fields` / `audit.assumptions`.
- If server generation is unavailable, the frontend uses a local deterministic fallback and still returns an editable draft with risk notes.
Example request:
```http
POST /api/workflows/generate-draft
Content-Type: application/json
{
"prompt": "Scan target assets for open ports, create a vulnerability task when critical ports are found, ask the owner for approval, and output a report",
"options": {
"include_objective": true,
"allow_schedule": false,
"allow_high_risk": false
},
"available_tools": [
{ "key": "nmap", "name": "nmap", "enabled": true }
]
}
```
---
## 4. Execution model (read this before configuring)
The engine executes the workflow as a **directed graph**, starting from the **Start** node and following edges to downstream nodes.
@@ -116,7 +148,7 @@ Agent B still receives Agent As output even when a condition node lies betwee
---
## 4. Template syntax
## 5. Template syntax
### 4.1 Basic format
@@ -198,7 +230,7 @@ Field bindings can read ordinary fields such as `output` or `message`, and also
---
## 5. Node types and configuration
## 6. Node types and configuration
### 5.1 Start
@@ -318,7 +350,7 @@ Optional node for an end summary template (less common in role-bound flows).
---
## 6. Edge configuration
## 7. Edge configuration
Select an **edge** to configure its **condition** in the right panel.
@@ -335,7 +367,7 @@ If no edge condition is set:
---
## 7. Full example: passing Agent output across a condition
## 8. Full example: passing Agent output across a condition
### 7.1 Graph structure
@@ -384,7 +416,7 @@ Start → Agent (initial value) → Condition → Agent (transform) → Output
---
## 8. Bind to a role and run
## 9. Bind to a role and run
### 8.1 Bind in Role Management
@@ -414,7 +446,7 @@ If no Output node is reached or no branch matches, `outputs` may be empty and th
---
## 9. Debugging, dry-run, and replay
## 10. Debugging, dry-run, and replay
### 9.1 Safe dry-run
@@ -487,7 +519,7 @@ Token and cost metrics depend on whether the underlying model/Agent events repor
---
## 10. Validation before save
## 11. Validation before save
On save, the system checks:
@@ -510,7 +542,7 @@ On save, the system checks:
---
## 11. Troubleshooting
## 12. Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
@@ -526,7 +558,7 @@ On save, the system checks:
---
## 12. Best practices
## 13. Best practices
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
@@ -539,7 +571,7 @@ On save, the system checks:
---
## 13. Code references (for developers)
## 14. Code references (for developers)
| Module | Path |
|--------|------|
+1 -1
View File
@@ -12,7 +12,7 @@
## 核心概念与编排
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md)
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md) · [Agent 最终回复治理](agent-finalization-best-practices.md)
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
## 功能指南
@@ -0,0 +1,333 @@
# Agent 最终回复治理最佳实践
[返回中文文档](README.md)
调研日期:2026-07-28
本文聚焦一个具体问题:Agent 在工具调用、推理、计划或子代理协作尚未真正完成时,输出了一段“像结论”的自然语言,前端或编排层把它当作最终回复展示。结论先说清楚:成熟 Agent 系统不会用“最近一段 assistant 文本”判断任务完成,而是用运行时状态、工具状态、验证结果和显式终态事件共同决定是否 final。
## 一、核心结论
1. **最终回复是运行时事件,不是自然语言内容。**
“已拿到”“下一步”“Huge breakthrough”这类文本只能作为候选观察或进展,不能作为完成信号。
2. **过程面和交付面必须隔离。**
`thinking``reasoning_chain``planning``response_delta`、子代理回复、工具输出都属于过程面;只有通过 final gate 的 `response` / `final` 事件才能写入主消息气泡和 `messages.content`
3. **复杂任务需要 verifier,而不是更长 prompt。**
Prompt 可以提醒模型谨慎,但最终完成必须由代码层判断:是否仍有待执行工具、后台 execution、未完成计划步骤、未验证证据、未记录事实/漏洞、未清理或未说明不可清理。
4. **不同 agent 模式不同,但 final 治理原则一致。**
单代理、Deep、Plan-Execute、Supervisor 都需要 final gate。区别只是 gate 的证据来源不同:单代理看工具轨迹,Deep 还要看子代理结果,Plan-Execute 要看 Replanner 的终止判断,Supervisor 要看 `exit` 与 supervisor 汇总。
## 二、成熟 Agent 的公开做法
| 系统 | 公开做法 | 对 final 治理的启发 |
|---|---|---|
| Codex | OpenAI 的 Codex prompting guide 建议不要在 prompt 中强行要求 upfront plan、preamble 或 status updates,因为这可能导致 rollout 未完成就停止。 | 不要把“模型自己说的阶段性计划/状态”当完成依据;agent harness 应负责执行循环和收尾。 |
| Claude Code | Claude Code 提供 `PreToolUse``PostToolUse``Stop` 等 hooks`PostToolUse` 明确发生在工具成功执行之后。 | 生命周期事件比自然语言可靠。验证、审计、阻断应挂在确定的阶段边界上。 |
| Claude Code Subagents | 子代理有独立上下文、自定义系统提示、特定工具权限和独立权限;子代理适合隔离大量检索/日志/文件读取。 | 子代理输出是证据材料,不是主任务最终结论;主代理必须汇总、验收、再 final。 |
| Claude Code Plan Mode | Plan mode 先读文件并产出计划,获得批准前不编辑。 | 计划与执行是不同状态;计划完成不等于任务完成。 |
| Cursor Plan Mode | Cursor Plan Mode 会研究代码库、询问澄清问题、生成可审查计划,并等待用户确认后再构建。 | UI 层把 plan/review/build 拆开,用户不会把计划误认为最终交付。 |
| OpenCode | OpenCode 把 Build、Plan、Review、Debug、Docs 等 agent 分成不同工具权限与用途,Plan agent 只分析规划不做修改。 | 用 agent 能力边界降低误触发:能规划的 agent 不等于能执行完成。 |
| Eino ADK | Eino ADK 提供事件驱动输出、Runner 回调、中断、checkpoint,以及 Supervisor、Plan-Execute 等协作原语。Plan-Execute 由 Planner、Executor、Replanner 协作。 | 当前项目选型方向正确;需要把事件驱动能力进一步固化为 finalization contract。 |
主要参考:
- OpenAI Codex Prompting Guide: https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide
- Claude Code Hooks: https://docs.anthropic.com/en/docs/claude-code/hooks
- Claude Code Subagents: https://docs.anthropic.com/en/docs/claude-code/sub-agents
- Claude Code Common Workflows: https://docs.anthropic.com/en/docs/claude-code/common-workflows
- Cursor Agent Best Practices: https://cursor.com/blog/agent-best-practices
- OpenCode Agents: https://opencode.ai/docs/agents/
- CloudWeGo Eino ADK: https://www.cloudwego.io/docs/eino/core_modules/eino_adk/
- CloudWeGo Eino ADK Patterns: https://www.cloudwego.io/docs/eino/overview/eino_adk0_1/
## 三、通用最佳实践
### 1. 建立 Finalization Contract
所有执行入口统一产出一个结构化收尾对象,只有它允许触发最终回复。
```go
type FinalizationDecision struct {
Status string // in_progress | completed | blocked | failed | cancelled
Finalizable bool
CompletionReason string // verified | user_cancelled | timeout | blocked | failed
FinalText string
EvidenceVerified bool
EvidenceRefs []string
PendingToolRuns []string
PendingPlanSteps []string
PendingApprovals []string
MissingChecks []string
}
```
硬规则:
- `Finalizable=false` 时禁止发送 `response` 终态事件。
- `Status=in_progress` 时只能发 `progress``planning``tool_*``reasoning_chain` 等过程事件。
- `FinalText` 不能为空,但非空不代表可以 final。
- `PendingToolRuns``PendingPlanSteps``PendingApprovals` 任一非空时不能 `completed`
- `EvidenceVerified=false` 时不能把候选输出写成已验证结论。
### 2. 固定 SSE 事件语义
推荐事件分层:
| 事件 | 展示位置 | 可否写 `messages.content` | 说明 |
|---|---|---:|---|
| `progress` | 任务状态/时间线 | 否 | 简短进度 |
| `planning` | 执行详情 | 否 | 主代理计划、阶段性判断 |
| `reasoning_chain` / `thinking` | 执行详情 | 否 | 推理/思考摘要 |
| `tool_call` / `tool_result` | 执行详情 | 否 | 工具事件 |
| `eino_agent_reply` | 执行详情 | 否 | 子代理返回材料 |
| `finalization_check` | 执行详情 | 否 | verifier 结果 |
| `finalization_auto_continue` | 执行详情 | 否 | verifier 触发的工程续跑,`contextInjection=false` |
| `response` | 主消息气泡 | 是 | 只能在 `data.finalized=true` 时使用 |
| `done` | 关闭流 | 否 | 仅表示流结束,不表示任务成功 |
| `error` / `cancelled` | 主消息气泡或系统提示 | 是,终态失败类 | 必须带原因 |
### 3. 把“最终候选”与“最终回复”分开
模型可以输出候选结论,但候选结论必须先进入 `final_candidate``planning`,再由 verifier 决定是否提升:
```text
assistant text
-> candidate
-> finalization gate
-> response(finalized=true)
```
不要这样做:
```text
assistant text
-> response
```
### 4. Stop-time Verification
借鉴 Claude Code hook 思路,在 agent run 停止时做一次确定性检查:
- 所有工具调用都有对应 tool result。
- 后台 execution 都处于 terminal 状态,或被明确登记为仍在运行且任务状态为 `in_progress` / `blocked`
- Plan-Execute 没有未执行的 required step。
- Supervisor 没有未汇总的子代理结果。
- 在 evidence-required 策略下,至少存在可查询到的 completed 工具执行证据。
### 5. 子代理输出只作证据
子代理返回不能直接成为用户最终回复。主代理必须完成:
- 去重和冲突合并。
- 证据强度排序。
- 不确定性标注。
- 范围边界确认。
- 用户可读交付。
### 6. Prompt 只做软约束,代码做硬约束
Prompt 中可以写:
```text
Interim observations must be marked as progress, not final.
Do not produce a final answer until verification is complete.
```
但真正决定 final 的必须是后端字段和状态机。否则模型只要生成一段像最终结论的自然语言,UI 仍可能误判。
## 四、CyberStrikeAI 当前落地状态
当前项目已经具备一套显式 final gate:
- [internal/agentfinalizer/decision.go](../../internal/agentfinalizer/decision.go) 是唯一的最终回复决策契约。
- [internal/handler/finalization_helpers.go](../../internal/handler/finalization_helpers.go) 负责把决策结果写入 `process_details`,并且只有 `Finalizable=true` 时才调用 `UpdateAssistantMessageFinalize`
- [internal/handler/eino_single_agent.go](../../internal/handler/eino_single_agent.go)、[internal/handler/multi_agent.go](../../internal/handler/multi_agent.go)、[internal/handler/workflow_integration.go](../../internal/handler/workflow_integration.go)、[internal/handler/batch_queue_executor.go](../../internal/handler/batch_queue_executor.go) 均已在收尾处接入 finalizer。
- [web/static/js/monitor.js](../../web/static/js/monitor.js) 只把 `data.finalized === true``response` 当最终回复;未最终化文本会显示为最终回复检查未通过。
- [web/static/js/webshell.js](../../web/static/js/webshell.js) 将流式正文标记为候选输出,只有 `response(finalized=true)` 才切换为完成态。
- [internal/agentfinalizer/decision_test.go](../../internal/agentfinalizer/decision_test.go) 覆盖 pending tool、HITL、空输出、证据策略要求但缺执行证据、失败证据不能支撑最终化、完成态证据可 final 等回归场景。
- [internal/handler/finalization_auto_continue.go](../../internal/handler/finalization_auto_continue.go) 在缺 completed 执行证据时最多自动续跑 2 段;续跑只恢复已有模型轨迹,不向 agent 注入新的 user/system 文案。
当前契约的核心规则:
1. **模型自然语言只是 candidate。**
`RunResult.Response` 不能直接升级为最终回复,必须经过 `agentfinalizer.Decide`
2. **所有 `response` 事件必须携带终态字段。**
至少包含 `finalized``finalizable``status``completionReason``evidenceVerified``evidenceRefs``pendingExecutionIds``missingChecks`
3. **未完成工具会阻断 final。**
`queued/running` 工具执行仍存在时,决策结果为 `in_progress/pending_tool_executions`
4. **执行证据必须由结构化策略声明。**
后端不从用户自然语言、助手回复或 agent mode 名称中推断执行意图。聊天请求通过 `finalization.requireExecutionEvidence` 显式声明;WebShell、Workflow、批量、机器人等执行入口由调用点显式传入 policy。policy 要求证据时,至少需要一个可查询到的 `completed` 工具执行记录;只有 failed/cancelled 记录不能支撑最终化。
5. **缺执行证据先工程续跑,再阻断。**
Eino 单代理和 Eino 多代理主链路在 `missing_execution_evidence` 时会先通过已有 trace 自动续跑,不注入额外上下文;达到续跑上限后仍缺证据才写入 blocked。
6. **HITL 和空输出不会 final。**
workflow 等待人工确认、空 assistant 文本、Eino 空输出占位均会写入阻断文案,而不是成功总结。
## 五、贴合当前项目的推荐架构
当前采用的链路是:
```text
Agent / Eino ADK events
-> event normalizer
-> process_details
-> finalization verifier
-> response(finalized=true)
-> messages.content
```
### 1. 后端统一 Finalizer
职责:
- 接收 `RunResult` / 候选文本、`mcpExecutionIds`、会话与助手消息 ID、HITL 状态、编排模式。
- 通过数据库查询工具执行状态,识别 pending、completed、failed、cancelled 等证据状态。
- 返回 `FinalizationDecision`
- 不调用高风险工具,只做状态和证据检查。
### 2. RunResult 终态字段
[internal/multiagent/runner.go](../../internal/multiagent/runner.go) 已扩展终态字段:
```go
type RunResult struct {
Response string
MCPExecutionIDs []string
LastAgentTraceInput string
LastAgentTraceOutput string
Finalized bool
Status string
CompletionReason string
EvidenceVerified bool
EvidenceRefs []string
PendingExecutionIDs []string
MissingChecks []string
}
```
### 3. 发送 `response` 的条件
在单代理、多代理、工作流、批处理收尾处统一执行:
```go
decision := h.finalizeAgentRunForDelivery(...)
if !decision.Finalizable {
sendEvent("finalization_check", "任务尚未达到最终回复条件", decision)
sendEvent("response", finalizationBlockedMessage(decision), finalizationResponsePayload(decision, extra))
return
}
sendEvent("response", decision.FinalText, finalizationResponsePayload(decision, extra))
```
### 4. 前端只信 `finalized=true`
在 [web/static/js/monitor.js](../../web/static/js/monitor.js) 的 `case 'response'` 中执行硬判断:
```js
const responseFinalized = isFinalizedResponseData(responseData);
const bubbleText = responseFinalized
? resolvedResponseText
: (event.message || '任务尚未达到最终回复条件,暂不生成成功结论。');
markAssistantFinalizationState(assistantIdFinal, responseData);
```
WebShell 侧同理:`response_delta` 可以用于实时预览,但 UI 文案应标记为“执行中输出”,只有最终 `response(finalized=true)` 才显示为完成态。
### 5. 各模式 final gate
| 模式 | 谁可以产出最终候选 | 谁决定 final | 必须检查 |
|---|---|---|---|
| Eino 单代理 | 单代理最后助手文本 | Finalizer | 无 pending tool、证据引用完整、任务状态 terminal |
| Deep | 主代理汇总文本 | Finalizer | 子代理结果已汇总;子代理文本不能直接 final;工具状态 terminal |
| Plan-Execute | Replanner 结束后的汇总文本 | Replanner + Finalizer | Executor 单步输出不能 final;计划步骤完成或明确 blocked |
| Supervisor | Supervisor 的 `exit` / 汇总文本 | Supervisor + Finalizer | transfer 已返回;无未处理专家结果;最终由 supervisor 统一口径 |
### 6. 安全测试场景的证据 gate
安全测试、WebShell、批量验证、Workflow 和多代理执行等 evidence-required 场景,最终回复必须至少满足:
- 有明确目标和授权范围标识。
- 有可复核证据引用,例如工具 execution id、请求/响应摘要、截图路径、命令输出摘要、事实/漏洞记录 ID。
- 有身份或影响验证结果,而不是只凭 marker 文本判断。
- 已记录到项目黑板或漏洞库,或明确说明未绑定项目导致无法记录。
- 高风险动作已清理、回滚、取消,或明确说明未执行清理的原因。
- 仍在运行的扫描/命令/WebShell/C2 任务不能被隐式当作完成。
注意:这里的 gate 是治理规则,不要求最终报告暴露敏感利用细节;可以只给证据摘要和内部引用。
## 六、落地状态与后续增强
### P0:先修“误 final”(已落地)
1. 已引入 `FinalizationDecision`
2. 主要 agent SSE `response` 事件已携带 `data.finalized/finalizable/status/completionReason` 等字段。
3. 前端 `monitor.js``webshell.js` 已按 `finalized=true` 区分候选输出和最终回复。
4. `RunResult.Response` 仍保留兼容字段名,但语义已由 finalizer 统一提升;后续可再拆成 `CandidateResponse` / `FinalResponse`,减少误用空间。
5. Plan-Execute / Deep / Supervisor / Eino Single 等模式均通过统一 handler 收尾 gate。
### P1:补证据链(部分落地)
1. 已用 `mcp_execution:<id>` 作为基础 evidence refs。
2. `finalization_check` 事件已展示 pending execution 与 missing checks。
3. 执行入口已启用显式 execution evidence policyEino 主链路在 policy 要求证据且缺少 completed 工具证据时先无注入续跑,达到上限后才阻断 final。
4. 后续建议:为 `record_vulnerability``upsert_project_fact`、项目黑板记录建立更细粒度 evidence refs。
5. 后续建议:最终报告模板固定包含“结论、证据、风险/不确定性、后续动作”。
### P2:体验和观测(后续增强)
1. 在任务卡片展示 `in_progress / verifying / finalizing / completed / blocked`
2. 为 finalizer 加日志和指标:误拦截率、缺失证据类型、pending tool 数量。
3. 支持“继续验证”按钮,从 `FinalizationDecision.MissingChecks` 自动生成下一轮输入。
## 七、验收测试建议
至少加入这些回归测试:
1. **推理文本不 final**
模拟 `reasoning_chain` 里出现看似完成的候选结论,但本轮没有 completed 工具执行证据;预期主消息气泡不显示成功结论,只显示执行中或阻断态。
2. **主代理阶段性输出不 final**
模拟 `response_start/delta` 输出“下一步继续验证”;预期只进入 timeline `planning`
3. **未完成后台工具不 final**
工具返回 `execution_id` 且状态 `running`;即使模型给出总结,也只能 `in_progress`
4. **Plan-Execute Executor 输出不 final**
Executor 输出“突破成功”,但 Replanner 未结束;预期不触发 `messages.content` finalize。
5. **Supervisor 子代理输出不 final**
子代理返回确定结论,Supervisor 未 `exit`;预期只进入 `eino_agent_reply`
6. **最终事件必须带 finalized**
前端收到旧格式 `response``finalized=true`;预期候选内容只进入详情/警告,主消息显示阻断态,不创建成功最终气泡。
7. **失败和取消可终态**
`error` / `cancelled` 仍可更新助手消息,但 `completionReason` 必须是 `failed` / `user_cancelled`,不能伪装为成功完成。
## 八、推荐默认策略
对 CyberStrikeAI,建议默认策略是:
```text
eino_single:轻量任务可用,但 final gate 必须开启
deep:复杂安全测试默认推荐
plan_execute:目标明确、需要严格“规划-执行-重规划”的任务推荐
supervisor:多专家路由任务使用,不作为默认泛化模式
```
最终治理一句话:
```text
messages.content 只能来自 FinalizationDecision.FinalText
process_details 可以展示所有过程;
前端只能把 response(finalized=true) 当最终回复。
```
+43 -11
View File
@@ -34,7 +34,39 @@
---
## 三、执行模型(先理解再配置)
## 三、自然语言生成草稿
工作流页面提供 **用自然语言创建** 入口。用户描述一句安全作业目标后,平台会生成一个可编辑草稿,并返回结构化审计结果:
- 只生成草稿,不会自动保存、试运行或真实执行工具。
- 服务端接口为 `POST /api/workflows/generate-draft`,权限为 `workflow:write`
- 生成结果包含 `graph``meta``capabilities``audit``stats`,前端应用到画布后仍走现有保存校验。
- 高风险语义(执行脚本、隔离、封禁、删除、利用等)会标记 `high_risk`,并默认插入 HITL 审批或 `requires_human_confirmation`
- 工具能力按已有工具列表匹配;未匹配到时降级为 Agent 草稿,并在 `audit.missing_fields` / `audit.assumptions` 中提示需要补配置。
- 如果服务端生成不可用,前端会使用本地确定性兜底生成器,继续给出可编辑草稿和风险提示。
示例请求:
```http
POST /api/workflows/generate-draft
Content-Type: application/json
{
"prompt": "",
"options": {
"include_objective": true,
"allow_schedule": false,
"allow_high_risk": false
},
"available_tools": [
{ "key": "nmap", "name": "nmap", "enabled": true }
]
}
```
---
## 四、执行模型(先理解再配置)
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
@@ -116,7 +148,7 @@ outputs["你填的变量名"] = 节点输出内容
---
## 、模板语法
## 、模板语法
### 4.1 基本格式
@@ -198,7 +230,7 @@ jq({{outputs.scan}}, ".severity") == "high"
---
## 、节点类型与配置
## 、节点类型与配置
### 5.1 开始(start
@@ -318,7 +350,7 @@ HITL 等待信息会记录:
---
## 、连线配置
## 、连线配置
选中 **连线** 后,右侧可配置 **连线条件**
@@ -335,7 +367,7 @@ HITL 等待信息会记录:
---
## 、完整示例:跨条件节点传递 Agent 输出
## 、完整示例:跨条件节点传递 Agent 输出
### 7.1 流程结构
@@ -384,7 +416,7 @@ HITL 等待信息会记录:
---
## 、绑定角色并运行
## 、绑定角色并运行
### 8.1 在角色管理中绑定
@@ -414,7 +446,7 @@ workflow_policy: auto
---
## 、调试、试运行与复盘
## 、调试、试运行与复盘
### 9.1 安全试运行(dry-run
@@ -487,7 +519,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、保存前校验规则
## 十、保存前校验规则
保存时系统会自动检查:
@@ -510,7 +542,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、排错指南
## 十、排错指南
| 现象 | 可能原因 | 处理建议 |
|------|----------|----------|
@@ -526,7 +558,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、最佳实践
## 十、最佳实践
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result``parsed_targets`,避免都叫 `agent_result`
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
@@ -539,7 +571,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、相关代码位置(开发者参考)
## 十、相关代码位置(开发者参考)
| 模块 | 路径 |
|------|------|
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+266
View File
@@ -0,0 +1,266 @@
package agentfinalizer
import (
"strings"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
)
const (
StatusCompleted = "completed"
StatusInProgress = "in_progress"
StatusBlocked = "blocked"
StatusFailed = "failed"
StatusCancelled = "cancelled"
StatusAwaitingHITL = "awaiting_hitl"
ReasonVerified = "verified"
ReasonPendingTools = "pending_tool_executions"
ReasonEmptyResponse = "empty_response"
ReasonAwaitingHITL = "awaiting_hitl"
ReasonFailed = "failed"
ReasonCancelled = "cancelled"
ReasonMissingEvidence = "missing_execution_evidence"
)
// Decision is the single contract that may promote an agent run to a final
// user-facing answer. Natural-language assistant text is only a candidate until
// this object says Finalizable.
type Decision struct {
Status string `json:"status"`
Finalizable bool `json:"finalizable"`
Finalized bool `json:"finalized"`
CompletionReason string `json:"completionReason"`
FinalText string `json:"finalText,omitempty"`
EvidenceVerified bool `json:"evidenceVerified"`
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
PendingToolRuns []string `json:"pendingToolRuns,omitempty"`
MissingChecks []string `json:"missingChecks,omitempty"`
AgentMode string `json:"agentMode,omitempty"`
ConversationID string `json:"conversationId,omitempty"`
AssistantMessageID string `json:"messageId,omitempty"`
CandidateResponseLen int `json:"candidateResponseLen,omitempty"`
}
type Input struct {
Response string
MCPExecutionIDs []string
ConversationID string
AssistantMessageID string
AgentMode string
Status string
CompletionReason string
AwaitingHITL bool
RequireExecutionEvidence bool
}
func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision {
if result != nil {
if strings.TrimSpace(in.Response) == "" {
in.Response = result.Response
}
if len(in.MCPExecutionIDs) == 0 {
in.MCPExecutionIDs = result.MCPExecutionIDs
}
if strings.TrimSpace(in.Status) == "" {
in.Status = result.Status
}
if strings.TrimSpace(in.CompletionReason) == "" {
in.CompletionReason = result.CompletionReason
}
}
d := Decide(db, in)
if result != nil {
result.Finalized = d.Finalized
result.Status = d.Status
result.CompletionReason = d.CompletionReason
result.EvidenceVerified = d.EvidenceVerified
result.EvidenceRefs = append([]string(nil), d.EvidenceRefs...)
result.PendingExecutionIDs = append([]string(nil), d.PendingExecutionIDs...)
result.MissingChecks = append([]string(nil), d.MissingChecks...)
}
return d
}
func Decide(db *database.DB, in Input) Decision {
text := strings.TrimSpace(in.Response)
status := strings.TrimSpace(in.Status)
if status == "" {
status = StatusCompleted
}
reason := strings.TrimSpace(in.CompletionReason)
if reason == "" {
reason = ReasonVerified
}
d := Decision{
Status: status,
CompletionReason: reason,
FinalText: text,
EvidenceVerified: true,
EvidenceRefs: evidenceRefs(in.MCPExecutionIDs),
AgentMode: strings.TrimSpace(in.AgentMode),
ConversationID: strings.TrimSpace(in.ConversationID),
AssistantMessageID: strings.TrimSpace(in.AssistantMessageID),
CandidateResponseLen: len([]rune(text)),
}
if in.AwaitingHITL {
d.Status = StatusAwaitingHITL
d.CompletionReason = ReasonAwaitingHITL
d.EvidenceVerified = false
d.MissingChecks = append(d.MissingChecks, "workflow is awaiting HITL approval")
return d
}
if isEmptyCandidate(text) {
d.Status = StatusBlocked
d.CompletionReason = ReasonEmptyResponse
d.EvidenceVerified = false
d.MissingChecks = append(d.MissingChecks, "assistant final text is empty or only an empty-response placeholder")
return d
}
switch status {
case StatusInProgress, StatusBlocked, StatusFailed, StatusCancelled, StatusAwaitingHITL:
d.Status = status
d.EvidenceVerified = false
if d.CompletionReason == ReasonVerified {
d.CompletionReason = status
}
d.MissingChecks = append(d.MissingChecks, "agent run status is "+status)
return d
}
pending := pendingExecutions(db, in.MCPExecutionIDs)
if len(pending) > 0 {
d.Status = StatusInProgress
d.CompletionReason = ReasonPendingTools
d.EvidenceVerified = false
d.PendingExecutionIDs = pending
d.PendingToolRuns = append([]string(nil), pending...)
d.MissingChecks = append(d.MissingChecks, "tool execution still queued or running")
return d
}
if in.RequireExecutionEvidence && !hasCompletedEvidence(db, in.MCPExecutionIDs) {
d.Status = StatusBlocked
d.CompletionReason = ReasonMissingEvidence
d.EvidenceVerified = false
d.MissingChecks = append(d.MissingChecks, "execution evidence is required but no completed tool execution was recorded")
return d
}
d.Finalizable = true
d.Finalized = true
d.Status = StatusCompleted
if d.CompletionReason == "" {
d.CompletionReason = ReasonVerified
}
return d
}
func ResponsePayload(d Decision, extra map[string]interface{}) map[string]interface{} {
out := map[string]interface{}{
"finalized": d.Finalized,
"finalizable": d.Finalizable,
"status": d.Status,
"completionReason": d.CompletionReason,
"evidenceVerified": d.EvidenceVerified,
"evidenceRefs": d.EvidenceRefs,
"pendingExecutionIds": d.PendingExecutionIDs,
"pendingToolRuns": d.PendingToolRuns,
"missingChecks": d.MissingChecks,
}
if d.ConversationID != "" {
out["conversationId"] = d.ConversationID
}
if d.AssistantMessageID != "" {
out["messageId"] = d.AssistantMessageID
}
if d.AgentMode != "" {
out["agentMode"] = d.AgentMode
}
for k, v := range extra {
out[k] = v
}
return out
}
func isEmptyCandidate(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
return strings.Contains(s, "no assistant text was captured") ||
strings.Contains(s, "未捕获到助手文本输出")
}
func evidenceRefs(ids []string) []string {
out := make([]string, 0, len(ids))
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, "mcp_execution:"+id)
}
return out
}
func pendingExecutions(db *database.DB, ids []string) []string {
if db == nil || len(ids) == 0 {
return nil
}
out := make([]string, 0)
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
exec, err := db.GetToolExecution(id)
if err != nil || exec == nil {
continue
}
switch strings.TrimSpace(exec.Status) {
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
out = append(out, id)
}
}
return out
}
func hasCompletedEvidence(db *database.DB, ids []string) bool {
if db == nil || len(ids) == 0 {
return false
}
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
exec, err := db.GetToolExecution(id)
if err != nil || exec == nil {
continue
}
if strings.TrimSpace(exec.Status) == mcp.ToolExecutionStatusCompleted {
return true
}
}
return false
}
+132
View File
@@ -0,0 +1,132 @@
package agentfinalizer
import (
"path/filepath"
"testing"
"time"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"go.uber.org/zap"
)
func newDecisionTestDB(t *testing.T) *database.DB {
t.Helper()
db, err := database.NewDB(filepath.Join(t.TempDir(), "finalizer.db"), zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func saveDecisionTestExecution(t *testing.T, db *database.DB, id, status string) {
t.Helper()
if err := db.SaveToolExecution(&mcp.ToolExecution{
ID: id,
ToolName: "test::tool",
Arguments: map[string]interface{}{"input": id},
Status: status,
StartTime: time.Now(),
}); err != nil {
t.Fatalf("SaveToolExecution(%s): %v", id, err)
}
}
func TestDecideBlocksPendingToolExecutions(t *testing.T) {
db := newDecisionTestDB(t)
saveDecisionTestExecution(t, db, "run-queued", mcp.ToolExecutionStatusQueued)
saveDecisionTestExecution(t, db, "run-running", mcp.ToolExecutionStatusRunning)
saveDecisionTestExecution(t, db, "run-completed", mcp.ToolExecutionStatusCompleted)
d := Decide(db, Input{
Response: "工具还没全部结束时,这只是一段候选输出。",
MCPExecutionIDs: []string{"run-queued", "run-running", "run-completed"},
})
if d.Finalizable || d.Finalized {
t.Fatalf("pending tools should not be finalizable: %+v", d)
}
if d.Status != StatusInProgress || d.CompletionReason != ReasonPendingTools {
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusInProgress, ReasonPendingTools)
}
if got, want := len(d.PendingExecutionIDs), 2; got != want {
t.Fatalf("pending execution count = %d, want %d (%v)", got, want, d.PendingExecutionIDs)
}
}
func TestDecideBlocksAwaitingHITLAndEmptyCandidate(t *testing.T) {
hitl := Decide(nil, Input{Response: "等待人工审批", AwaitingHITL: true})
if hitl.Finalizable || hitl.Status != StatusAwaitingHITL || hitl.CompletionReason != ReasonAwaitingHITL {
t.Fatalf("HITL decision mismatch: %+v", hitl)
}
empty := Decide(nil, Input{Response: "⚠️ Eino 执行完成,但未捕获到助手文本输出。"})
if empty.Finalizable || empty.Status != StatusBlocked || empty.CompletionReason != ReasonEmptyResponse {
t.Fatalf("empty candidate decision mismatch: %+v", empty)
}
}
func TestDecideBlocksWhenExecutionEvidenceIsRequiredButMissing(t *testing.T) {
d := Decide(nil, Input{
Response: "任务已处理完成。",
RequireExecutionEvidence: true,
})
if d.Finalizable || d.Finalized {
t.Fatalf("missing required execution evidence should not finalize: %+v", d)
}
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
}
if d.EvidenceVerified {
t.Fatalf("missing required execution evidence should be marked unverified: %+v", d)
}
if len(d.MissingChecks) == 0 {
t.Fatalf("missing checks should explain the evidence gap: %+v", d)
}
}
func TestDecideBlocksWhenOnlyFailedEvidenceIsRecorded(t *testing.T) {
db := newDecisionTestDB(t)
saveDecisionTestExecution(t, db, "run-failed", mcp.ToolExecutionStatusFailed)
saveDecisionTestExecution(t, db, "run-cancelled", mcp.ToolExecutionStatusCancelled)
d := Decide(db, Input{
Response: "任务已处理完成。",
MCPExecutionIDs: []string{"run-failed", "run-cancelled"},
RequireExecutionEvidence: true,
})
if d.Finalizable || d.Finalized {
t.Fatalf("failed evidence should not satisfy required execution evidence: %+v", d)
}
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
}
}
func TestDecideFinalizesCompletedEvidence(t *testing.T) {
db := newDecisionTestDB(t)
saveDecisionTestExecution(t, db, "run-ok", mcp.ToolExecutionStatusCompleted)
d := Decide(db, Input{
Response: "任务已处理完成,见工具执行记录。",
MCPExecutionIDs: []string{"run-ok"},
RequireExecutionEvidence: true,
})
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
t.Fatalf("completed execution should finalize: %+v", d)
}
if !d.EvidenceVerified || len(d.EvidenceRefs) != 1 {
t.Fatalf("evidence refs mismatch: %+v", d)
}
}
func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *testing.T) {
d := Decide(nil, Input{Response: "这是一个概念解释,不需要执行工具。"})
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
}
}
+1
View File
@@ -1362,6 +1362,7 @@ func setupRoutes(
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
protected.POST("/workflows/validate", workflowHandler.Validate)
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
protected.POST("/workflows/generate-draft", workflowHandler.GenerateDraft)
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
+12 -12
View File
@@ -291,16 +291,13 @@ func (l *HTTPBeaconListener) handleResult(w http.ResponseWriter, r *http.Request
}
var report TaskResultReport
plaintext, decErr := DecryptAESGCM(l.rec.EncryptionKey, string(body))
if decErr == nil {
if err := json.Unmarshal(plaintext, &report); err != nil {
l.disguisedReject(w)
return
}
} else {
if err := json.Unmarshal(body, &report); err != nil {
l.disguisedReject(w)
return
}
if decErr != nil {
l.disguisedReject(w)
return
}
if err := json.Unmarshal(plaintext, &report); err != nil {
l.disguisedReject(w)
return
}
if err := l.manager.IngestTaskResult(report); err != nil {
http.Error(w, "ingest result failed", http.StatusInternalServerError)
@@ -341,12 +338,15 @@ func (l *HTTPBeaconListener) handleUpload(w http.ResponseWriter, r *http.Request
l.disguisedReject(w)
return
}
dir := filepath.Join(l.manager.StorageDir(), "uploads")
dir, dst, err := uploadPathForTask(l.manager.StorageDir(), taskID)
if err != nil {
l.disguisedReject(w)
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
http.Error(w, "mkdir failed", http.StatusInternalServerError)
return
}
dst := filepath.Join(dir, taskID+".bin")
if err := os.WriteFile(dst, plaintext, 0o644); err != nil {
http.Error(w, "save failed", http.StatusInternalServerError)
return
+85
View File
@@ -7,6 +7,7 @@ import (
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
@@ -227,3 +228,87 @@ func TestHTTPBeaconListener_HandleFileServe(t *testing.T) {
})
}
}
func TestHTTPBeaconListener_HandleUploadConfinesTaskID(t *testing.T) {
tmp := t.TempDir()
store := filepath.Join(tmp, "c2store")
keyB64, err := GenerateAESKey()
if err != nil {
t.Fatal(err)
}
token := "test-implant-token-upload"
l := &HTTPBeaconListener{
rec: &database.C2Listener{
EncryptionKey: keyB64,
ImplantToken: token,
},
manager: NewManager(nil, zap.NewNop(), store),
logger: zap.NewNop(),
}
encrypted, err := EncryptAESGCM(keyB64, []byte("safe upload"))
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/upload?task_id=t_safe123", strings.NewReader(encrypted))
req.Header.Set("X-Implant-Token", token)
rr := httptest.NewRecorder()
l.handleUpload(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
}
got, err := os.ReadFile(filepath.Join(store, "uploads", "t_safe123.bin"))
if err != nil {
t.Fatal(err)
}
if string(got) != "safe upload" {
t.Fatalf("content=%q", got)
}
evilBody, err := EncryptAESGCM(keyB64, []byte("owned"))
if err != nil {
t.Fatal(err)
}
evilReq := httptest.NewRequest(http.MethodPost, "/upload?task_id=..%2Fowned", strings.NewReader(evilBody))
evilReq.Header.Set("X-Implant-Token", token)
evilRR := httptest.NewRecorder()
l.handleUpload(evilRR, evilReq)
if evilRR.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%q", evilRR.Code, evilRR.Body.String())
}
if _, err := os.Stat(filepath.Join(store, "owned.bin")); !os.IsNotExist(err) {
t.Fatalf("outside file exists or stat failed unexpectedly: %v", err)
}
}
func TestHTTPBeaconListener_HandleResultRejectsPlaintextJSON(t *testing.T) {
keyB64, err := GenerateAESKey()
if err != nil {
t.Fatal(err)
}
l := &HTTPBeaconListener{
rec: &database.C2Listener{
EncryptionKey: keyB64,
ImplantToken: "test-implant-token-result",
},
logger: zap.NewNop(),
}
req := httptest.NewRequest(http.MethodPost, "/result", strings.NewReader(`{"task_id":"t_test","success":true}`))
req.Header.Set("X-Implant-Token", "test-implant-token-result")
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
l.handleResult(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "404 Not Found") {
t.Fatalf("expected disguised 404 body, got %q", rr.Body.String())
}
}
+62 -5
View File
@@ -42,6 +42,11 @@ type Manager struct {
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
const MCPToolC2Task = "c2_task"
var (
resultBlobSuffixPattern = regexp.MustCompile(`^\.[A-Za-z0-9][A-Za-z0-9_-]{0,31}$`)
uploadTaskIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
)
// HITLBridge 把"危险任务"桥到现有 internal/handler/hitl 审批流的接口。
// internal/app 实例化时传入;空实现表示禁用 HITL 拦截(开发期方便)。
type HITLBridge interface {
@@ -736,18 +741,24 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
}
func (m *Manager) saveResultBlob(taskID, b64Content, suffix string) (string, error) {
suffix = strings.TrimSpace(suffix)
if suffix == "" {
suffix = ".bin"
taskID = strings.TrimSpace(taskID)
if taskID == "" || taskID == "." || taskID == ".." ||
strings.ContainsAny(taskID, `/\`) {
return "", fmt.Errorf("invalid task_id")
}
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
suffix, err := normalizeResultBlobSuffix(suffix)
if err != nil {
return "", err
}
dir := filepath.Join(m.storageDir, "results")
if err := osMkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, taskID+suffix)
if err := ensurePathInDir(dir, path); err != nil {
return "", err
}
data, err := base64Decode(b64Content)
if err != nil {
return "", err
@@ -758,6 +769,52 @@ func (m *Manager) saveResultBlob(taskID, b64Content, suffix string) (string, err
return path, nil
}
func uploadPathForTask(storageDir, taskID string) (dir, path string, err error) {
taskID = strings.TrimSpace(taskID)
if !uploadTaskIDPattern.MatchString(taskID) {
return "", "", fmt.Errorf("invalid task_id")
}
dir = filepath.Join(storageDir, "uploads")
path = filepath.Join(dir, taskID+".bin")
if err := ensurePathInDir(dir, path); err != nil {
return "", "", err
}
return dir, path, nil
}
func normalizeResultBlobSuffix(suffix string) (string, error) {
suffix = strings.TrimSpace(suffix)
if suffix == "" {
return ".bin", nil
}
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
}
if !resultBlobSuffixPattern.MatchString(suffix) {
return "", fmt.Errorf("invalid blob suffix")
}
return suffix, nil
}
func ensurePathInDir(dir, path string) error {
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
rel, err := filepath.Rel(absDir, absPath)
if err != nil {
return err
}
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
return fmt.Errorf("path escapes result directory")
}
return nil
}
// ----------------------------------------------------------------------------
// 事件总线辅助
// ----------------------------------------------------------------------------
+75
View File
@@ -0,0 +1,75 @@
package c2
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestManagerSaveResultBlobConfinesPath(t *testing.T) {
tmp := t.TempDir()
mgr := NewManager(nil, zap.NewNop(), filepath.Join(tmp, "c2store"))
content := base64.StdEncoding.EncodeToString([]byte("result bytes"))
got, err := mgr.saveResultBlob("t_safe123", content, "txt")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(tmp, "c2store", "results", "t_safe123.txt")
if got != want {
t.Fatalf("path=%q want %q", got, want)
}
raw, err := os.ReadFile(want)
if err != nil {
t.Fatal(err)
}
if string(raw) != "result bytes" {
t.Fatalf("content=%q", raw)
}
outside := filepath.Join(tmp, "owned")
if _, err := mgr.saveResultBlob("t_safe123", content, "./../../owned"); err == nil {
t.Fatal("expected traversal suffix to be rejected")
}
if _, err := os.Stat(outside); !os.IsNotExist(err) {
t.Fatalf("outside file exists or stat failed unexpectedly: %v", err)
}
}
func TestUploadPathForTaskConfinesPath(t *testing.T) {
tmp := t.TempDir()
store := filepath.Join(tmp, "c2store")
dir, got, err := uploadPathForTask(store, "t_safe123")
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(store, "uploads"); dir != want {
t.Fatalf("dir=%q want %q", dir, want)
}
if want := filepath.Join(store, "uploads", "t_safe123.bin"); got != want {
t.Fatalf("path=%q want %q", got, want)
}
for _, taskID := range []string{"", ".", "..", "../owned", `..\owned`, "sub/owned", "sub\\owned", "task.with.dot", "-leading"} {
if _, _, err := uploadPathForTask(store, taskID); err == nil {
t.Fatalf("task_id %q unexpectedly accepted", taskID)
}
}
}
func TestNormalizeResultBlobSuffix(t *testing.T) {
for _, suffix := range []string{"", "png", ".jpg", ".7z", ".safe_name-1"} {
if _, err := normalizeResultBlobSuffix(suffix); err != nil {
t.Fatalf("suffix %q rejected: %v", suffix, err)
}
}
for _, suffix := range []string{".", "..", "../x", "./../../x", "/tmp/x", `..\x`, ".name.with.dot", ".toolong012345678901234567890123456789"} {
if _, err := normalizeResultBlobSuffix(suffix); err == nil {
t.Fatalf("suffix %q unexpectedly accepted", suffix)
}
}
}
+4 -2
View File
@@ -209,11 +209,13 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea
if err != nil {
return
}
dir := filepath.Join(l.manager.StorageDir(), "uploads")
dir, dst, err := uploadPathForTask(l.manager.StorageDir(), up.TaskID)
if err != nil {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
dst := filepath.Join(dir, up.TaskID+".bin")
if err := os.WriteFile(dst, plainFile, 0o644); err != nil {
return
}
+40 -13
View File
@@ -1120,29 +1120,56 @@ const hitlAuditAgentPromptBase = `你是 CyberStrikeAI 人机协同审计 Agent
你会收到 JSON包含 hitlModetoolNamearguments/argumentsObjuserMessagethinkingreasoningChainplanning 等字段
规则优先级
- 必须 reject 的规则
- 必须 approve 的规则
- 默认放行规则
规则匹配要求
- 如果同时命中 reject approve必须 reject
- 每次裁决必须给出一个规则编号不得输出命中规则
- 未命中明确 reject approve 规则但未发现明确破坏行为时命中 D1默认放行
- 不得仅凭本地执行设置超时文件名包含 test判断脚本无害必须检查实际 command代码或请求参数
- 如果脚本具体内容不可见应表述为当前参数未显示明确高危操作不能断言无持久化影响
裁决基调默认放行
- 常规低风险的渗透测试操作 approve如信息收集端口/服务扫描目录枚举只读查询无害探测命令
- 与用户授权当前任务目标一致且未见明确高危迹象 approve
- 仅在可能对系统造成实质影响 reject
- 绝大多数渗透测试操作包括各类攻击 payload都应放行
- 在裁决之前需要判断出1此请求的是干什么理解请求内容2此请求造成什么后果
- 仅在明确会直接破坏目标业务系统正常运行或造成真实用户数据丢失/不可逆篡改 reject
- 信息不足参数略宽泛仅为探测意图时 approve
- 拿不准时 approve宁可放过可恢复的测试不要误拦合法攻击
必须 reject 的高危情形示例非穷举
- 删库清表批量删除数据格式化磁盘不可逆破坏
- 修改/重置密码创建或篡改管理员账号持久化后门开机自启
- 向生产环境写入恶意载荷勒索加密停止关键服务修改系统核心配置
- 明显越权与任务/授权目标无关的破坏性操作
- 修改/重置任意用户或管理员密码强制下线真实用户
- 修改服务器或服务配置系统配置Web/数据库参数防火墙启动项计划任务
- 修改/创建/删除用户角色权限账户体系篡改
- 删除或大规模破坏数据/文件DROP/TRUNCATE/DELETE 全表rm 重要文件清空数据库
- 停止禁用重启业务服务systemctl stopkill 关键进程shutdownreboot
- 大规模压测或高并发造成 DoS
不应单独作为 reject 理由的情形
- 常规 nmap/curl/grep/读文件/枚举类命令本身
- 参数略显宽泛但无明确破坏意图审查编辑模式可收窄参数后 approve
- 仅因信息不足若无上述高危迹象 approve 并可在 comment 中提示注意点`
以下情形必须 approve即使看起来攻击性强或包含写操作
- 各类漏洞探测与利用 payloadSQL 注入含读写型命令注入反序列化文件包含路径遍历SSRFSSTIXXE
- 文件上传测试包括上传 webshell写入 webshell 文件
- 命令执行探测反弹 Shelldnslog / 数据外带探测
- 数据库命令执行写文件写计划任务内存马注入等 getshell 利用
- 常规速率的口令爆破 DoS
- 信息收集端口/目录扫描枚举抓取
- 读取查询探测类操作含敏感文件
- 攻陷后无害侦察命令whoami/id/uname/ls/cat
- 参数宽泛但无明确破坏意图的请求
判定原则
- 本身不是拦截理由渗透测试大量依赖写操作注入上传爆破getshell
- 关键看是否破坏业务可用性 / 关键数据完整性
- 只有明确命中上方 reject 清单才 reject`
const hitlAuditAgentPromptApprovalOutput = `
仅输出一行 JSON不要 markdown 代码块
{"decision":"approve"|"reject","comment":"简要理由"}`
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:..."}`
const hitlAuditAgentPromptReviewEditOutput = `
仅输出一行 JSON不要 markdown 代码块
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:...","editedArguments":{...}}
editedArguments 规则 approve 且需要改参时填写否则省略该字段
- 提供完整替换后的工具参数对象键名与 argumentsObj 一致
+31
View File
@@ -0,0 +1,31 @@
package config
import (
"strings"
"testing"
)
func TestDefaultHitlAuditAgentPromptIncludesPrioritizedRules(t *testing.T) {
prompt := DefaultHitlAuditAgentPrompt()
for _, want := range []string{
"如果同时命中 reject 和 approve,必须 reject",
"修改/重置任意用户或管理员密码",
"修改/创建/删除用户、角色、权限",
"停止、禁用、重启业务服务",
"命中规则:...",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("default approval prompt missing %q", want)
}
}
}
func TestDefaultHitlAuditAgentPromptReviewEditKeepsEditedArguments(t *testing.T) {
prompt := DefaultHitlAuditAgentPromptReviewEdit()
if !strings.Contains(prompt, `"editedArguments":{...}`) {
t.Fatal("review-edit prompt must preserve editedArguments output")
}
if !strings.Contains(prompt, "命中规则:...") {
t.Fatal("review-edit prompt must require a matched rule")
}
}
+150 -8
View File
@@ -13,6 +13,7 @@ import (
"unicode/utf8"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/net/idna"
)
@@ -50,6 +51,7 @@ type Asset struct {
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
VulnerabilityCount int `json:"vulnerability_count"`
RiskLevel string `json:"risk_level"`
RiskScore int `json:"-"`
OwnerUserID string `json:"-"`
}
@@ -443,15 +445,15 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
args = append(args, *filter.Port)
}
if filter.RiskLevel != "" {
query += " AND " + assetRiskLevelExpr + " = ?"
query += " AND " + assetRiskLevelCachedExpr + " = ?"
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
}
if filter.MinVulnerabilities != nil {
query += " AND " + assetVulnerabilityCountExpr + " >= ?"
query += " AND " + assetVulnerabilityCountCachedExpr + " >= ?"
args = append(args, *filter.MinVulnerabilities)
}
if filter.MaxVulnerabilities != nil {
query += " AND " + assetVulnerabilityCountExpr + " <= ?"
query += " AND " + assetVulnerabilityCountCachedExpr + " <= ?"
args = append(args, *filter.MaxVulnerabilities)
}
for _, item := range []struct {
@@ -573,20 +575,24 @@ const assetVulnerabilityMatchExpr = `(
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
const assetRiskScoreExpr = `COALESCE((
const assetRiskScoreQueryExpr = `COALESCE((
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
FROM vulnerabilities v
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
),0)`
const assetRiskLevelExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreExpr + `
const assetRiskLevelQueryExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreQueryExpr + `
WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END)`
const assetVulnerabilityCountCachedExpr = `COALESCE(assets.vulnerability_count,0)`
const assetRiskScoreCachedExpr = `COALESCE(assets.risk_score,0)`
const assetRiskLevelCachedExpr = `COALESCE(NULLIF(assets.risk_level,''),'unassessed')`
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country,
assets.province,assets.city,assets.responsible_person,assets.department,assets.business_system,assets.environment,assets.criticality,
assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
` + assetVulnerabilityCountExpr + `,` + assetRiskLevelExpr
` + assetVulnerabilityCountCachedExpr + `,` + assetRiskLevelCachedExpr
// MarkAssetScanned links an asset to the conversation or batch subtask created from it.
// The link lets the asset list show the latest scan time and vulnerabilities produced by that scan.
@@ -601,6 +607,9 @@ func (db *DB) MarkAssetScanned(id, conversationID, queueID, taskID string, acces
if n == 0 {
return sql.ErrNoRows
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
return nil
}
@@ -629,6 +638,9 @@ func (db *DB) CompleteAssetScan(id, conversationID string, access RBACListAccess
if n == 0 {
return sql.ErrNoRows
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
return nil
}
@@ -638,6 +650,136 @@ func (db *DB) BatchTaskBelongsToQueue(taskID, queueID string) bool {
return err == nil && count > 0
}
func assetRiskLevelFromScore(score int, scanned bool) string {
if !scanned {
return "unassessed"
}
switch score {
case 5:
return "critical"
case 4:
return "high"
case 3:
return "medium"
case 2:
return "low"
case 1:
return "info"
default:
return "normal"
}
}
// RefreshAssetRiskCache recalculates the denormalized fields used by the asset
// list. Keeping this in the database layer makes Web API and MCP writes share
// one consistency path.
func (db *DB) RefreshAssetRiskCache(assetID string) error {
assetID = strings.TrimSpace(assetID)
if assetID == "" {
return nil
}
var count int
if err := db.QueryRow("SELECT "+assetVulnerabilityCountExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&count); err != nil {
if err == sql.ErrNoRows {
return nil
}
return fmt.Errorf("刷新资产漏洞数量失败: %w", err)
}
var score int
if err := db.QueryRow("SELECT "+assetRiskScoreQueryExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&score); err != nil {
return fmt.Errorf("刷新资产风险分数失败: %w", err)
}
var lastScan interface{}
if err := db.QueryRow("SELECT "+assetEffectiveLastScanExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&lastScan); err != nil {
return fmt.Errorf("刷新资产扫描状态失败: %w", err)
}
level := assetRiskLevelFromScore(score, lastScan != nil)
if _, err := db.Exec(`UPDATE assets SET vulnerability_count=?, risk_score=?, risk_level=? WHERE id=?`, count, score, level, assetID); err != nil {
return fmt.Errorf("更新资产风险缓存失败: %w", err)
}
return nil
}
func (db *DB) RefreshAllAssetRiskCache() error {
rows, err := db.Query(`SELECT id FROM assets`)
if err != nil {
return fmt.Errorf("查询资产列表失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
}
return rows.Err()
}
func (db *DB) AssetIDsForVulnerabilityConversations(conversationIDs []string) ([]string, error) {
seen := map[string]struct{}{}
cleaned := make([]string, 0, len(conversationIDs))
for _, id := range conversationIDs {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
cleaned = append(cleaned, id)
}
if len(cleaned) == 0 {
return nil, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(cleaned)), ",")
args := make([]interface{}, 0, len(cleaned)*2)
for _, id := range cleaned {
args = append(args, id)
}
for _, id := range cleaned {
args = append(args, id)
}
rows, err := db.Query(`SELECT DISTINCT assets.id FROM assets
WHERE assets.last_scan_conversation_id IN (`+placeholders+`)
OR assets.last_scan_task_id IN (SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id IN (`+placeholders+`))`, args...)
if err != nil {
return nil, fmt.Errorf("查询受影响资产失败: %w", err)
}
defer rows.Close()
assetIDs := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
assetIDs = append(assetIDs, id)
}
return assetIDs, rows.Err()
}
func (db *DB) RefreshAssetRiskCacheForConversations(conversationIDs ...string) error {
assetIDs, err := db.AssetIDsForVulnerabilityConversations(conversationIDs)
if err != nil {
return err
}
for _, id := range assetIDs {
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
}
return nil
}
func (db *DB) refreshAssetRiskCacheForConversationsBestEffort(conversationIDs ...string) {
if err := db.RefreshAssetRiskCacheForConversations(conversationIDs...); err != nil && db.logger != nil {
db.logger.Warn("刷新资产风险缓存失败", zap.Error(err))
}
}
func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
if limit < 1 {
limit = 20
@@ -726,9 +868,9 @@ func assetOrderBy(sortBy, sortOrder string) string {
case "port":
expression = "assets.port"
case "vulnerability_count":
expression = assetVulnerabilityCountExpr
expression = assetVulnerabilityCountCachedExpr
case "risk_level":
expression = assetRiskScoreExpr
expression = assetRiskScoreCachedExpr
default:
expression = "assets.last_seen_at"
}
+6 -1
View File
@@ -383,7 +383,12 @@ func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
t.Fatalf("unexpected scan metadata: %#v", linked)
}
if _, err := db.Exec(`UPDATE vulnerabilities SET status='fixed' WHERE conversation_id=?`, conv.ID); err != nil {
vulns, err := db.ListVulnerabilities(10, 0, VulnerabilityListFilter{ConversationID: conv.ID})
if err != nil || len(vulns) != 1 {
t.Fatalf("list linked vulnerabilities: len=%d err=%v", len(vulns), err)
}
vulns[0].Status = "fixed"
if err := db.UpdateVulnerability(vulns[0].ID, vulns[0]); err != nil {
t.Fatal(err)
}
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
+59 -19
View File
@@ -23,6 +23,7 @@ type Conversation struct {
Title string `json:"title"`
ProjectID string `json:"projectId,omitempty"`
RoleName string `json:"roleName,omitempty"`
AgentMode string `json:"agentMode,omitempty"`
Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
@@ -59,29 +60,30 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
}
}
roleName := normalizeConversationRoleName(meta.RoleName)
agentMode := normalizeConversationAgentMode(meta.AgentMode)
var err error
wsID := strings.TrimSpace(webshellConnectionID)
switch {
case wsID != "" && projectID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, projectID, roleName,
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, projectID, roleName, agentMode,
)
case wsID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, roleName,
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, roleName, agentMode,
)
case projectID != "":
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
id, title, now, now, projectID, roleName,
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
id, title, now, now, projectID, roleName, agentMode,
)
default:
_, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, role_name) VALUES (?, ?, ?, ?, ?)",
id, title, now, now, roleName,
"INSERT INTO conversations (id, title, created_at, updated_at, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?)",
id, title, now, now, roleName, agentMode,
)
}
if err != nil {
@@ -93,6 +95,7 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
Title: title,
ProjectID: projectID,
RoleName: roleName,
AgentMode: agentMode,
CreatedAt: now,
UpdatedAt: now,
}
@@ -240,10 +243,11 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
var projectID sql.NullString
var roleName sql.NullString
var agentMode sql.NullString
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在")
@@ -256,6 +260,9 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
if agentMode.Valid {
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
}
// 尝试多种时间格式解析
var err1, err2 error
@@ -330,10 +337,11 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
var projectID sql.NullString
var roleName sql.NullString
var agentMode sql.NullString
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在")
@@ -346,6 +354,9 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
if agentMode.Valid {
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
}
// 尝试多种时间格式解析
var err1, err2 error
@@ -384,6 +395,17 @@ func normalizeConversationRoleName(roleName string) string {
return roleName
}
func normalizeConversationAgentMode(agentMode string) string {
agentMode = strings.ToLower(strings.TrimSpace(agentMode))
agentMode = strings.ReplaceAll(agentMode, "-", "_")
switch agentMode {
case "deep", "plan_execute", "supervisor":
return agentMode
default:
return "eino_single"
}
}
func (db *DB) SetConversationRoleName(id, roleName string) error {
roleName = normalizeConversationRoleName(roleName)
_, err := db.Exec(
@@ -396,6 +418,18 @@ func (db *DB) SetConversationRoleName(id, roleName string) error {
return nil
}
func (db *DB) SetConversationAgentMode(id, agentMode string) error {
agentMode = normalizeConversationAgentMode(agentMode)
_, err := db.Exec(
"UPDATE conversations SET agent_mode = ? WHERE id = ?",
agentMode, id,
)
if err != nil {
return fmt.Errorf("更新对话模式失败: %w", err)
}
return nil
}
func conversationProjectIDColumn(alias string) string {
if alias != "" {
return alias + ".project_id"
@@ -520,7 +554,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset)
rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
FROM conversations c`+where+`
`+orderClause+`
LIMIT ? OFFSET ?`,
@@ -536,7 +570,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
}
args = append(args, limit, offset)
rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
args...,
)
}
@@ -564,7 +598,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
args = append(args, limit, offset)
rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
FROM conversations c`+where+`
`+orderClause+`
LIMIT ? OFFSET ?`, args...)
@@ -579,7 +613,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
}
args = append(args, limit, offset)
rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
args...)
}
if err != nil {
@@ -597,7 +631,8 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
var pinned int
var projectID sql.NullString
var roleName sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName); err != nil {
var agentMode sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
if projectID.Valid {
@@ -606,6 +641,9 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
if agentMode.Valid {
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
}
var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
@@ -665,7 +703,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset)
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
where+`
`+orderClause+`
LIMIT ? OFFSET ?`,
@@ -689,7 +727,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
args = append(args, limit, offset)
rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
where+`
`+orderClause+`
LIMIT ? OFFSET ?`,
@@ -1386,6 +1424,7 @@ type ProcessDetailsSummary struct {
type ProcessDetailsToolExecution struct {
ProcessDetailID string `json:"processDetailId,omitempty"`
ResultDetailID string `json:"resultDetailId,omitempty"`
ToolName string `json:"toolName,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
ExecutionID string `json:"executionId,omitempty"`
@@ -1514,6 +1553,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
if toolCallID != "" {
lastMatchedToolIndexByCallID[toolCallID] = idx
}
summary.ToolExecutions[idx].ResultDetailID = strings.TrimSpace(detailID)
if summary.ToolExecutions[idx].ToolName == "" {
summary.ToolExecutions[idx].ToolName = toolName
}
@@ -6,6 +6,7 @@ type ConversationCreateMeta struct {
WebShellConnectionID string
ProjectID string
RoleName string
AgentMode string
ClientIP string
SessionHint string
}
+23 -1
View File
@@ -184,6 +184,7 @@ func (db *DB) initTables() error {
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
role_name TEXT NOT NULL DEFAULT '默认',
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
last_react_input TEXT,
last_react_output TEXT
);`
@@ -420,6 +421,7 @@ func (db *DB) initTables() error {
responsible_person TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', business_system TEXT NOT NULL DEFAULT '',
environment TEXT NOT NULL DEFAULT '', criticality TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'manual', source_query TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active',
vulnerability_count INTEGER NOT NULL DEFAULT 0, risk_score INTEGER NOT NULL DEFAULT 0, risk_level TEXT NOT NULL DEFAULT 'unassessed',
tags_json TEXT NOT NULL DEFAULT '[]', first_seen_at DATETIME NOT NULL, last_seen_at DATETIME NOT NULL,
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, owner_user_id TEXT,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
@@ -744,6 +746,9 @@ func (db *DB) initTables() error {
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
CREATE INDEX IF NOT EXISTS idx_assets_vulnerability_count ON assets(vulnerability_count);
CREATE INDEX IF NOT EXISTS idx_assets_risk_score ON assets(risk_score);
CREATE INDEX IF NOT EXISTS idx_assets_risk_level ON assets(risk_level);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
@@ -976,7 +981,6 @@ func (db *DB) initTables() error {
if _, err := db.Exec(createIndexes); err != nil {
return fmt.Errorf("创建索引失败: %w", err)
}
db.logger.Debug("数据库表初始化完成")
return nil
}
@@ -1026,6 +1030,9 @@ func (db *DB) migrateAssetsTable() error {
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
{"environment", "ALTER TABLE assets ADD COLUMN environment TEXT NOT NULL DEFAULT ''"},
{"criticality", "ALTER TABLE assets ADD COLUMN criticality TEXT NOT NULL DEFAULT ''"},
{"vulnerability_count", "ALTER TABLE assets ADD COLUMN vulnerability_count INTEGER NOT NULL DEFAULT 0"},
{"risk_score", "ALTER TABLE assets ADD COLUMN risk_score INTEGER NOT NULL DEFAULT 0"},
{"risk_level", "ALTER TABLE assets ADD COLUMN risk_level TEXT NOT NULL DEFAULT 'unassessed'"},
}
for _, column := range columns {
var count int
@@ -1174,6 +1181,21 @@ func (db *DB) migrateConversationsTable() error {
}
}
// 检查 agent_mode 字段是否存在(对话绑定的执行模式,用于历史任务切换时恢复对话模式)
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='agent_mode'").Scan(&count)
if err != nil {
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
}
}
} else if count == 0 {
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); err != nil {
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
}
}
return nil
}
@@ -22,10 +22,13 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
{"toolName": "http-framework-test", "success": true},
{"toolName": "http-framework-test", "success": true},
}
var resultIDs []string
for _, result := range results {
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", result); err != nil {
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", result)
if err != nil {
t.Fatalf("AddProcessDetail(tool_result): %v", err)
}
resultIDs = append(resultIDs, resultID)
}
summary, err := db.GetProcessDetailsSummary(messageID)
@@ -39,6 +42,9 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
if execution.Status != "completed" {
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
}
if execution.ResultDetailID != resultIDs[i] {
t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i])
}
}
for i, execution := range summary.ToolExecutions[2:4] {
if execution.Status != "result_missing" {
+31
View File
@@ -191,6 +191,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
if err != nil {
return nil, fmt.Errorf("创建漏洞失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(vuln.ConversationID)
return vuln, nil
}
@@ -299,6 +300,8 @@ func (db *DB) CountVulnerabilitiesForAccess(filter VulnerabilityListFilter, acce
// UpdateVulnerability 更新漏洞
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
vuln.UpdatedAt = time.Now()
var oldConversationID string
_ = db.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&oldConversationID)
query := `
UPDATE vulnerabilities
@@ -318,6 +321,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
return fmt.Errorf("更新漏洞失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(oldConversationID, vuln.ConversationID)
return nil
}
@@ -337,6 +341,10 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
args := []interface{}{}
where, args = filter.appendWhere(where, args)
where, args = appendVulnerabilityAccessFilter(where, args, access)
affectedConversations, err := collectVulnerabilityConversationIDs(tx, where, args)
if err != nil {
return 0, err
}
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
@@ -356,6 +364,7 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交事务失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(affectedConversations...)
return deleted, nil
}
@@ -366,6 +375,8 @@ func (db *DB) DeleteVulnerability(id string) error {
return fmt.Errorf("开启事务失败: %w", err)
}
defer func() { _ = tx.Rollback() }()
var conversationID string
_ = tx.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&conversationID)
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
@@ -377,9 +388,29 @@ func (db *DB) DeleteVulnerability(id string) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交事务失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(conversationID)
return nil
}
func collectVulnerabilityConversationIDs(tx *sql.Tx, where string, args []interface{}) ([]string, error) {
rows, err := tx.Query(`SELECT DISTINCT COALESCE(conversation_id,'') FROM vulnerabilities `+where, args...)
if err != nil {
return nil, fmt.Errorf("查询受影响漏洞会话失败: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
if strings.TrimSpace(id) != "" {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
+39 -20
View File
@@ -333,17 +333,24 @@ type ChatReasoningRequest struct {
Effort string `json:"effort,omitempty"`
}
// ChatFinalizationRequest is a caller-provided delivery policy. The server does
// not infer execution intent from natural-language user text.
type ChatFinalizationRequest struct {
RequireExecutionEvidence *bool `json:"requireExecutionEvidence,omitempty"`
}
// ChatRequest 聊天请求
type ChatRequest struct {
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
Hitl *HITLRequest `json:"hitl,omitempty"`
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
Message string `json:"message" binding:"required"`
ConversationID string `json:"conversationId,omitempty"`
ProjectID string `json:"projectId,omitempty"` // 新对话绑定的项目(可选;未指定时可用 config.project.default_project_id
Role string `json:"role,omitempty"` // 角色名称
Attachments []ChatAttachment `json:"attachments,omitempty"`
WebShellConnectionID string `json:"webshellConnectionId,omitempty"` // WebShell 管理 - AI 助手:当前选中的连接 ID,仅使用 webshell_* 工具
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
Hitl *HITLRequest `json:"hitl,omitempty"`
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
Finalization ChatFinalizationRequest `json:"finalization,omitempty"`
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/streamdeep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
Orchestration string `json:"orchestration,omitempty"`
}
@@ -668,10 +675,18 @@ func (h *AgentHandler) mergeAssistantMessagePartialOnCancel(messageID, partial s
// ChatResponse 聊天响应
type ChatResponse struct {
Response string `json:"response"`
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
ConversationID string `json:"conversationId"` // 对话ID
Time time.Time `json:"time"`
Response string `json:"response"`
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
ConversationID string `json:"conversationId"` // 对话ID
Time time.Time `json:"time"`
Finalizable bool `json:"finalizable"`
Finalized bool `json:"finalized"`
Status string `json:"status,omitempty"`
CompletionReason string `json:"completionReason,omitempty"`
EvidenceVerified bool `json:"evidenceVerified"`
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
MissingChecks []string `json:"missingChecks,omitempty"`
}
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
@@ -687,19 +702,20 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
}
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
if assistantMessageID != "" {
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
}
} else {
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
}
if assistantMessageID == "" {
if _, err := h.db.AddMessage(conversationID, "assistant", responseText, resultMA.MCPExecutionIDs); err != nil {
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
}
}
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
}
return resultMA.Response, conversationID, nil
return responseText, conversationID, nil
}
func (h *AgentHandler) runRobotEinoSingleWithRetry(
@@ -830,6 +846,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
robotMode := config.NormalizeAgentMode(agentMode)
if err := h.db.SetConversationAgentMode(conversationID, robotMode); err != nil {
h.logger.Warn("机器人:更新对话模式失败", zap.String("conversationId", conversationID), zap.String("agentMode", robotMode), zap.Error(err))
}
switch robotMode {
case "eino_single":
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
+37 -9
View File
@@ -238,6 +238,11 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
useBatchMulti = true
batchOrch = "deep"
}
if useBatchMulti {
_ = h.db.SetConversationAgentMode(conversationID, batchOrch)
} else {
_ = h.db.SetConversationAgentMode(conversationID, "eino_single")
}
var resultMA *multiagent.RunResult
var runErr error
@@ -268,19 +273,38 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
resText := resultMA.Response
mcpIDs := resultMA.MCPExecutionIDs
lastIn := resultMA.LastAgentTraceInput
lastOut := resultMA.LastAgentTraceOutput
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(lastIn)
agentMode := "batch_eino_single"
if useBatchMulti {
agentMode = "batch_eino_" + batchOrch
}
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
resText := decision.FinalText
if !decision.Finalizable {
resText = finalizationBlockedMessage(decision)
finishStatus = decision.Status
sendEvent("finalization_check", resText, decision)
}
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": agentMode,
"mcpExecutionIds": mcpIDs,
"batchQueueId": queueID,
"batchTaskId": task.ID,
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
"candidatePreview": safeTruncateString(resultMA.Response, 500),
}))
if assistantMessageID != "" {
if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
}
} else if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
if assistantMessageID == "" {
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
} else if !decision.Finalizable {
err = nil
}
if err != nil {
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
}
@@ -290,6 +314,10 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
}
}
if !decision.Finalizable {
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusFailed, resText, finalizationCheckMessage(decision), conversationID)
return
}
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
}
@@ -68,15 +68,15 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
case <-time.After(backoff):
}
inject := multiagent.FormatEmptyResponseContinueUserMessage()
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, "")
if progressCallback != nil {
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"attempt": *attempt,
"maxAttempts": maxAttempts,
"contextSource": "empty_response_continue",
"conversationId": conversationID,
"source": "eino",
"attempt": *attempt,
"maxAttempts": maxAttempts,
"contextSource": "empty_response_continue",
"contextInjection": false,
})
}
return true
+57 -18
View File
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
@@ -189,6 +190,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
var decision agentfinalizer.Decision
for {
segmentMainIterationMax := 0
@@ -258,6 +261,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
timeoutCancel()
break
}
@@ -358,9 +368,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
timeoutCancel()
if assistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
if decision.CompletionReason == "" {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
}
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
@@ -368,12 +379,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
}
}
sendEvent("response", result.Response, map[string]interface{}{
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
sendEvent("finalization_check", responseText, decision)
taskStatus = decision.Status
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
}
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
"mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": "eino_single",
})
}))
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
}
@@ -429,6 +447,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
curMsg := prep.FinalMessage
var result *multiagent.RunResult
var runErr error
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
var decision agentfinalizer.Decision
for {
result, runErr = multiagent.RunEinoSingleChatModelAgent(
taskCtx,
@@ -446,28 +467,46 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
chatReasoningToClientIntent(req.Reasoning),
h.agentSessionContextBlock(prep.ConversationID),
)
if runErr == nil {
break
if runErr != nil {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
return
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
mw := &h.config.MultiAgent.EinoMiddleware
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
return
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
break
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
}
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
}
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
}
c.JSON(http.StatusOK, gin.H{
"response": result.Response,
"conversationId": prep.ConversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "eino_single",
"response": responseText,
"conversationId": prep.ConversationID,
"mcpExecutionIds": result.MCPExecutionIDs,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "eino_single",
"finalized": decision.Finalized,
"finalizable": decision.Finalizable,
"status": decision.Status,
"completionReason": decision.CompletionReason,
"evidenceVerified": decision.EvidenceVerified,
"evidenceRefs": decision.EvidenceRefs,
"pendingExecutionIds": decision.PendingExecutionIDs,
"missingChecks": decision.MissingChecks,
})
}
@@ -0,0 +1,77 @@
package handler
import (
"context"
"time"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
)
const finalizationAutoContinueMaxAttempts = 2
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
if d.Finalizable || d.Finalized {
return false
}
if attempt >= finalizationAutoContinueMaxAttempts {
return false
}
return d.CompletionReason == agentfinalizer.ReasonMissingEvidence
}
func (h *AgentHandler) tryAutoContinueAfterFinalization(
taskCtx context.Context,
conversationID string,
result *multiagent.RunResult,
decision agentfinalizer.Decision,
attempt *int,
curHistory *[]agent.ChatMessage,
curFinalMessage *string,
progressCallback func(eventType, message string, data interface{}),
) bool {
if !shouldAutoContinueAfterFinalization(decision, *attempt) || result == nil || !multiagent.HasEinoResumeTrace(result) {
return false
}
*attempt++
h.persistEinoAgentTraceForResume(conversationID, result)
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
*curHistory = hist
} else if h.logger != nil {
h.logger.Warn("finalization auto-continue could not restore trace",
zap.String("conversationId", conversationID),
zap.Error(err))
return false
}
// Agent 无感续跑:不追加新的 user/system 文案,只使用上一段模型可见轨迹继续 Runner。
*curFinalMessage = ""
if progressCallback != nil {
progressCallback("finalization_auto_continue", "最终回复检查尚未收敛,正在基于已有轨迹继续执行…", map[string]interface{}{
"conversationId": conversationID,
"source": "finalizer",
"attempt": *attempt,
"maxAttempts": finalizationAutoContinueMaxAttempts,
"status": decision.Status,
"completionReason": decision.CompletionReason,
"missingChecks": decision.MissingChecks,
"pendingExecutionIds": decision.PendingExecutionIDs,
"contextInjection": false,
})
}
select {
case <-taskCtx.Done():
return false
case <-time.After(finalizationAutoContinueBackoff(*attempt)):
return true
}
}
func finalizationAutoContinueBackoff(attempt int) time.Duration {
if attempt <= 1 {
return 500 * time.Millisecond
}
return time.Duration(attempt) * time.Second
}
@@ -0,0 +1,59 @@
package handler
import (
"testing"
"cyberstrike-ai/internal/agentfinalizer"
)
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
missingEvidence := agentfinalizer.Decision{
Status: agentfinalizer.StatusBlocked,
CompletionReason: agentfinalizer.ReasonMissingEvidence,
}
if !shouldAutoContinueAfterFinalization(missingEvidence, 0) {
t.Fatal("missing execution evidence should trigger auto-continue")
}
if shouldAutoContinueAfterFinalization(missingEvidence, finalizationAutoContinueMaxAttempts) {
t.Fatal("auto-continue should stop at max attempts")
}
finalized := agentfinalizer.Decision{
Status: agentfinalizer.StatusCompleted,
CompletionReason: agentfinalizer.ReasonVerified,
Finalizable: true,
Finalized: true,
}
if shouldAutoContinueAfterFinalization(finalized, 0) {
t.Fatal("finalized decision should not auto-continue")
}
awaitingHITL := agentfinalizer.Decision{
Status: agentfinalizer.StatusAwaitingHITL,
CompletionReason: agentfinalizer.ReasonAwaitingHITL,
}
if shouldAutoContinueAfterFinalization(awaitingHITL, 0) {
t.Fatal("awaiting HITL should not auto-continue without approval")
}
}
func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
if requestRequiresExecutionEvidence(nil) {
t.Fatal("nil request should not require execution evidence")
}
if requestRequiresExecutionEvidence(&ChatRequest{}) {
t.Fatal("missing finalization policy should not require execution evidence")
}
require := true
if !requestRequiresExecutionEvidence(&ChatRequest{
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
}) {
t.Fatal("explicit true policy should require execution evidence")
}
require = false
if requestRequiresExecutionEvidence(&ChatRequest{
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
}) {
t.Fatal("explicit false policy should not require execution evidence")
}
}
+171
View File
@@ -0,0 +1,171 @@
package handler
import (
"fmt"
"strings"
"time"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/multiagent"
"go.uber.org/zap"
)
func (h *AgentHandler) finalizeAgentRunForDelivery(
conversationID string,
assistantMessageID string,
agentMode string,
result *multiagent.RunResult,
mcpExecutionIDs []string,
reasoningContent string,
) agentfinalizer.Decision {
return h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, mcpExecutionIDs, reasoningContent, false)
}
func (h *AgentHandler) finalizeAgentRunForDeliveryWithPolicy(
conversationID string,
assistantMessageID string,
agentMode string,
result *multiagent.RunResult,
mcpExecutionIDs []string,
reasoningContent string,
requireExecutionEvidence bool,
) agentfinalizer.Decision {
decision := agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
ConversationID: conversationID,
AssistantMessageID: assistantMessageID,
AgentMode: agentMode,
MCPExecutionIDs: mcpExecutionIDs,
RequireExecutionEvidence: requireExecutionEvidence,
})
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpExecutionIDs, reasoningContent, decision)
return decision
}
func (h *AgentHandler) decideAgentRunForDeliveryWithPolicy(
conversationID string,
assistantMessageID string,
agentMode string,
result *multiagent.RunResult,
mcpExecutionIDs []string,
requireExecutionEvidence bool,
) agentfinalizer.Decision {
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
ConversationID: conversationID,
AssistantMessageID: assistantMessageID,
AgentMode: agentMode,
MCPExecutionIDs: mcpExecutionIDs,
RequireExecutionEvidence: requireExecutionEvidence,
})
}
func (h *AgentHandler) decideAgentRunForDelivery(
conversationID string,
assistantMessageID string,
agentMode string,
result *multiagent.RunResult,
mcpExecutionIDs []string,
) agentfinalizer.Decision {
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
ConversationID: conversationID,
AssistantMessageID: assistantMessageID,
AgentMode: agentMode,
MCPExecutionIDs: mcpExecutionIDs,
RequireExecutionEvidence: false,
})
}
func (h *AgentHandler) persistFinalizationDecision(
conversationID string,
assistantMessageID string,
agentMode string,
mcpExecutionIDs []string,
reasoningContent string,
decision agentfinalizer.Decision,
) {
if assistantMessageID == "" || h.db == nil {
return
}
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
if decision.Finalizable {
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
}
return
}
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
}
func (h *AgentHandler) finalizeCandidateForDelivery(
conversationID string,
assistantMessageID string,
agentMode string,
response string,
mcpExecutionIDs []string,
awaitingHITL bool,
reasoningContent string,
) agentfinalizer.Decision {
return h.finalizeCandidateForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, response, mcpExecutionIDs, awaitingHITL, reasoningContent, false)
}
func (h *AgentHandler) finalizeCandidateForDeliveryWithPolicy(
conversationID string,
assistantMessageID string,
agentMode string,
response string,
mcpExecutionIDs []string,
awaitingHITL bool,
reasoningContent string,
requireExecutionEvidence bool,
) agentfinalizer.Decision {
decision := agentfinalizer.Decide(h.db, agentfinalizer.Input{
Response: response,
ConversationID: conversationID,
AssistantMessageID: assistantMessageID,
AgentMode: agentMode,
MCPExecutionIDs: mcpExecutionIDs,
AwaitingHITL: awaitingHITL,
RequireExecutionEvidence: requireExecutionEvidence,
})
if assistantMessageID == "" || h.db == nil {
return decision
}
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
if decision.Finalizable {
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
}
return decision
}
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
return decision
}
func finalizationCheckMessage(d agentfinalizer.Decision) string {
if d.Finalizable {
return "最终回复检查通过。"
}
return finalizationBlockedMessage(d)
}
func finalizationBlockedMessage(d agentfinalizer.Decision) string {
parts := []string{"任务尚未达到最终回复条件,暂不生成成功结论。"}
if d.CompletionReason != "" {
parts = append(parts, "原因: "+d.CompletionReason)
}
if len(d.PendingExecutionIDs) > 0 {
parts = append(parts, fmt.Sprintf("仍有 %d 个工具执行未结束: %s", len(d.PendingExecutionIDs), strings.Join(d.PendingExecutionIDs, ", ")))
}
if len(d.MissingChecks) > 0 {
parts = append(parts, "缺失检查: "+strings.Join(d.MissingChecks, "; "))
}
return strings.Join(parts, "\n")
}
func finalizationResponsePayload(d agentfinalizer.Decision, extra map[string]interface{}) map[string]interface{} {
return agentfinalizer.ResponsePayload(d, extra)
}
func requestRequiresExecutionEvidence(req *ChatRequest) bool {
return req != nil && req.Finalization.RequireExecutionEvidence != nil && *req.Finalization.RequireExecutionEvidence
}
+19 -2
View File
@@ -767,7 +767,7 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
body["include"] = fields
}
var apiResp struct {
Code int `json:"code"`
Code interface{} `json:"code"`
Message string `json:"message"`
TotalCount int `json:"total_count"`
Data []map[string]interface{} `json:"data"`
@@ -780,7 +780,7 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "X-QuakeToken", body, &apiResp, "Quake") {
return
}
if apiResp.Code != 0 {
if !isZeroSpaceSearchCode(apiResp.Code) {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Quake 返回错误"
@@ -801,6 +801,23 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
})
}
func isZeroSpaceSearchCode(code interface{}) bool {
switch v := code.(type) {
case nil:
return false
case int:
return v == 0
case int64:
return v == 0
case float64:
return v == 0
case string:
return strings.TrimSpace(v) == "0"
default:
return false
}
}
func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey string) {
baseURL := strings.TrimRight(h.resolveBaseURL("shodan"), "/") + "/shodan/host/search"
u, err := url.Parse(baseURL)
+40
View File
@@ -155,6 +155,46 @@ func TestShodanSearchReportsShortfallWhenTotalExceedsMatches(t *testing.T) {
}
}
func TestQuakeSearchHandlesStringErrorCode(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("QUAKE_API_KEY", "")
quakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-QuakeToken"); got != "test-quake-key" {
t.Fatalf("Quake token = %q, want test-quake-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"q5000","message":"查询语法错误"}`))
}))
defer quakeServer.Close()
h := NewFofaHandler(&config.Config{
Quake: config.SpaceSearchConfig{
BaseURL: quakeServer.URL,
APIKey: "test-quake-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"provider":"quake","query":"bad query","fields":"ip,port","size":10,"page":1}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("Search() status = %d, want %d, body = %s", recorder.Code, http.StatusBadGateway, recorder.Body.String())
}
bodyText := recorder.Body.String()
if !strings.Contains(bodyText, "查询语法错误") {
t.Fatalf("response should include Quake error message, got %s", bodyText)
}
if strings.Contains(bodyText, "cannot unmarshal") {
t.Fatalf("response exposed JSON type decoding failure: %s", bodyText)
}
}
func TestExtractInfoCollectJSONObject(t *testing.T) {
t.Parallel()
cases := []struct {
+51 -6
View File
@@ -1,8 +1,11 @@
package handler
import (
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
@@ -17,6 +20,11 @@ type GroupHandler struct {
logger *zap.Logger
}
const (
maxGroupNameRunes = 64
maxGroupIconRunes = 16
)
// NewGroupHandler 创建新的分组处理器
func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler {
return &GroupHandler{
@@ -25,6 +33,41 @@ func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler {
}
}
func validateGroupTextField(field, value string, maxRunes int, required bool) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
if required {
return "", errors.New(field + "不能为空")
}
return "", nil
}
if utf8.RuneCountInString(value) > maxRunes {
return "", errors.New(field + "过长")
}
for _, r := range value {
switch r {
case '<', '>', '"', '\'', '`':
return "", errors.New(field + "包含非法字符")
}
if r < 0x20 || r == 0x7f {
return "", errors.New(field + "包含非法控制字符")
}
}
return value, nil
}
func validateGroupFields(name, icon string) (string, string, error) {
validName, err := validateGroupTextField("分组名称", name, maxGroupNameRunes, true)
if err != nil {
return "", "", err
}
validIcon, err := validateGroupTextField("分组图标", icon, maxGroupIconRunes, false)
if err != nil {
return "", "", err
}
return validName, validIcon, nil
}
// CreateGroupRequest 创建分组请求
type CreateGroupRequest struct {
Name string `json:"name"`
@@ -39,13 +82,14 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
return
}
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称不能为空"})
name, icon, err := validateGroupFields(req.Name, req.Icon)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
session, _ := security.CurrentSession(c)
group, err := h.db.CreateGroup(req.Name, req.Icon, session.UserID)
group, err := h.db.CreateGroup(name, icon, session.UserID)
if err != nil {
h.logger.Error("创建分组失败", zap.Error(err))
// 如果是名称重复错误,返回400状态码
@@ -111,12 +155,13 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
return
}
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称不能为空"})
name, icon, err := validateGroupFields(req.Name, req.Icon)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.UpdateGroup(id, req.Name, req.Icon); err != nil {
if err := h.db.UpdateGroup(id, name, icon); err != nil {
h.logger.Error("更新分组失败", zap.Error(err))
// 如果是名称重复错误,返回400状态码
if err.Error() == "分组名称已存在" {
+41
View File
@@ -0,0 +1,41 @@
package handler
import (
"strings"
"testing"
)
func TestValidateGroupFieldsAllowsNormalNamesAndIcons(t *testing.T) {
name, icon, err := validateGroupFields(" 日常安全巡检 ", " 📁 ")
if err != nil {
t.Fatalf("validateGroupFields returned error: %v", err)
}
if name != "日常安全巡检" {
t.Fatalf("name = %q, want trimmed normal name", name)
}
if icon != "📁" {
t.Fatalf("icon = %q, want trimmed icon", icon)
}
}
func TestValidateGroupFieldsRejectsStoredXSSPayloads(t *testing.T) {
tests := []struct {
name string
icon string
}{
{name: `<img src=x onerror="alert(1)">`, icon: "📁"},
{name: "日常安全巡检", icon: `<svg onload=alert(1)>`},
{name: "日常安全巡检`onmouseover=alert(1)", icon: "📁"},
{name: "日常安全巡检\x00", icon: "📁"},
{name: strings.Repeat("分", maxGroupNameRunes+1), icon: "📁"},
{name: "日常安全巡检", icon: strings.Repeat("📁", maxGroupIconRunes+1)},
}
for _, tt := range tests {
t.Run(tt.name+"/"+tt.icon, func(t *testing.T) {
if _, _, err := validateGroupFields(tt.name, tt.icon); err == nil {
t.Fatal("validateGroupFields returned nil error for unsafe input")
}
})
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
h.agentHandler = ah
}
const monitorPageTopTools = 3
const monitorPageTopTools = 6
// MonitorStatsSummary 工具调用汇总
type MonitorStatsSummary struct {
+70 -25
View File
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"cyberstrike-ai/internal/agentfinalizer"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/multiagent"
@@ -197,6 +198,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
var mainIterationOffset int
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
if o := strings.TrimSpace(req.Orchestration); o != "" {
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
}
agentMode := "eino_" + effectiveOrch
var decision agentfinalizer.Decision
for {
segmentMainIterationMax := 0
@@ -267,6 +275,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
mainIterationOffset += segmentMainIterationMax
timeoutCancel()
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
continue
}
timeoutCancel()
break
}
@@ -367,9 +382,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
timeoutCancel()
if assistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
if decision.CompletionReason == "" {
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
}
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
@@ -377,16 +393,19 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
}
}
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
if o := strings.TrimSpace(req.Orchestration); o != "" {
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
sendEvent("finalization_check", responseText, decision)
taskStatus = decision.Status
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
}
sendEvent("response", result.Response, map[string]interface{}{
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
"mcpExecutionIds": cumulativeMCPExecutionIDs,
"conversationId": conversationID,
"messageId": assistantMessageID,
"agentMode": "eino_" + effectiveOrch,
})
"agentMode": agentMode,
}))
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
}
@@ -437,6 +456,14 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
curMsg := prep.FinalMessage
var result *multiagent.RunResult
var runErr error
var emptyResponseContinueAttempt int
var finalizationAutoContinueAttempt int
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
if o := strings.TrimSpace(req.Orchestration); o != "" {
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
}
agentMode := "eino_" + effectiveOrch
var decision agentfinalizer.Decision
for {
result, runErr = multiagent.RunDeepAgent(
taskCtx,
@@ -456,24 +483,30 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
chatReasoningToClientIntent(req.Reasoning),
h.agentSessionContextBlock(prep.ConversationID),
)
if runErr == nil {
break
if runErr != nil {
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
}
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.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})
return
}
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
mw := &h.config.MultiAgent.EinoMiddleware
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
h.logger.Error("Eino DeepAgent 执行失败", zap.Error(runErr))
errMsg := "执行失败: " + runErr.Error()
if prep.AssistantMessageID != "" {
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), prep.AssistantMessageID)
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
continue
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
return
break
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
}
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
@@ -481,11 +514,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
}
}
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
}
c.JSON(http.StatusOK, ChatResponse{
Response: result.Response,
MCPExecutionIDs: result.MCPExecutionIDs,
ConversationID: prep.ConversationID,
Time: time.Now(),
Response: responseText,
MCPExecutionIDs: result.MCPExecutionIDs,
ConversationID: prep.ConversationID,
Time: time.Now(),
Finalizable: decision.Finalizable,
Finalized: decision.Finalized,
Status: decision.Status,
CompletionReason: decision.CompletionReason,
EvidenceVerified: decision.EvidenceVerified,
EvidenceRefs: decision.EvidenceRefs,
PendingExecutionIDs: decision.PendingExecutionIDs,
MissingChecks: decision.MissingChecks,
})
}
+12
View File
@@ -6,6 +6,7 @@ import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/audit"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/security"
@@ -25,6 +26,13 @@ type multiAgentPrepared struct {
UserMessageID string
}
func chatRequestAgentMode(req *ChatRequest, source string) string {
if strings.HasPrefix(strings.TrimSpace(source), "multi_agent") {
return config.NormalizeMultiAgentOrchestration(req.Orchestration)
}
return "eino_single"
}
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
if len(req.Attachments) > maxAttachments {
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
@@ -57,6 +65,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
meta := audit.ConversationCreateMetaFromGin(c, source)
meta.ProjectID = projectID
meta.RoleName = req.Role
meta.AgentMode = chatRequestAgentMode(req, source)
if webshellID != "" {
meta.Source = source + "_webshell"
meta.WebShellConnectionID = webshellID
@@ -84,6 +93,9 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
}
if err := h.db.SetConversationAgentMode(conversationID, chatRequestAgentMode(req, source)); err != nil {
h.logger.Warn("更新对话模式失败", zap.String("conversationId", conversationID), zap.String("source", source), zap.String("orchestration", req.Orchestration), zap.Error(err))
}
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
if err != nil {
+97 -6
View File
@@ -35,6 +35,17 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
scheme = "https"
}
finalizationRequestSchema := map[string]interface{}{
"type": "object",
"description": "最终回复交付策略。后端不会从自然语言内容推断执行意图;执行入口应显式声明是否要求 completed 工具证据。",
"properties": map[string]interface{}{
"requireExecutionEvidence": map[string]interface{}{
"type": "boolean",
"description": "为 true 时,缺少 completed 工具执行记录会触发无注入续跑或最终阻断;普通聊天可省略或设为 false。",
},
},
}
spec := map[string]interface{}{
"openapi": "3.0.0",
"info": map[string]interface{}{
@@ -85,6 +96,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
"required": []string{"projectId"},
},
"AgentChatResponse": map[string]interface{}{
"type": "object",
"description": "Agent 非流式响应。response 只是交付文本;是否为成功最终回复必须以 finalized/finalizable/status 为准。",
"properties": map[string]interface{}{
"response": map[string]interface{}{
"type": "string",
"description": "交付给用户的文本。finalized=false 时为阻断/未完成说明,不是成功结论。",
},
"conversationId": map[string]interface{}{
"type": "string",
"description": "对话 ID",
},
"assistantMessageId": map[string]interface{}{
"type": "string",
"description": "助手消息 ID(部分接口返回)",
},
"mcpExecutionIds": map[string]interface{}{
"type": "array",
"description": "本轮关联的 MCP 工具执行 ID",
"items": map[string]interface{}{"type": "string"},
},
"agentMode": map[string]interface{}{
"type": "string",
"description": "agent 模式,例如 eino_single、eino_deep、workflow",
},
"finalized": map[string]interface{}{
"type": "boolean",
"description": "是否已经通过最终回复检查。只有 true 才能当成功最终回复。",
},
"finalizable": map[string]interface{}{
"type": "boolean",
"description": "候选输出是否可提升为最终回复。",
},
"status": map[string]interface{}{
"type": "string",
"description": "最终化状态",
"enum": []string{"completed", "in_progress", "blocked", "failed", "cancelled", "awaiting_hitl"},
},
"completionReason": map[string]interface{}{
"type": "string",
"description": "最终化或阻断原因,例如 verified、pending_tool_executions、missing_execution_evidence",
},
"evidenceVerified": map[string]interface{}{
"type": "boolean",
"description": "证据是否满足最终化要求",
},
"evidenceRefs": map[string]interface{}{
"type": "array",
"description": "证据引用,例如 mcp_execution:<id>",
"items": map[string]interface{}{"type": "string"},
},
"pendingExecutionIds": map[string]interface{}{
"type": "array",
"description": "仍处于 queued/running 的工具执行 ID",
"items": map[string]interface{}{"type": "string"},
},
"missingChecks": map[string]interface{}{
"type": "array",
"description": "未通过最终化检查的原因列表",
"items": map[string]interface{}{"type": "string"},
},
},
"required": []string{"response", "conversationId", "finalized", "finalizable", "status", "evidenceVerified"},
},
"Conversation": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@@ -1581,6 +1656,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
"finalization": finalizationRequestSchema,
},
"required": []string{"message"},
},
@@ -1588,7 +1664,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{"description": "成功,响应格式同 /api/eino-agent"},
"200": map[string]interface{}{
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
},
},
},
"400": map[string]interface{}{"description": "参数错误"},
"401": map[string]interface{}{"description": "未授权"},
"500": map[string]interface{}{"description": "执行失败"},
@@ -1599,7 +1682,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"post": map[string]interface{}{
"tags": []string{"对话交互"},
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE",
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。**不依赖** `multi_agent.enabled`。",
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。`data.finalized=false` 时 message 为未完成/阻断说明。**不依赖** `multi_agent.enabled`。",
"operationId": "sendMessageEinoSingleAgentStream",
"requestBody": map[string]interface{}{
"required": true,
@@ -1612,6 +1695,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
"finalization": finalizationRequestSchema,
},
"required": []string{"message"},
},
@@ -1625,7 +1709,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"text/event-stream": map[string]interface{}{
"schema": map[string]interface{}{
"type": "string",
"description": "SSE 流",
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
},
},
},
@@ -1663,6 +1747,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"type": "string",
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
},
"finalization": finalizationRequestSchema,
"orchestration": map[string]interface{}{
"type": "string",
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
@@ -1676,7 +1761,12 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "成功,响应格式同 /api/eino-agent",
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
},
},
},
"400": map[string]interface{}{"description": "参数错误"},
"401": map[string]interface{}{"description": "未授权"},
@@ -1689,7 +1779,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"post": map[string]interface{}{
"tags": []string{"对话交互"},
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE",
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。**前提**`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
"operationId": "sendMessageMultiAgentStream",
"requestBody": map[string]interface{}{
"required": true,
@@ -1702,6 +1792,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"conversationId": map[string]interface{}{"type": "string"},
"role": map[string]interface{}{"type": "string"},
"webshellConnectionId": map[string]interface{}{"type": "string"},
"finalization": finalizationRequestSchema,
"orchestration": map[string]interface{}{
"type": "string",
"description": "deep | plan_execute | supervisor;缺省 deep",
@@ -1720,7 +1811,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"text/event-stream": map[string]interface{}{
"schema": map[string]interface{}{
"type": "string",
"description": "SSE 流",
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
},
},
},
+45
View File
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
@@ -47,6 +48,12 @@ type workflowDryRunRequest struct {
Inputs map[string]interface{} `json:"inputs,omitempty"`
}
type workflowGenerateDraftRequest struct {
Prompt string `json:"prompt"`
Options workflowrunner.DraftOptions `json:"options"`
AvailableTools []workflowrunner.DraftTool `json:"available_tools,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)
@@ -126,6 +133,44 @@ func (h *WorkflowHandler) DryRun(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": result})
}
func (h *WorkflowHandler) GenerateDraft(c *gin.Context) {
var req workflowGenerateDraftRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
draftReq := workflowrunner.DraftRequest{
Prompt: req.Prompt,
Options: req.Options,
AvailableTools: req.AvailableTools,
}
var result *workflowrunner.DraftResult
var llmErr error
if h.cfg != nil {
if llmCfg, _, ok := h.cfg.ResolveAIChannel(""); ok && strings.TrimSpace(llmCfg.APIKey) != "" && strings.TrimSpace(llmCfg.Model) != "" {
result, llmErr = workflowrunner.GenerateDraftFromLLM(c.Request.Context(), draftReq, llmCfg, h.logger)
} else {
llmErr = errors.New("AI 通道未配置 api_key 或 model")
}
} else {
llmErr = errors.New("工作流生成器未加载平台 AI 配置")
}
if llmErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "大模型生成失败: " + llmErr.Error()})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "workflow", "generate_draft", "自然语言生成工作流草稿", "", "", map[string]interface{}{
"generator": result.Generator,
"nodes": result.Stats["nodes"],
"edges": result.Stats["edges"],
"high_risk": result.Audit.HighRisk,
"savable": result.Audit.Savable,
})
}
c.JSON(http.StatusOK, gin.H{"result": result})
}
func (h *WorkflowHandler) Update(c *gin.Context) {
h.save(c, c.Param("id"))
}
+51 -14
View File
@@ -152,20 +152,37 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
return true
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
decision := h.finalizeCandidateForDeliveryWithPolicy(
prep.ConversationID,
prep.AssistantMessageID,
"workflow",
result.Response,
nil,
result.AwaitingHITL,
"",
true,
)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
taskStatus = decision.Status
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
sendEvent("finalization_check", responseText, decision)
}
payload := map[string]interface{}{
payload := finalizationResponsePayload(decision, map[string]interface{}{
"conversationId": prep.ConversationID,
"messageId": prep.AssistantMessageID,
"agentMode": "workflow",
"workflowRunId": result.RunID,
}
})
if result.AwaitingHITL {
payload["workflowStatus"] = "awaiting_hitl"
payload["awaitingHitl"] = true
} else {
payload["workflowStatus"] = result.Status
payload["awaitingHitl"] = false
}
sendEvent("response", result.Response, payload)
sendEvent("response", responseText, payload)
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
return true
}
@@ -251,17 +268,37 @@ func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatReque
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
return true
}
if prep.AssistantMessageID != "" {
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
decision := h.finalizeCandidateForDeliveryWithPolicy(
prep.ConversationID,
prep.AssistantMessageID,
"workflow",
result.Response,
nil,
result.AwaitingHITL,
"",
true,
)
responseText := decision.FinalText
if !decision.Finalizable {
responseText = finalizationBlockedMessage(decision)
taskStatus = decision.Status
}
c.JSON(http.StatusOK, gin.H{
"response": result.Response,
"conversationId": prep.ConversationID,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "workflow",
"workflowRunId": result.RunID,
"workflowStatus": result.Status,
"awaitingHitl": result.AwaitingHITL,
"response": responseText,
"conversationId": prep.ConversationID,
"assistantMessageId": prep.AssistantMessageID,
"agentMode": "workflow",
"workflowRunId": result.RunID,
"workflowStatus": result.Status,
"awaitingHitl": result.AwaitingHITL,
"finalized": decision.Finalized,
"finalizable": decision.Finalizable,
"status": decision.Status,
"completionReason": decision.CompletionReason,
"evidenceVerified": decision.EvidenceVerified,
"evidenceRefs": decision.EvidenceRefs,
"pendingExecutionIds": decision.PendingExecutionIDs,
"missingChecks": decision.MissingChecks,
})
return true
}
+24
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
@@ -76,3 +77,26 @@ func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
t.Fatalf("saved=%#v", saved)
}
}
func TestWorkflowHandlerGenerateDraft(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewWorkflowHandler(nil, zap.NewNop())
body := bytes.NewBufferString(`{"prompt":"对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告","options":{"include_objective":true},"available_tools":[{"key":"nmap","name":"nmap","enabled":true}]}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflows/generate-draft", body)
c.Request.Header.Set("Content-Type", "application/json")
h.GenerateDraft(c)
if w.Code != http.StatusBadGateway {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Error string `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !strings.Contains(resp.Error, "大模型生成失败") {
t.Fatalf("unexpected error: %#v", resp.Error)
}
}
@@ -21,6 +21,7 @@ var HitlExemptMetaTools = []string{
"TaskGet",
"TaskUpdate",
"TaskList",
"upsert_project_fact",
}
// IsToolSearchTool reports whether name is the Eino dynamictool tool_search meta-tool.
@@ -45,4 +45,14 @@ func TestMergeHitlExemptMetaTools_includesToolSearch(t *testing.T) {
if !found {
t.Fatalf("tool_search missing from %v", merged)
}
foundProjectFact := false
for _, name := range merged {
if strings.EqualFold(strings.TrimSpace(name), "upsert_project_fact") {
foundProjectFact = true
break
}
}
if !foundProjectFact {
t.Fatalf("upsert_project_fact missing from %v", merged)
}
}
+7
View File
@@ -40,6 +40,13 @@ type RunResult struct {
MCPExecutionIDs []string
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
Finalized bool
Status string
CompletionReason string
EvidenceVerified bool
EvidenceRefs []string
PendingExecutionIDs []string
MissingChecks []string
}
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
+3 -1
View File
@@ -172,6 +172,8 @@ func permissionForRequest(method, fullPath string) string {
return "workflow:read"
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
return "workflow:write"
case path == "/workflows/generate-draft":
return "workflow:write"
case strings.HasPrefix(path, "/workflows"):
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
return "workflow:execute"
@@ -265,7 +267,7 @@ func isProcessGlobalMutationPath(path string) bool {
}
if strings.HasPrefix(path, "/workflows") {
// Workflow runs inherit conversation access; definitions are global.
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run"
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run" && path != "/workflows/generate-draft"
}
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
return true
@@ -157,9 +157,15 @@ func TestWorkflowRunPermissionIsSeparateFromDefinitionManagement(t *testing.T) {
if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" {
t.Fatalf("resume permission = %q, want workflow:execute", got)
}
if got := permissionForRequest(http.MethodPost, "/api/workflows/generate-draft"); got != "workflow:write" {
t.Fatalf("generate draft permission = %q, want workflow:write", got)
}
if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
t.Fatalf("definition permission = %q, want workflow:write", got)
}
if isProcessGlobalMutationPath("/workflows/generate-draft") {
t.Fatalf("generate draft should not be treated as a process-global mutation")
}
}
func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) {
+782
View File
@@ -0,0 +1,782 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"regexp"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/openai"
"go.uber.org/zap"
)
type DraftTool struct {
Key string `json:"key"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
}
type DraftOptions struct {
IncludeObjective bool `json:"include_objective"`
AllowSchedule bool `json:"allow_schedule"`
AllowHighRisk bool `json:"allow_high_risk"`
}
type DraftRequest struct {
Prompt string `json:"prompt"`
Options DraftOptions `json:"options"`
AvailableTools []DraftTool `json:"available_tools,omitempty"`
}
type DraftMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
}
type DraftCapability struct {
Label string `json:"label"`
ToolName string `json:"tool_name,omitempty"`
ToolCandidates []string `json:"tool_candidates,omitempty"`
}
type DraftAudit struct {
Savable bool `json:"savable"`
Validation []string `json:"validation,omitempty"`
MissingFields []string `json:"missing_fields,omitempty"`
RiskWarnings []string `json:"risk_warnings,omitempty"`
Assumptions []string `json:"assumptions,omitempty"`
HighRisk bool `json:"high_risk"`
NeedsHITL bool `json:"needs_hitl"`
}
type DraftResult struct {
Graph *graphDef `json:"graph"`
Meta DraftMeta `json:"meta"`
Generator string `json:"generator"`
Audit DraftAudit `json:"audit"`
Capabilities []DraftCapability `json:"capabilities,omitempty"`
Stats map[string]int `json:"stats"`
}
type llmDraftEnvelope struct {
Graph graphDef `json:"graph"`
Meta DraftMeta `json:"meta"`
Capabilities []DraftCapability `json:"capabilities,omitempty"`
Audit DraftAudit `json:"audit,omitempty"`
}
type draftToolHint struct {
Label string
Keywords []string
Tools []string
}
var draftToolHints = []draftToolHint{
{Label: "子域名发现", Keywords: []string{"子域名", "subdomain", "subfinder", "amass"}, Tools: []string{"subfinder", "amass"}},
{Label: "端口扫描", Keywords: []string{"端口", "port", "nmap", "rustscan", "masscan"}, Tools: []string{"nmap", "rustscan", "masscan"}},
{Label: "漏洞扫描", Keywords: []string{"漏洞", "vuln", "漏洞扫描", "nuclei", "nikto", "zap"}, Tools: []string{"nuclei", "nikto", "zap"}},
{Label: "暴露面探测", Keywords: []string{"目录", "路径", "暴露页面", "dir", "ffuf", "gobuster", "feroxbuster"}, Tools: []string{"ffuf", "gobuster", "feroxbuster", "dirsearch"}},
{Label: "证书与域名线索收集", Keywords: []string{"证书", "certificate", "crt"}, Tools: []string{"subfinder"}},
{Label: "云配置审计", Keywords: []string{"云", "cloud", "配置审计", "prowler", "scout"}, Tools: []string{"prowler", "scout-suite"}},
{Label: "容器安全检查", Keywords: []string{"容器", "镜像", "k8s", "kubernetes", "trivy", "kube"}, Tools: []string{"trivy", "kube-bench", "kube-hunter"}},
{Label: "威胁情报收集", Keywords: []string{"情报", "威胁情报", "threat", "ioc", "virustotal", "shodan", "fofa"}, Tools: []string{"virustotal_search", "shodan_search", "fofa_search"}},
}
var highRiskDraftRE = regexp.MustCompile(`(?i)(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)`)
func GenerateDraftFromNaturalLanguage(ctx context.Context, req DraftRequest) (*DraftResult, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, fmt.Errorf("工作流需求不能为空")
}
capabilities := detectDraftCapabilities(prompt, req.AvailableTools)
wantsApproval := containsAnyFold(prompt, "审批", "确认", "审核", "负责人", "人工", "review", "approve", "approval", "human")
wantsReport := containsAnyFold(prompt, "报告", "汇总", "输出", "通知", "任务", "工单", "report", "summary", "notify", "ticket")
wantsCondition := containsAnyFold(prompt, "如果", "发现", "存在", "高危", "新增", "失败", "通过", "否则", "if", "when", "high", "critical", "new", "fail")
highRisk := highRiskDraftRE.MatchString(prompt)
builder := &draftGraphBuilder{x: 120, y: 150}
assumptions := make([]string, 0)
riskWarnings := make([]string, 0)
missingFields := make([]string, 0)
start := builder.add("start", "开始", map[string]any{"input_keys": "message, conversationId, projectId, target"}, 0)
previous := start
for _, capability := range capabilities {
hasTool := strings.TrimSpace(capability.ToolName) != ""
var id string
if hasTool {
id = builder.add("tool", capability.Label, map[string]any{
"tool_name": capability.ToolName,
"arguments": `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`,
"timeout_seconds": "120",
"join_strategy": "all_merge",
}, 0)
} else {
id = builder.add("agent", capability.Label, map[string]any{
"agent_mode": "eino_single",
"input_binding": map[string]any{"from": "previous", "field": "output"},
"instruction": capability.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt,
"output_key": "agent_result",
"join_strategy": "all_merge",
"missing_tool_candidates": strings.Join(capability.ToolCandidates, ", "),
}, 0)
if len(capability.ToolCandidates) > 0 {
assumptions = append(assumptions, capability.Label+" 未匹配到已启用工具,已生成 Agent 草稿节点。")
missingFields = append(missingFields, capability.Label+": 选择或启用对应 MCP 工具")
}
}
builder.connect(previous, id, "", nil)
previous = id
}
openConditionID := ""
if wantsCondition {
expr := `{{previous.output}} != ""`
label := "是否满足触发条件"
if highRisk {
expr = `{{previous.output}} contains "高危"`
label = "是否需要高风险处置"
}
condition := builder.add("condition", label, map[string]any{"expression": expr, "join_strategy": "all_merge"}, 0)
builder.connect(previous, condition, "", nil)
openConditionID = condition
report := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
"output_key": "result",
"source_binding": map[string]any{"from": "previous", "field": "output"},
"static_value": "",
"join_strategy": "all_merge",
}, 130)
builder.connect(condition, report, "否", map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"})
previous = condition
}
insertedHITL := false
if highRisk {
if !req.Options.AllowHighRisk || wantsApproval {
approval := builder.add("hitl", "人工审批", map[string]any{
"prompt": "请确认是否允许继续执行高风险处置:" + prompt,
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
"reviewer": "human",
"join_strategy": "all_merge",
"risk_level": "high",
}, 0)
builder.connect(previous, approval, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
if previous == openConditionID {
openConditionID = ""
}
previous = approval
insertedHITL = true
}
action := builder.add("agent", "执行受控处置", map[string]any{
"agent_mode": "eino_single",
"input_binding": map[string]any{"from": "previous", "field": "output"},
"instruction": "仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:" + prompt,
"output_key": "remediation_plan",
"join_strategy": "all_merge",
"risk_level": "high",
"requires_human_confirmation": "true",
}, 0)
builder.connect(previous, action, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
if previous == openConditionID {
openConditionID = ""
}
previous = action
if insertedHITL {
riskWarnings = append(riskWarnings, "检测到高风险动作,已加入人工审批与 requires_human_confirmation 标记。")
} else {
riskWarnings = append(riskWarnings, "检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。")
}
} else if wantsApproval {
approval := builder.add("hitl", "人工审批", map[string]any{
"prompt": "请审核工作流阶段结果:" + prompt,
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
"reviewer": "human",
"join_strategy": "all_merge",
}, 0)
builder.connect(previous, approval, "", nil)
previous = approval
insertedHITL = true
}
output := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
"output_key": "result",
"source_binding": map[string]any{"from": "previous", "field": "output"},
"static_value": "",
"join_strategy": "all_merge",
}, 0)
builder.connect(previous, output, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
graph := &graphDef{Nodes: builder.nodes, Edges: builder.edges, Config: map[string]any{
"schema_version": 1,
"generated_by": "natural_language",
"source_prompt": prompt,
}}
if req.Options.IncludeObjective {
graph.Config["objective"] = prompt
}
if req.Options.AllowSchedule && containsAnyFold(prompt, "每天", "每周", "定时", "周期", "持续", "daily", "weekly", "schedule", "monitor") {
if containsAnyFold(prompt, "每天", "daily") {
graph.Config["trigger_suggestion"] = "daily"
} else {
graph.Config["trigger_suggestion"] = "scheduled"
}
assumptions = append(assumptions, "已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。")
}
raw, _ := json.Marshal(graph)
validation := make([]string, 0)
if err := ValidateGraphJSON(ctx, string(raw)); err != nil {
validation = append(validation, err.Error())
}
return &DraftResult{
Graph: graph,
Meta: DraftMeta{ID: draftSlug(prompt), Name: draftName(prompt), Description: prompt, Enabled: true},
Generator: "deterministic",
Audit: DraftAudit{
Savable: len(validation) == 0,
Validation: validation,
MissingFields: missingFields,
RiskWarnings: riskWarnings,
Assumptions: assumptions,
HighRisk: highRisk,
NeedsHITL: insertedHITL,
},
Capabilities: capabilities,
Stats: map[string]int{"nodes": len(graph.Nodes), "edges": len(graph.Edges)},
}, nil
}
func GenerateDraftFromLLM(ctx context.Context, req DraftRequest, oa config.OpenAIConfig, logger *zap.Logger) (*DraftResult, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, fmt.Errorf("工作流需求不能为空")
}
if strings.TrimSpace(oa.APIKey) == "" || strings.TrimSpace(oa.Model) == "" {
return nil, fmt.Errorf("AI 通道未配置 api_key 或 model")
}
if logger == nil {
logger = zap.NewNop()
}
callCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
toolJSON, _ := json.Marshal(req.AvailableTools)
systemPrompt := `你是 CyberStrikeAI 的工作流编排助手你必须把用户的一句话需求转换为可保存的工作流草稿 JSON
只返回 JSON 对象不要 Markdown不要解释JSON 必须符合
{
"meta": {"id":"kebab-case-id","name":"短名称","description":"用户需求","enabled":true},
"graph": {
"nodes": [{"id":"start-1","type":"start","label":"显示名","position":{"x":120,"y":150},"config":{}}],
"edges": [{"id":"edge-1","source":"start-1","target":"node-2","label":"","config":{}}],
"config": {"schema_version":1,"generated_by":"llm","source_prompt":"用户原文"}
},
"capabilities": [{"label":"能力名","tool_name":"已匹配工具名","tool_candidates":["候选工具"]}],
"audit": {"assumptions":[],"missing_fields":[],"risk_warnings":[]}
}
硬性规则
- 只能输出一个合法 JSON object不要输出 JSON Schema注释解释文字Markdown 代码块或多余前后缀
- 不要在 JSON 字符串值中使用竖线枚举写法type 字段一次只能填写一个节点类型字符串
- 至少 1 start 1 outputoutput/end 不能有出边
- 节点 type 只能从这些字符串中选择starttoolagentconditionhitloutputend
- 每个 agenttooloutput 节点都必须配置唯一的 output_keyoutput 节点默认使用 result
- agent 节点必须配置 instruction input_binding默认 input_binding {"from":"previous","field":"output"}
- output 节点必须配置 source_binding static_value默认 source_binding {"from":"previous","field":"output"}
- tool 节点必须配置 tool_nameargumentstimeout_secondsarguments 必须是合法 JSON 字符串
- 所有非 start 且可能有多个上游的节点必须配置 join_strategy:"all_merge"
- condition 最多 2 条出边必须用 branch true/false并用 label /
- tool 节点只有在 available_tools 中存在启用工具时才使用否则用 agent 节点并在 audit.missing_fields 写明缺失工具
- 高风险动作执行脚本隔离封禁删除利用payload命令执行等必须加入 hitl 审批或在高风险节点 config 中标记 requires_human_confirmation:"true"risk_level:"high"
- 不要生成会真实执行攻击的参数工具参数使用 {{inputs.target}}{{inputs.message}} 占位
- 所有节点 config generated_by:"llm" needs_review:"true"`
userPrompt := fmt.Sprintf("用户需求:%s\n\n选项:%+v\n\n可用工具 JSON%s", prompt, req.Options, string(toolJSON))
requestBody := map[string]interface{}{
"model": strings.TrimSpace(oa.Model),
"messages": []map[string]interface{}{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
"temperature": 0,
"max_completion_tokens": 4096,
"response_format": map[string]interface{}{"type": "json_object"},
"thinking": map[string]interface{}{"type": "disabled"},
}
var apiResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
}
client := openai.NewClient(&oa, nil, logger)
if err := client.ChatCompletion(callCtx, requestBody, &apiResponse); err != nil {
return nil, fmt.Errorf("调用大模型失败: %w", err)
}
if len(apiResponse.Choices) == 0 {
return nil, fmt.Errorf("大模型未返回候选结果")
}
raw := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
if raw == "" {
raw = strings.TrimSpace(apiResponse.Choices[0].Message.ReasoningContent)
}
env, err := parseLLMDraftEnvelope(raw)
if err != nil {
return nil, err
}
result := normalizeLLMDraft(prompt, req, env)
graphRaw, _ := json.Marshal(result.Graph)
validation := make([]string, 0)
if err := ValidateGraphJSON(ctx, string(graphRaw)); err != nil {
validation = append(validation, err.Error())
}
result.Audit.Validation = validation
result.Audit.Savable = len(validation) == 0
if !result.Audit.Savable {
return nil, fmt.Errorf("大模型生成的工作流未通过校验: %s", strings.Join(validation, ""))
}
return result, nil
}
type draftGraphBuilder struct {
nodes []graphNode
edges []graphEdge
x float64
y float64
nodeSeq int
edgeSeq int
}
func (b *draftGraphBuilder) add(nodeType, label string, config map[string]any, yOffset float64) string {
b.nodeSeq++
id := fmt.Sprintf("%s-%d", nodeType, b.nodeSeq)
if config == nil {
config = make(map[string]any)
}
config["generated_by"] = "natural_language"
config["needs_review"] = "true"
b.nodes = append(b.nodes, graphNode{
ID: id,
Type: nodeType,
Label: label,
Position: graphPosition{X: b.x, Y: b.y + yOffset},
Config: config,
})
b.x += 210
return id
}
func (b *draftGraphBuilder) connect(source, target, label string, config map[string]any) {
b.edgeSeq++
if config == nil {
config = make(map[string]any)
}
b.edges = append(b.edges, graphEdge{ID: fmt.Sprintf("edge-ai-%d", b.edgeSeq), Source: source, Target: target, Label: label, Config: config})
}
func parseLLMDraftEnvelope(raw string) (llmDraftEnvelope, error) {
var lastErr error
for _, candidate := range jsonObjectCandidates(raw) {
var env llmDraftEnvelope
if err := json.Unmarshal([]byte(candidate), &env); err == nil {
if len(env.Graph.Nodes) == 0 {
lastErr = fmt.Errorf("大模型 JSON 缺少 graph.nodes")
continue
}
return env, nil
} else {
lastErr = err
}
}
if lastErr == nil {
lastErr = fmt.Errorf("大模型响应为空")
}
return llmDraftEnvelope{}, fmt.Errorf("解析大模型工作流 JSON 失败: %w", lastErr)
}
func jsonObjectCandidates(raw string) []string {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
candidates := []string{s}
if start := strings.Index(s, "{"); start >= 0 {
if end := strings.LastIndex(s, "}"); end > start {
candidates = append(candidates, s[start:end+1])
}
}
return candidates
}
func normalizeLLMDraft(prompt string, req DraftRequest, env llmDraftEnvelope) *DraftResult {
g := env.Graph
if g.Config == nil {
g.Config = make(map[string]any)
}
g.Config["schema_version"] = 1
g.Config["generated_by"] = "llm"
g.Config["source_prompt"] = prompt
if req.Options.IncludeObjective {
g.Config["objective"] = prompt
}
enabledTools := enabledDraftToolNames(req.AvailableTools)
usedOutputKeys := make(map[string]bool)
nodeTypes := make(map[string]string, len(g.Nodes))
for i := range g.Nodes {
if strings.TrimSpace(g.Nodes[i].ID) == "" {
g.Nodes[i].ID = fmt.Sprintf("%s-%d", firstNonEmpty(g.Nodes[i].Type, "node"), i+1)
}
if strings.TrimSpace(g.Nodes[i].Type) == "" {
g.Nodes[i].Type = "agent"
}
if strings.TrimSpace(g.Nodes[i].Label) == "" {
g.Nodes[i].Label = displayNodeType(g.Nodes[i].Type)
}
if g.Nodes[i].Position.X == 0 && g.Nodes[i].Position.Y == 0 {
g.Nodes[i].Position = graphPosition{X: 120 + float64(i)*210, Y: 150}
}
if g.Nodes[i].Config == nil {
g.Nodes[i].Config = make(map[string]any)
}
g.Nodes[i].Config["generated_by"] = "llm"
g.Nodes[i].Config["needs_review"] = "true"
normalizeLLMNodeConfig(prompt, &g.Nodes[i], enabledTools, usedOutputKeys)
nodeTypes[g.Nodes[i].ID] = strings.ToLower(strings.TrimSpace(g.Nodes[i].Type))
}
conditionBranchCounts := make(map[string]int)
for i := range g.Edges {
if strings.TrimSpace(g.Edges[i].ID) == "" {
g.Edges[i].ID = fmt.Sprintf("edge-llm-%d", i+1)
}
if g.Edges[i].Config == nil {
g.Edges[i].Config = make(map[string]any)
}
normalizeLLMEdgeConfig(&g.Edges[i], nodeTypes, conditionBranchCounts)
}
audit := env.Audit
highRisk := highRiskDraftRE.MatchString(prompt) || graphHasHighRisk(g)
audit.HighRisk = highRisk
audit.NeedsHITL = graphHasNodeType(g, "hitl")
if highRisk && !audit.NeedsHITL && !graphHasConfirmation(g) {
audit.RiskWarnings = append(audit.RiskWarnings, "大模型生成包含高风险语义,请补充人工审批或确认标记后再运行。")
}
if len(audit.RiskWarnings) == 0 && highRisk {
audit.RiskWarnings = append(audit.RiskWarnings, "检测到高风险动作,已标记为需要重点审计。")
}
meta := env.Meta
if strings.TrimSpace(meta.Description) == "" {
meta.Description = prompt
}
if strings.TrimSpace(meta.Name) == "" {
meta.Name = draftName(prompt)
}
if strings.TrimSpace(meta.ID) == "" {
meta.ID = draftSlug(prompt)
}
meta.Enabled = true
return &DraftResult{
Graph: &g,
Meta: meta,
Generator: "llm",
Audit: audit,
Capabilities: env.Capabilities,
Stats: map[string]int{"nodes": len(g.Nodes), "edges": len(g.Edges)},
}
}
func normalizeLLMEdgeConfig(edge *graphEdge, nodeTypes map[string]string, conditionBranchCounts map[string]int) {
if nodeTypes[strings.TrimSpace(edge.Source)] != "condition" {
return
}
if conditionBranchHint(*edge) != "" {
return
}
conditionBranchCounts[edge.Source]++
branch := "true"
label := "是"
if conditionBranchCounts[edge.Source] > 1 {
branch = "false"
label = "否"
}
edge.Label = label
edge.Config["branch"] = branch
}
func normalizeLLMNodeConfig(prompt string, node *graphNode, enabledTools map[string]bool, usedOutputKeys map[string]bool) {
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
switch nodeType {
case "start":
if cfgString(node.Config, "input_keys") == "" {
node.Config["input_keys"] = "message, conversationId, projectId, target"
}
case "tool":
toolName := cfgString(node.Config, "tool_name")
if toolName == "" || !enabledTools[strings.ToLower(toolName)] {
node.Type = "agent"
node.Config["missing_tool_name"] = toolName
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
return
}
if cfgString(node.Config, "arguments") == "" {
node.Config["arguments"] = `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`
}
if cfgString(node.Config, "timeout_seconds") == "" {
node.Config["timeout_seconds"] = "120"
}
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "tool_result"))
ensureJoinStrategy(node)
case "agent":
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
case "condition":
if cfgString(node.Config, "expression") == "" {
node.Config["expression"] = `{{previous.output}} != ""`
}
ensureJoinStrategy(node)
case "hitl":
if cfgString(node.Config, "prompt") == "" {
node.Config["prompt"] = "请审核工作流阶段结果:" + prompt
}
if cfgString(node.Config, "reviewer") == "" {
node.Config["reviewer"] = "human"
}
ensureJoinStrategy(node)
case "output":
ensureNodeOutputKey(node, usedOutputKeys, "result")
if cfgString(node.Config, "static_value") == "" {
if _, ok := parseFieldBinding(node.Config, "source_binding"); !ok {
node.Config["source_binding"] = map[string]any{"from": "previous", "field": "output"}
}
}
ensureJoinStrategy(node)
case "end":
ensureJoinStrategy(node)
}
}
func normalizeAgentDraftConfig(prompt string, node *graphNode, usedOutputKeys map[string]bool) {
if cfgString(node.Config, "agent_mode") == "" {
node.Config["agent_mode"] = "eino_single"
}
if cfgString(node.Config, "instruction") == "" {
node.Config["instruction"] = node.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt
}
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
node.Config["input_binding"] = map[string]any{"from": "previous", "field": "output"}
}
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "agent_result"))
ensureJoinStrategy(node)
}
func ensureJoinStrategy(node *graphNode) {
if cfgString(node.Config, "join_strategy") == "" {
node.Config["join_strategy"] = "all_merge"
}
}
func ensureNodeOutputKey(node *graphNode, used map[string]bool, fallback string) {
current := sanitizeOutputKey(cfgString(node.Config, "output_key"))
if current == "" {
current = sanitizeOutputKey(fallback)
}
if current == "" {
current = "result"
}
base := current
for i := 2; used[current]; i++ {
current = fmt.Sprintf("%s_%d", base, i)
}
node.Config["output_key"] = current
used[current] = true
}
func draftOutputKeyBase(node *graphNode, fallback string) string {
if name := cfgString(node.Config, "tool_name"); name != "" {
return name + "_result"
}
if node.ID != "" {
return node.ID + "_result"
}
return fallback
}
func sanitizeOutputKey(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastUnderscore := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastUnderscore = false
continue
}
if b.Len() > 0 && !lastUnderscore {
b.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}
func enabledDraftToolNames(tools []DraftTool) map[string]bool {
names := make(map[string]bool, len(tools)*2)
for _, tool := range tools {
if !tool.Enabled {
continue
}
if key := strings.ToLower(strings.TrimSpace(tool.Key)); key != "" {
names[key] = true
}
if name := strings.ToLower(strings.TrimSpace(tool.Name)); name != "" {
names[name] = true
}
}
return names
}
func graphHasNodeType(g graphDef, nodeType string) bool {
for _, node := range g.Nodes {
if strings.EqualFold(node.Type, nodeType) {
return true
}
}
return false
}
func graphHasConfirmation(g graphDef) bool {
for _, node := range g.Nodes {
if cfgString(node.Config, "requires_human_confirmation") == "true" {
return true
}
}
return false
}
func graphHasHighRisk(g graphDef) bool {
for _, node := range g.Nodes {
if cfgString(node.Config, "risk_level") == "high" || cfgString(node.Config, "requires_human_confirmation") == "true" {
return true
}
if highRiskDraftRE.MatchString(node.Label) || highRiskDraftRE.MatchString(cfgString(node.Config, "instruction")) {
return true
}
}
return false
}
func detectDraftCapabilities(prompt string, tools []DraftTool) []DraftCapability {
capabilities := make([]DraftCapability, 0)
for _, hint := range draftToolHints {
if containsAnyFold(prompt, hint.Keywords...) {
capabilities = append(capabilities, DraftCapability{
Label: hint.Label,
ToolName: matchDraftTool(hint.Tools, tools),
ToolCandidates: append([]string(nil), hint.Tools...),
})
}
}
if len(capabilities) == 0 {
capabilities = append(capabilities, DraftCapability{Label: "节点能力", ToolCandidates: nil})
}
return capabilities
}
func matchDraftTool(candidates []string, tools []DraftTool) string {
if len(candidates) == 0 || len(tools) == 0 {
return ""
}
for _, enabledOnly := range []bool{true, false} {
for _, candidate := range candidates {
candidate = strings.ToLower(strings.TrimSpace(candidate))
for _, tool := range tools {
if enabledOnly && !tool.Enabled {
continue
}
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(tool.Key, tool.Name)))
if key != "" && strings.Contains(key, candidate) {
return firstNonEmpty(tool.Key, tool.Name)
}
}
}
}
return ""
}
func containsAnyFold(text string, needles ...string) bool {
lower := strings.ToLower(text)
for _, needle := range needles {
if strings.Contains(lower, strings.ToLower(needle)) {
return true
}
}
return false
}
func draftOutputLabel(wantsReport bool) string {
if wantsReport {
return "输出报告"
}
return "输出"
}
func branchLabel(source, conditionID string) string {
if source == conditionID && conditionID != "" {
return "是"
}
return ""
}
func branchConfig(source, conditionID string, yes bool) map[string]any {
if source != conditionID || conditionID == "" {
return nil
}
if yes {
return map[string]any{"condition": `{{previous.matched}} == "true"`, "branch": "true"}
}
return map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"}
}
func draftName(prompt string) string {
runes := []rune(strings.TrimSpace(prompt))
if len(runes) > 22 {
return string(runes[:22]) + "..."
}
return string(runes)
}
func draftSlug(prompt string) string {
lower := strings.ToLower(strings.TrimSpace(prompt))
var b strings.Builder
lastDash := false
for _, r := range lower {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
slug := strings.Trim(b.String(), "-")
if slug != "" {
if len(slug) > 48 {
return strings.Trim(slug[:48], "-")
}
return slug
}
h := fnv.New32a()
_, _ = h.Write([]byte(lower))
if !utf8.ValidString(lower) || lower == "" {
lower = "workflow"
}
return fmt.Sprintf("ai-workflow-%x", h.Sum32())
}
+213
View File
@@ -0,0 +1,213 @@
package workflow
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cyberstrike-ai/internal/config"
)
func TestGenerateDraftFromNaturalLanguageHighRiskAddsHITLAndValidGraph(t *testing.T) {
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
Prompt: "对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告",
Options: DraftOptions{
IncludeObjective: true,
AllowSchedule: false,
AllowHighRisk: false,
},
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
})
if err != nil {
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
}
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
}
if !result.Audit.HighRisk || !result.Audit.NeedsHITL || len(result.Audit.RiskWarnings) == 0 {
t.Fatalf("audit did not flag high-risk HITL path: %#v", result.Audit)
}
var hasTool, hasHITL, hasConfirmation bool
for _, node := range result.Graph.Nodes {
if node.Type == "tool" && cfgString(node.Config, "tool_name") == "nmap" {
hasTool = true
}
if node.Type == "hitl" {
hasHITL = true
}
if cfgString(node.Config, "requires_human_confirmation") == "true" {
hasConfirmation = true
}
}
if !hasTool || !hasHITL || !hasConfirmation {
t.Fatalf("expected nmap tool, HITL, and confirmation marker; tool=%v hitl=%v confirmation=%v", hasTool, hasHITL, hasConfirmation)
}
}
func TestGenerateDraftAllowHighRiskStillLabelsConditionBranch(t *testing.T) {
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
Prompt: "如果漏洞扫描发现高危漏洞,允许生成执行修复脚本的草稿并输出报告",
Options: DraftOptions{
AllowHighRisk: true,
},
AvailableTools: []DraftTool{{Key: "nuclei", Name: "nuclei", Enabled: true}},
})
if err != nil {
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
}
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
}
branches := map[string]bool{}
for _, edge := range result.Graph.Edges {
if branch := cfgString(edge.Config, "branch"); branch != "" {
branches[branch] = true
}
}
if !branches["true"] || !branches["false"] {
t.Fatalf("condition branches = %#v, want true and false", branches)
}
}
func TestGenerateDraftFromLLMUsesOpenAICompatibleEndpoint(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.URL.Path != "/chat/completions" {
t.Fatalf("path = %s, want /chat/completions", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("authorization = %q", got)
}
var payload struct {
Temperature float64 `json:"temperature"`
ResponseFormat struct {
Type string `json:"type"`
} `json:"response_format"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload.Temperature != 0 || payload.ResponseFormat.Type != "json_object" {
t.Fatalf("unexpected structured output controls: temperature=%v response_format=%#v", payload.Temperature, payload.ResponseFormat)
}
if len(payload.Messages) == 0 || strings.Contains(payload.Messages[0].Content, "start|tool|agent") {
t.Fatalf("system prompt still contains pipe enum: %q", payload.Messages[0].Content)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"llm-port-scan\",\"name\":\"端口扫描\",\"description\":\"端口扫描\",\"enabled\":true},\"graph\":{\"nodes\":[{\"id\":\"start-1\",\"type\":\"start\",\"label\":\"开始\",\"position\":{\"x\":120,\"y\":150},\"config\":{\"input_keys\":\"message, target\"}},{\"id\":\"tool-2\",\"type\":\"tool\",\"label\":\"端口扫描\",\"position\":{\"x\":330,\"y\":150},\"config\":{\"tool_name\":\"nmap\",\"arguments\":\"{\\\"target\\\":\\\"{{inputs.target}}\\\"}\",\"timeout_seconds\":\"120\",\"join_strategy\":\"all_merge\"}},{\"id\":\"output-3\",\"type\":\"output\",\"label\":\"输出报告\",\"position\":{\"x\":540,\"y\":150},\"config\":{\"source_binding\":{\"from\":\"previous\",\"field\":\"output\"},\"join_strategy\":\"all_merge\"}}],\"edges\":[{\"id\":\"edge-1\",\"source\":\"start-1\",\"target\":\"tool-2\"},{\"id\":\"edge-2\",\"source\":\"tool-2\",\"target\":\"output-3\"}],\"config\":{\"schema_version\":1}},\"capabilities\":[{\"label\":\"端口扫描\",\"tool_name\":\"nmap\",\"tool_candidates\":[\"nmap\"]}],\"audit\":{\"assumptions\":[]}}"}}]}`))
}))
defer srv.Close()
result, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
Prompt: "对目标做端口扫描并输出报告",
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
if err != nil {
t.Fatalf("GenerateDraftFromLLM: %v", err)
}
if !called {
t.Fatal("expected LLM endpoint to be called")
}
if result.Generator != "llm" || !result.Audit.Savable || result.Meta.ID != "llm-port-scan" {
t.Fatalf("unexpected result: %#v", result)
}
for _, node := range result.Graph.Nodes {
if node.Type == "output" && cfgString(node.Config, "output_key") != "result" {
t.Fatalf("output_key = %q, want result", cfgString(node.Config, "output_key"))
}
}
}
func TestGenerateDraftFromLLMReturnsErrorOnMalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"bad\"},|\"graph\":{\"nodes\":[]}}"}}]}`))
}))
defer srv.Close()
_, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
Prompt: "随便生成一个工作流,要求所有节点都用到输出变量",
Options: DraftOptions{
IncludeObjective: true,
},
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
if err == nil {
t.Fatal("expected malformed JSON error")
}
if !strings.Contains(err.Error(), "解析大模型工作流 JSON 失败") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNormalizeLLMDraftRepairsMissingRequiredConfig(t *testing.T) {
result := normalizeLLMDraft("随便生成一个工作流", DraftRequest{}, llmDraftEnvelope{
Graph: graphDef{
Nodes: []graphNode{
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
{ID: "agent-1", Type: "agent", Label: "分析", Config: map[string]any{}},
{ID: "out-1", Type: "output", Label: "输出结果", Config: map[string]any{}},
},
Edges: []graphEdge{
{ID: "e1", Source: "start-1", Target: "agent-1"},
{ID: "e2", Source: "agent-1", Target: "out-1"},
},
},
})
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
}
var agentKey, outputKey string
for _, node := range result.Graph.Nodes {
switch node.Type {
case "agent":
agentKey = cfgString(node.Config, "output_key")
case "output":
outputKey = cfgString(node.Config, "output_key")
}
}
if agentKey == "" || outputKey != "result" {
t.Fatalf("agentKey=%q outputKey=%q", agentKey, outputKey)
}
}
func TestNormalizeLLMDraftRepairsConditionBranches(t *testing.T) {
result := normalizeLLMDraft("如果发现异常则输出详情,否则输出正常", DraftRequest{}, llmDraftEnvelope{
Graph: graphDef{
Nodes: []graphNode{
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
{ID: "cond-1", Type: "condition", Label: "判断", Config: map[string]any{"expression": `{{inputs.message}} != ""`}},
{ID: "out-yes", Type: "output", Label: "异常", Config: map[string]any{}},
{ID: "out-no", Type: "output", Label: "正常", Config: map[string]any{}},
},
Edges: []graphEdge{
{ID: "e1", Source: "start-1", Target: "cond-1"},
{ID: "e2", Source: "cond-1", Target: "out-yes"},
{ID: "e3", Source: "cond-1", Target: "out-no"},
},
},
})
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
}
branches := map[string]bool{}
for _, edge := range result.Graph.Edges {
if edge.Source == "cond-1" {
branches[cfgString(edge.Config, "branch")] = true
}
}
if !branches["true"] || !branches["false"] {
t.Fatalf("branches = %#v, want true and false", branches)
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
name: active-directory-attack
description: >-
内网域攻击:BloodHound,Kerberoast,ADCS ESC1/ESC8,NTLM Relay,Coerce,DACL,DCSync,Zerologon/NoPac/PrintNightmare,mitm6,LLMNR,Linux内网。Use when attacking Active Directory, ADCS, NTLM relay, or internal domain.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 内网域攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: ai-llm-app-attack
description: >-
AI/LLM应用攻击:提示注入,Agent工具滥用RCE,RAG投毒,MCP供应链,torch.load pickle RCE。Use when testing LLM apps, agents, RAG, MCP plugins, or AI model file risks.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## AI / LLM 应用攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: attack-surface-recon
description: >-
侦察/攻击面测绘:被动whois/amass/crt.sh/FOFA/Shodan,主动subfinder/httpx/naabu/katana/nuclei,DNS地域/CDN/Nginx catch-all/宝塔/UniApp指纹。开局第一动作,认知写入项目黑板。Use when starting recon, asset mapping, fingerprinting, or CDN/DNS bypass discovery.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 侦察 / 攻击面测绘
+2 -1
View File
@@ -2,7 +2,8 @@
name: binary-mobile-reversing
description: >-
APK/EXE/二进制:UniApp/DCloud/Flutter逆向,证书固定绕过,导出组件,内存破坏exploit链,IoT固件。Use when reversing APK/EXE, UniApp/Flutter, native .so, or memory-corruption exploits.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## APK / EXE / 二进制逆向
+2 -1
View File
@@ -2,7 +2,8 @@
name: blockchain-contract-attack
description: >-
区块链/智能合约:Etherscan,slither/mythril,重入/访问控制/预言机/闪电贷,跨链桥,RPC暴露。Use when auditing smart contracts, DeFi, or blockchain attack surfaces.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 区块链 / 智能合约
+2 -1
View File
@@ -2,7 +2,8 @@
name: capability-primitive-search
description: >-
能力原语+状态空间搜索:read/write/exec/ssrf等原语凑RCE等式A-F,低危映射,正反向搜索,跨域兑现。Use when no single RCE, chaining low-severity vulns, or deriving novel attack chains.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 能力原语 + 状态空间搜索
+2 -1
View File
@@ -2,7 +2,8 @@
name: cloud-attack-methods
description: >-
云攻击:元数据API,S3/K8s,AWS/Azure/GCP身份提权,MinIO矩阵,阿里云FC,ChengZi SDK解密。Use when attacking cloud metadata, IAM, K8s, MinIO, Aliyun FC, or cloud post-ex.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 云攻击手法
+2 -1
View File
@@ -2,7 +2,8 @@
name: component-vuln-intel
description: >-
联网情报收集:识别组件后必做CVE/搜索引擎/中文社区/GitHub PoC/资产引擎/即时情报/依赖扩展+受阻换路。Use when a framework/component/version is identified and must search before exploit.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 联网情报收集(识别组件→立即全网搜;结果=线索/tentative,验证前不是 confirmed Fact
+2 -1
View File
@@ -2,7 +2,8 @@
name: initial-access-phishing
description: >-
初始访问/钓鱼/社工:凭据喷洒,AiTM,设备码,OAuth同意钓鱼,载荷,vishing。Use when needing initial access, phishing, AiTM, device code, or social engineering.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 初始访问 / 钓鱼 / 社工
+2 -1
View File
@@ -6,7 +6,8 @@ description: >-
(联网情报/Web/认证/服务端/源码/社工/后渗透/二进制/内网域/云/区块链/AI/无线/硬件)+0day+
组合拳+代理自举。核心:全网搜不到洞时现场推导独属于目标的攻击链。本文件为套件索引。
Use when starting a full-chain pentest engagement or needing the skill map for this suite.
tags: [渗透测试, penetration-testing, 红队, autonomous]
metadata:
tags: [渗透测试, penetration-testing, 红队, autonomous]
---
# 渗透测试Agent操作系统
+2 -1
View File
@@ -4,7 +4,8 @@ description: >-
CyberStrikeAI 项目黑板:跨会话 Fact 图(SQLite+ upsert_project_fact/record_vulnerability
边渗透边记录节奏、关系边 links、confidence、与多代理协调落库。Use when managing project
facts, blackboard index, writing evidence, or avoiding context-loss after compression.
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
metadata:
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
---
# 项目黑板(与本产品对齐)
+2 -1
View File
@@ -3,7 +3,8 @@ name: pentest-output-standards
description: >-
输出规范:中文分析,思维链,漏洞报告模板,负结果,黑板状态总览,改动台账,死锁突破。
Use when reporting findings, maintaining change ledger, or formatting pentest output.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 输出规范
+2 -1
View File
@@ -3,7 +3,8 @@ name: pentest-verification
description: >-
验证铁律:搜索≠漏洞,confirmed Fact须证据,tentative表线索,禁止空泛推测,负结果也落库,
想象力拉满+单步验证零容忍。Use when writing project facts, validating findings, or avoiding hallucination.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 验证铁律(全系统最高规则,违反即幻觉)
+2 -1
View File
@@ -2,7 +2,8 @@
name: post-exploitation
description: >-
后渗透/提权+凭据破解+密码学:反弹shell,Linux/Windows提权,横向,隧道,免杀,C2持久化,hashcat/Padding Oracle/hash长度扩展。Use when post-exploitation, privilege escalation, lateral movement, or cracking crypto.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 后渗透 / 提权 / 凭据破解 / 密码学
+2 -1
View File
@@ -2,7 +2,8 @@
name: proxy-tool-bootstrap
description: >-
自找代理+工具自举:SOCKS5/HTTP/Tor换路序列,工具Python自举,字典自生成,OOB基础设施。Use when blocked by 403/429/WAF/timeout, missing tools, or needing OOB confirmation.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 自找代理 + 工具自举(被拦换路,没工具自己写)
+2 -1
View File
@@ -2,7 +2,8 @@
name: redteam-opsec
description: >-
OPSEC隐蔽作战纪律:IP黑名单绕过,速率时序,流量混淆,最小足迹,反取证,渐进暴露。Use when maintaining stealth, bypassing IP bans, or planning covert red-team ops.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## OPSEC / 隐蔽作战纪律(免杀 / 稳定 / 隐蔽)
+2 -1
View File
@@ -2,7 +2,8 @@
name: source-code-hunting
description: >-
源码狩猎:.git泄露,危险函数grep,JS RC4解混淆,semgrep/CodeQL,trufflehog,patch diff,供应链/CI。Use when hunting source leaks, secrets, JS deobfuscation, or supply-chain issues.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 源码狩猎
+2 -1
View File
@@ -2,7 +2,8 @@
name: specialized-attack-playbooks
description: >-
专题实战利用:GoEdge私钥导出,灰产CDN取证,ARP MITM,CDN→S3 STS链,宝塔+UniApp,AI IDE API反代,OCS+MinIO;含references/scripts支持文件索引。Use when applying specialized playbooks for GoEdge, CDN, ARP MITM, BT Panel, OCS, MinIO.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 专题实战利用(全内联)
+2 -1
View File
@@ -2,7 +2,8 @@
name: unlimited-attack-scope
description: >-
不设限原则:搜索/验证/串联/记录一切,全领域适用,被拦换路,不达不休。Use when reminding scope is unlimited across Web/APK/cloud/AI/wireless/social eng.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 不设限原则
+2 -1
View File
@@ -2,7 +2,8 @@
name: web-attack-methods
description: >-
Web全栈攻击:SQLi/命令注入/SSTI/XSS/SSRF/NoSQL,认证JWT/OAuth/SAML,LFI/上传,Tomcat/WS/STOMP/XFF/PATH_INFO/CDN502/网宿JS挑战绕过。Use when testing Web injection, auth bypass, server-side, WAF/CDN bypass.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## Web 攻击手法(注入 / 认证 / 服务端 / 杂项 / CDN)
+2 -1
View File
@@ -2,7 +2,8 @@
name: wireless-hardware-attack
description: >-
无线/硬件:WiFi PMKID/WPS/Evil Twin,BLE,Zigbee,NFC,SDR,UART/JTAG/SPI,侧信道,故障注入。Use when attacking WiFi, BLE, RFID, SDR, or hardware interfaces.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 无线 / 硬件攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: zero-day-discovery
description: >-
0day自主发现引擎:变体分析/补丁间隙/差分/Fuzzing/污点推理/N-day武器化/猎人思维。Use when public vulns not found and need to discover 0day or weaponize N-day.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 0day 自主发现引擎(全网搜不到漏洞时自己挖)
+351
View File
@@ -3990,6 +3990,17 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
border-top-left-radius: 2px;
}
.message.assistant.assistant-not-finalized .message-bubble {
border-color: rgba(245, 124, 0, 0.28);
border-left: 4px solid #f57c00;
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), rgba(245, 124, 0, 0.035));
box-shadow: 0 6px 18px rgba(245, 124, 0, 0.08);
}
.message.assistant.assistant-not-finalized .message-bubble strong:first-child {
color: #b45309;
}
.message.assistant .message-bubble pre {
margin: 0;
white-space: pre-wrap;
@@ -7381,6 +7392,16 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
background: rgba(245, 124, 0, 0.09);
}
.timeline-item-finalization_check {
border-left-color: #f57c00;
background: linear-gradient(90deg, rgba(245, 124, 0, 0.1), rgba(96, 125, 139, 0.045));
}
.timeline-item-finalization_check .timeline-item-title {
color: #9a5200;
font-weight: 600;
}
.timeline-item-tool_calls_detected {
border-left-color: #0277bd;
background: rgba(2, 119, 189, 0.06);
@@ -22967,6 +22988,18 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
font-weight: 500;
color: var(--text-secondary);
}
.webshell-ai-timeline-finalization_check {
border-left: 3px solid #f57c00;
padding-left: 10px;
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), transparent);
}
.webshell-ai-timeline-finalization_check .webshell-ai-timeline-title {
color: #9a5200;
font-weight: 600;
}
.webshell-ai-timeline-msg {
margin-top: 4px;
padding-left: 0;
@@ -23144,6 +23177,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.webshell-ai-msg.assistant.webshell-ai-candidate-output {
border-color: rgba(245, 124, 0, 0.28);
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), rgba(245, 124, 0, 0.035));
box-shadow: 0 6px 16px rgba(245, 124, 0, 0.08);
}
.webshell-ai-msg.assistant.webshell-ai-msg-error {
max-width: 72%;
border-color: rgba(220, 53, 69, 0.35);
@@ -30312,6 +30350,27 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
line-height: 1.45;
}
.skills-management-page .skill-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.skills-management-page .skill-card-tags span {
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid rgba(15, 23, 42, 0.1);
background: rgba(15, 23, 42, 0.04);
color: var(--text-secondary);
font-size: 0.72rem;
line-height: 1.3;
}
.skills-management-page .skill-card-actions {
display: flex;
grid-column: auto;
@@ -36232,12 +36291,42 @@ html[data-theme="dark"] .timeline-item-user_interrupt_continue {
background: rgba(251, 191, 36, 0.08);
}
html[data-theme="dark"] .timeline-item-finalization_check {
background: linear-gradient(90deg, rgba(251, 191, 36, 0.1), rgba(30, 41, 59, 0.18));
border-left-color: #fbbf24;
}
html[data-theme="dark"] .timeline-item-finalization_check .timeline-item-title,
html[data-theme="dark"] .webshell-ai-timeline-finalization_check .webshell-ai-timeline-title {
color: #fbbf24;
}
html[data-theme="dark"] .message.assistant .message-bubble {
background: #111827;
color: var(--text-primary);
border-color: var(--border-color);
}
html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble {
border-color: rgba(251, 191, 36, 0.24);
border-left-color: #fbbf24;
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.9));
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
}
html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble strong:first-child {
color: #fbbf24;
}
html[data-theme="dark"] .webshell-ai-timeline-finalization_check {
border-left-color: #fbbf24;
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.12));
}
html[data-theme="dark"] .webshell-ai-msg.assistant.webshell-ai-candidate-output {
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.12));
}
html[data-theme="dark"] .message-copy-btn {
background: #1f2937;
border-color: #334155;
@@ -40035,6 +40124,247 @@ html[data-theme="dark"] .workflow-status-toggle.is-disabled {
padding: 12px 18px;
}
.workflow-ai-modal-content {
width: min(760px, calc(100vw - 32px));
max-height: calc(100vh - 64px);
overflow: hidden;
}
.workflow-ai-modal-header {
align-items: flex-start;
}
.workflow-ai-modal-header h2 {
margin-bottom: 4px;
}
.workflow-ai-subtitle {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
}
.workflow-ai-modal-body {
display: grid;
gap: 14px;
max-height: min(66vh, 640px);
overflow-y: auto;
}
.workflow-ai-prompt-field textarea {
resize: vertical;
min-height: 116px;
}
.workflow-ai-examples,
.workflow-ai-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.workflow-ai-options label {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--border-color);
border-radius: 7px;
color: var(--text-secondary);
font-size: 13px;
}
.workflow-ai-result {
display: grid;
gap: 10px;
}
.workflow-ai-result:empty {
display: none;
}
.workflow-ai-result-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.workflow-ai-progress {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.workflow-ai-progress[hidden] {
display: none;
}
.workflow-ai-progress span {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 9px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
background: var(--bg-secondary);
}
.workflow-ai-progress b {
display: inline-grid;
flex: 0 0 22px;
width: 22px;
height: 22px;
place-items: center;
border-radius: 50%;
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
}
.workflow-ai-progress small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-ai-progress .is-active {
border-color: color-mix(in srgb, var(--accent-color) 45%, var(--border-color));
color: var(--text-primary);
}
.workflow-ai-progress .is-active b {
background: var(--accent-color);
color: white;
}
.workflow-ai-progress .is-complete b {
background: #16a34a;
color: white;
}
.workflow-ai-preview {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
.workflow-ai-preview-title {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
.workflow-ai-preview-flow {
display: flex;
align-items: center;
gap: 7px;
overflow-x: auto;
padding-bottom: 2px;
}
.workflow-ai-preview-flow i {
flex: 0 0 auto;
color: var(--text-secondary);
font-style: normal;
}
.workflow-ai-preview-node {
display: grid;
flex: 0 0 auto;
gap: 2px;
width: 128px;
min-height: 48px;
padding: 7px 9px;
border: 1px solid color-mix(in srgb, var(--accent-color) 22%, var(--border-color));
border-radius: 8px;
background: var(--bg-primary);
}
.workflow-ai-preview-node small {
color: var(--text-secondary);
font-size: 11px;
}
.workflow-ai-preview-node strong {
overflow: hidden;
color: var(--text-primary);
font-size: 12px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-ai-preview-node.is-risky {
border-color: rgba(249, 115, 22, 0.62);
background: rgba(249, 115, 22, 0.08);
}
.workflow-ai-result-grid span,
.workflow-ai-result-section {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
}
.workflow-ai-result-grid span {
display: grid;
gap: 4px;
padding: 10px;
color: var(--text-secondary);
font-size: 12px;
}
.workflow-ai-result-grid strong {
color: var(--text-primary);
font-size: 16px;
}
.workflow-ai-result-section {
padding: 10px 12px;
font-size: 13px;
}
.workflow-ai-result-section p {
margin: 4px 0 0;
color: var(--text-secondary);
}
.workflow-ai-result-section ul {
margin: 8px 0 0;
padding-left: 18px;
color: var(--text-secondary);
}
.workflow-ai-result-section.is-warning {
border-color: rgba(245, 158, 11, 0.45);
background: rgba(245, 158, 11, 0.08);
}
.workflow-ai-result-section.is-attention {
border-color: rgba(14, 165, 233, 0.42);
background: rgba(14, 165, 233, 0.08);
}
.workflow-ai-modal-footer {
gap: 8px;
}
@media (max-width: 640px) {
.workflow-ai-progress {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workflow-ai-result-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
html[data-theme="dark"] .workflow-dry-run-modal-icon {
color: #bfdbfe;
background: rgba(59, 130, 246, 0.14);
@@ -40275,6 +40605,27 @@ html[data-theme="dark"] .workflow-toolbar #workflow-connect-btn[aria-pressed="tr
font-weight: 600;
}
.workflow-ai-canvas-status {
position: absolute;
top: 14px;
left: 50%;
z-index: 2;
transform: translateX(-50%);
padding: 9px 14px;
border: 1px solid color-mix(in srgb, var(--accent-color) 35%, var(--border-color));
border-radius: 999px;
background: color-mix(in srgb, var(--bg-primary) 92%, var(--accent-color));
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
pointer-events: none;
}
.workflow-ai-canvas-status[hidden] {
display: none;
}
.workflow-properties {
display: flex;
flex-direction: column;
+76 -1
View File
@@ -3532,6 +3532,81 @@
"canvasTools": "Canvas tools",
"moreActions": "More",
"deleteWorkflow": "Delete workflow",
"ai": {
"open": "Create from natural language",
"title": "Create Workflow from Natural Language",
"subtitle": "AI generates an editable draft and will not save or run it automatically.",
"promptLabel": "Workflow request",
"promptPlaceholder": "Example: continuously monitor a domain's subdomains, certificates, and exposed pages, then generate a report when new assets appear",
"examplesLabel": "Example requests",
"exampleDomain": "Domain monitor",
"exampleReport": "Report approval",
"exampleTriage": "Asset triage",
"includeObjective": "Generate objective configuration too",
"allowSchedule": "Allow scheduled trigger suggestions",
"allowHighRisk": "Allow high-risk node drafts",
"allowFallback": "Allow deterministic fallback if AI fails",
"promptRequired": "Describe the workflow request first.",
"generateFailed": "Generation failed",
"generatedWithIssues": "The draft still has validation issues. Adjust the request and try again.",
"generate": "Generate draft",
"generating": "Generating…",
"apply": "Render to canvas",
"rendering": "Rendering…",
"applied": "Workflow draft generated. Review it before saving.",
"canvasRendering": "AI is composing the canvas…",
"generatorAI": "AI channel",
"generatorLLM": "Model generated",
"generatorServer": "Server draft generator",
"generatorFallback": "Deterministic fallback",
"generatorUnknown": "Unknown generator",
"llmTitle": "Generated with the platform default AI channel and structurally validated",
"serverTitle": "Generated on the server and structurally audited",
"fallbackTitle": "Deterministic fallback was used",
"serverFallbackNotice": "Model generation is unavailable: {{reason}}",
"preview": "Flow preview",
"resultNodes": "Nodes",
"resultEdges": "Edges",
"resultRepairs": "Repair rounds",
"resultCapabilities": "Node capabilities",
"resultTools": "Tool capabilities",
"resultRisk": "Risk",
"resultSaveState": "Savable",
"yes": "Yes",
"no": "No",
"missingFields": "Missing configuration",
"riskWarnings": "Risk warnings",
"assumptions": "Assumptions",
"capabilityTrace": "Capability toolchain",
"nodeGenerated": "AI generated",
"nodeNeedsInput": "Needs input",
"riskLow": "Low",
"riskMedium": "Medium",
"riskHigh": "High",
"steps": {
"understand": "Understand request",
"match": "Match tools",
"draft": "Generate nodes",
"audit": "Safety audit"
},
"examples": {
"domainMonitor": "Continuously monitor a domain's subdomains, certificates, and exposed pages, then generate a report when new assets appear",
"reportReview": "Collect threat intelligence every day, generate a report, and send it to the security owner for approval",
"assetTriage": "Extract high-risk assets from scan results, deduplicate them, and output remediation suggestions"
}
},
"audit": {
"title": "Draft Audit",
"ready": "No blocking items found in this draft",
"review": "Review before saving or running",
"aiNodes": "AI nodes",
"needsInput": "Needs input",
"highRisk": "High risk",
"validation": "Structure issues",
"nodeIssues": "Node notes",
"validationIssues": "Structure checks",
"saveConfirm": "This draft contains missing configuration or high-risk nodes. Save as draft anyway?"
},
"package": {
"importLocal": "Import local package",
"export": "Export",
@@ -3641,7 +3716,7 @@
"deleteSelected": "Delete selected",
"autoLayout": "Auto layout",
"dryRun": "Dry run",
"canvasEmpty": "Drag nodes from the left onto the canvas, or click node buttons to add quickly",
"canvasEmpty": "Drag nodes from the left onto the canvas, or generate a workflow from natural language",
"properties": "Properties",
"nodeProperties": "Node properties",
"edgeProperties": "Edge properties",
+76 -1
View File
@@ -3520,6 +3520,81 @@
"canvasTools": "画布工具",
"moreActions": "更多",
"deleteWorkflow": "删除工作流",
"ai": {
"open": "用自然语言创建",
"title": "用自然语言创建工作流",
"subtitle": "AI 会生成可编辑草稿,不会自动保存或运行。",
"promptLabel": "工作流需求",
"promptPlaceholder": "例如:持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告",
"examplesLabel": "示例需求",
"exampleDomain": "域名监控",
"exampleReport": "报告审批",
"exampleTriage": "资产研判",
"includeObjective": "同时生成 objective 配置",
"allowSchedule": "允许生成定时触发建议",
"allowHighRisk": "允许包含高风险节点草稿",
"allowFallback": "AI 失败时允许确定性兜底",
"promptRequired": "请先描述工作流需求。",
"generateFailed": "生成失败",
"generatedWithIssues": "草稿仍有校验问题,请调整需求后重试。",
"generate": "生成草稿",
"generating": "正在生成…",
"apply": "渲染到画布",
"rendering": "正在渲染…",
"applied": "已生成工作流草稿,请检查后保存。",
"canvasRendering": "AI 正在编排画布…",
"generatorAI": "AI 通道",
"generatorLLM": "大模型生成",
"generatorServer": "服务端草稿生成器",
"generatorFallback": "确定性兜底",
"generatorUnknown": "未知生成器",
"llmTitle": "已调用平台默认 AI 通道生成,并完成结构校验",
"serverTitle": "已通过服务端生成并完成结构审计",
"fallbackTitle": "已使用确定性兜底",
"serverFallbackNotice": "大模型生成不可用:{{reason}}",
"preview": "流程预览",
"resultNodes": "节点",
"resultEdges": "连线",
"resultRepairs": "修复轮次",
"resultCapabilities": "节点能力",
"resultTools": "工具能力",
"resultRisk": "风险",
"resultSaveState": "可保存",
"yes": "是",
"no": "否",
"missingFields": "缺失配置",
"riskWarnings": "风险提示",
"assumptions": "生成假设",
"capabilityTrace": "能力工具链",
"nodeGenerated": "AI 生成",
"nodeNeedsInput": "需补配置",
"riskLow": "低",
"riskMedium": "中",
"riskHigh": "高",
"steps": {
"understand": "理解需求",
"match": "匹配工具",
"draft": "生成节点",
"audit": "安全审计"
},
"examples": {
"domainMonitor": "持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告",
"reportReview": "每天收集威胁情报,生成报告后交给安全负责人审批",
"assetTriage": "从扫描结果中提取高风险资产,去重后输出处置建议"
}
},
"audit": {
"title": "草稿审计",
"ready": "当前草稿未发现阻断项",
"review": "保存或运行前建议检查",
"aiNodes": "AI 节点",
"needsInput": "需补配置",
"highRisk": "高风险",
"validation": "结构问题",
"nodeIssues": "节点提示",
"validationIssues": "结构校验",
"saveConfirm": "当前草稿包含需补配置或高风险节点,确认仅保存为草稿?"
},
"package": {
"importLocal": "导入本地包",
"export": "导出",
@@ -3629,7 +3704,7 @@
"deleteSelected": "删除选中",
"autoLayout": "自动布局",
"dryRun": "试运行",
"canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加",
"canvasEmpty": "从左侧拖拽节点到画布,或用自然语言生成工作流",
"properties": "属性",
"nodeProperties": "节点属性",
"edgeProperties": "连线属性",
+23 -11
View File
@@ -198,7 +198,7 @@ function renderSidebar() {
const li = document.createElement('li');
li.className = 'api-group-item';
const groupLabel = translateApiDocTag(group);
li.innerHTML = `<a href="#" class="api-group-link" data-group="${escapeHtml(group)}">${escapeHtml(groupLabel)}</a>`;
li.innerHTML = `<a href="#" class="api-group-link" data-group="${escapeAttr(group)}">${escapeHtml(groupLabel)}</a>`;
groupList.appendChild(li);
});
@@ -265,7 +265,7 @@ function createEndpointCard(endpoint) {
<div class="api-endpoint-header">
<div class="api-endpoint-title">
<span class="api-method ${methodClass}">${endpoint.method.toUpperCase()}</span>
<span class="api-path">${endpoint.path}</span>
<span class="api-path">${escapeHtml(endpoint.path)}</span>
${tagHtml}
</div>
</div>
@@ -541,7 +541,7 @@ function renderTestSection(endpoint) {
bodyInput = `
<div class="api-test-input-group">
<label>${escapeHtml(_t('apiDocs.requestBodyJson'))}</label>
<textarea id="${bodyInputId}" class="test-body-input" placeholder='${escapeHtml(_t('apiDocs.requestBodyPlaceholder'))}'>${defaultBody}</textarea>
<textarea id="${escapeAttr(bodyInputId)}" class="test-body-input" placeholder='${escapeAttr(_t('apiDocs.requestBodyPlaceholder'))}'>${escapeHtml(defaultBody)}</textarea>
</div>
`;
}
@@ -554,8 +554,8 @@ function renderTestSection(endpoint) {
const inputId = `test-param-${param.name}-${escapeId(path)}-${method}`;
return `
<div class="api-test-input-group">
<label>${param.name} <span style="color: var(--error-color);">*</span></label>
<input type="text" id="${inputId}" placeholder="${param.description || param.name}" required>
<label>${escapeHtml(param.name)} <span style="color: var(--error-color);">*</span></label>
<input type="text" id="${escapeAttr(inputId)}" placeholder="${escapeAttr(param.description || param.name)}" required>
</div>
`;
}).join('');
@@ -572,11 +572,11 @@ function renderTestSection(endpoint) {
const required = param.required ? '<span style="color: var(--error-color);">*</span>' : '<span style="color: var(--text-muted);">' + escapeHtml(_t('apiDocs.optional')) + '</span>';
return `
<div class="api-test-input-group">
<label>${param.name} ${required}</label>
<label>${escapeHtml(param.name)} ${required}</label>
<input type="${param.schema?.type === 'number' || param.schema?.type === 'integer' ? 'number' : 'text'}"
id="${inputId}"
placeholder="${placeholder}"
value="${defaultValue}"
id="${escapeAttr(inputId)}"
placeholder="${escapeAttr(placeholder)}"
value="${escapeAttr(defaultValue)}"
${param.required ? 'required' : ''}>
</div>
`;
@@ -598,13 +598,13 @@ function renderTestSection(endpoint) {
${queryParamsInput ? `<div style="margin-top: 16px;"><div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${queryParamsTitle}</div>${queryParamsInput}</div>` : ''}
${bodyInput}
<div class="api-test-buttons">
<button class="api-test-btn primary" onclick="testAPI('${method}', '${escapeHtml(path)}', '${endpoint.operationId || ''}')">
<button class="api-test-btn primary" onclick="testAPI(${escapeJsStringAttr(method)}, ${escapeJsStringAttr(path)}, ${escapeJsStringAttr(endpoint.operationId || '')})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
${sendRequestLabel}
</button>
<button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, '${method}', '${escapeHtml(path)}')" title="${copyCurlTitle}">
<button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, ${escapeJsStringAttr(method)}, ${escapeJsStringAttr(path)})" title="${copyCurlTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/>
@@ -1006,6 +1006,18 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
// ID转义(用于HTML ID属性)
function escapeId(text) {
return text.replace(/[{}]/g, '').replace(/\//g, '-');
+7 -2
View File
@@ -11,6 +11,10 @@ function getAssetPageSize() {
const assetPageState = { page: 1, pageSize: getAssetPageSize(), total: 0, totalPages: 1, items: [], projects: [], projectsLoaded: false, detailIndex: -1, editIndex: -1, detailAsset: null, editAsset: null, selected: new Map(), selectionQuery: '', allMatchingSelected: false, scanMode: 'chat', scanAssets: [], editorTags: [], editorDirty: false, editorBusy: false, editorReturnFocus: null, editorInteractionsReady: false, editorParsedTarget: '', importRows: [], importFileName: '', importBusy: false, importInteractionsReady: false, importReturnFocus: null };
let assetOverviewDays = 30;
function assetEscapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const ASSET_CUSTOM_SELECT_IDS = [
'asset-status-filter',
'asset-project-filter',
@@ -1147,6 +1151,7 @@ async function sendAssetsToChat(assets, template) {
input.value = message;
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
void sendMessage();
}
@@ -1239,14 +1244,14 @@ function populateAssetProjectSelects() {
const el = document.getElementById(id);
if (!el) return;
const current = el.value;
el.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` + assetPageState.projects.map(project => `<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)}${project.status === 'archived' ? ' · ' + escapeHtml(assetT('assets.archived', '已归档')) : ''}</option>`).join('');
el.innerHTML = `<option value="">${escapeHtml(emptyLabel)}</option>` + assetPageState.projects.map(project => `<option value="${assetEscapeAttr(project.id)}">${escapeHtml(project.name)}${project.status === 'archived' ? ' · ' + escapeHtml(assetT('assets.archived', '已归档')) : ''}</option>`).join('');
el.value = current;
syncAssetSelect(el);
});
const batch = document.getElementById('asset-batch-project');
if (batch) {
const current = batch.value;
batch.innerHTML = `<option value="" disabled hidden>${escapeHtml(assetT('assets.chooseProject', '请选择项目'))}</option>` + assetPageState.projects.map(project => `<option value="${escapeHtml(project.id)}">${escapeHtml(project.name)}${project.status === 'archived' ? ' · ' + escapeHtml(assetT('assets.archived', '已归档')) : ''}</option>`).join('');
batch.innerHTML = `<option value="" disabled hidden>${escapeHtml(assetT('assets.chooseProject', '请选择项目'))}</option>` + assetPageState.projects.map(project => `<option value="${assetEscapeAttr(project.id)}">${escapeHtml(project.name)}${project.status === 'archived' ? ' · ' + escapeHtml(assetT('assets.archived', '已归档')) : ''}</option>`).join('');
batch.value = current;
syncAssetSelect(batch);
}
+13 -1
View File
@@ -632,6 +632,18 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
/** @param {string} text @param {{ profile?: 'chat'|'timeline' }} [options] */
function formatMarkdown(text, options) {
if (typeof window.csMarkdownSanitize !== 'undefined') {
@@ -863,7 +875,7 @@ async function loadRobotAccountBindings() {
<div class="robot-binding-account-name"><strong>${escapeHtml(platformLabels[binding.platform] || binding.platform || '-')}</strong><span></span></div>
<small>账号标识 ${escapeHtml(binding.external_user_hint || '-')} · 更新于 ${escapeHtml(formatRobotBindingTime(binding.updated_at))}</small>
</div>
<button type="button" class="btn-secondary btn-small robot-binding-unbind-btn" onclick="deleteRobotAccountBinding('${escapeHtml(binding.id || '')}')">解除绑定</button>
<button type="button" class="btn-secondary btn-small robot-binding-unbind-btn" onclick="deleteRobotAccountBinding(${escapeJsStringAttr(binding.id || '')})">解除绑定</button>
</div>`).join('');
if (typeof window.loadVulnerabilityAlertSubscription === 'function') {
window.loadVulnerabilityAlertSubscription();
+164 -85
View File
@@ -113,10 +113,10 @@
if (!id || seen.has(id)) return;
seen.add(id);
const label = c2ProjectDisplayName(id, name);
html += `<option value="${escapeHtml(id)}"${id === selected ? ' selected' : ''}>${escapeHtml(label)}</option>`;
html += `<option value="${escapeAttr(id)}"${id === selected ? ' selected' : ''}>${escapeHtml(label)}</option>`;
});
if (selected && !seen.has(selected)) {
html += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(c2ProjectDisplayName(selected))}</option>`;
html += `<option value="${escapeAttr(selected)}" selected>${escapeHtml(c2ProjectDisplayName(selected))}</option>`;
}
return html;
}
@@ -147,7 +147,7 @@
}
function c2ProjectBindSelectHtml(listener) {
return `<select class="c2-project-bind-select" data-id="${escapeHtml(listener.id || '')}" title="${escapeHtml(c2t('assets.project') || '所属项目')}" onclick="event.stopPropagation()" onchange="C2.bindListenerProject(this.dataset.id, this.value)">${c2ProjectOptionsHtml(c2ResourceProjectId(listener))}</select>`;
return `<select class="c2-project-bind-select" data-id="${escapeAttr(listener.id || '')}" title="${escapeAttr(c2t('assets.project') || '所属项目')}" onclick="event.stopPropagation()" onchange="C2.bindListenerProject(this.dataset.id, this.value)">${c2ProjectOptionsHtml(c2ResourceProjectId(listener))}</select>`;
}
function withC2ProjectQuery(url) {
@@ -478,7 +478,7 @@
if (empty) valueClasses.push('is-empty');
const rowCls = opts && opts.full ? ' c2-session-info-dl__row--full' : '';
const copyBtn = (opts && opts.copy && !empty)
? `<button type="button" class="c2-session-info-copy" title="${escapeHtml(c2t('c2.sessions.infoCopy'))}" aria-label="${escapeHtml(c2t('c2.sessions.infoCopy'))}" onclick="event.stopPropagation(); C2.copyText(${JSON.stringify(String(value))})">
? `<button type="button" class="c2-session-info-copy" title="${escapeAttr(c2t('c2.sessions.infoCopy'))}" aria-label="${escapeAttr(c2t('c2.sessions.infoCopy'))}" data-c2-copy-value="${escapeAttr(String(value))}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
</button>`
: '';
@@ -486,7 +486,7 @@
<div class="c2-session-info-dl__row${rowCls}">
<dt class="c2-session-info-dl__label">${escapeHtml(label)}</dt>
<dd class="${valueClasses.join(' ')}">
<span class="c2-session-info-dl__text" title="${escapeHtml(empty ? '' : display)}">${escapeHtml(display)}</span>
<span class="c2-session-info-dl__text" title="${escapeAttr(empty ? '' : display)}">${escapeHtml(display)}</span>
${copyBtn}
</dd>
</div>`;
@@ -543,16 +543,16 @@
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--note" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</span>
<button type="button" class="c2-session-note-edit-btn" data-require-permission="c2:write" onclick='C2.beginEditSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('c2.sessions.noteEdit'))}</button>
<button type="button" class="c2-session-note-edit-btn" data-require-permission="c2:write" data-c2-action="session-note-edit" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('c2.sessions.noteEdit'))}</button>
</div>
<div class="c2-session-info-note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-view">${escapeHtml(noteText || c2t('c2.sessions.infoNoteEmpty'))}</div>
<div class="c2-session-note-editor" id="c2-session-note-editor" hidden>
<textarea id="c2-session-note-input" class="c2-session-note-textarea" maxlength="2000" rows="4" placeholder="${escapeHtml(c2t('c2.sessions.notePlaceholder'))}">${escapeHtml(noteText)}</textarea>
<textarea id="c2-session-note-input" class="c2-session-note-textarea" maxlength="2000" rows="4" placeholder="${escapeAttr(c2t('c2.sessions.notePlaceholder'))}">${escapeHtml(noteText)}</textarea>
<div class="c2-session-note-editor__footer">
<span class="c2-session-note-counter" id="c2-session-note-counter">${noteText.length}/2000</span>
<div class="c2-session-note-editor__actions">
<button type="button" class="btn-secondary btn-sm" onclick="C2.cancelEditSessionNote()">${escapeHtml(c2t('common.cancel'))}</button>
<button type="button" class="btn-primary btn-sm" id="c2-session-note-save" onclick='C2.saveSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('common.save'))}</button>
<button type="button" class="btn-primary btn-sm" id="c2-session-note-save" data-c2-action="session-note-save" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('common.save'))}</button>
</div>
</div>
</div>
@@ -638,15 +638,19 @@
return div.innerHTML;
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/** 任务列表操作按钮(查看/取消/删除)— 事件委托 */
function bindC2TaskActionDelegation() {
if (document.documentElement.dataset.c2TaskActionsBound === '1') return;
document.documentElement.dataset.c2TaskActionsBound = '1';
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-c2-task-action]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const btn = e.target.closest('[data-c2-task-action]');
if (!btn) return;
e.preventDefault();
e.stopImmediatePropagation();
const action = btn.getAttribute('data-c2-task-action');
const id = btn.getAttribute('data-task-id');
if (!id) return;
@@ -657,6 +661,75 @@
}
bindC2TaskActionDelegation();
/** C2 动态内容操作按钮 — 避免把用户可控值拼入 inline onclick */
function bindC2SafeActionDelegation() {
if (document.documentElement.dataset.c2SafeActionsBound === '1') return;
document.documentElement.dataset.c2SafeActionsBound = '1';
document.addEventListener('click', function(e) {
const copyBtn = e.target.closest('[data-c2-copy-value]');
if (copyBtn) {
e.preventDefault();
e.stopPropagation();
C2.copyText(copyBtn.getAttribute('data-c2-copy-value') || '');
return;
}
const fileBtn = e.target.closest('[data-c2-file-action]');
if (fileBtn) {
e.preventDefault();
e.stopPropagation();
const name = fileBtn.getAttribute('data-c2-file-name') || '';
const action = fileBtn.getAttribute('data-c2-file-action');
if (action === 'open') C2.openDirectory(name);
else if (action === 'download') C2.downloadFile(name);
return;
}
const stopEl = e.target.closest('[data-c2-stop-action]');
const actionEl = e.target.closest('[data-c2-action]');
if (stopEl && (!actionEl || !stopEl.contains(actionEl))) return;
if (!actionEl) return;
e.preventDefault();
e.stopPropagation();
runC2SafeAction(actionEl);
});
document.addEventListener('keydown', function(e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
const actionEl = e.target.closest('[data-c2-action][role="button"]');
if (!actionEl) return;
e.preventDefault();
runC2SafeAction(actionEl);
});
}
bindC2SafeActionDelegation();
function runC2SafeAction(el) {
const action = el.getAttribute('data-c2-action');
const id = el.getAttribute('data-c2-id') || '';
switch (action) {
case 'session-note-edit': C2.beginEditSessionNote(id); break;
case 'session-note-save': C2.saveSessionNote(id); break;
case 'listener-start': C2.startListener(id); break;
case 'listener-stop': C2.stopListener(id); break;
case 'listener-edit': C2.editListener(id); break;
case 'listener-delete': C2.deleteListener(id); break;
case 'listener-save': C2.saveListener(id); break;
case 'session-select': C2.selectSession(id); break;
case 'session-delete': C2.deleteSessionRecord(id); break;
case 'session-sleep': C2.setSessionSleep(id); break;
case 'session-kill': C2.killSession(id); break;
case 'session-tasks-refresh': C2.loadSessionTasks(id); break;
case 'task-view': C2.viewTask(id); break;
case 'event-view': C2.viewEvent(id); break;
case 'event-delete': C2.deleteEventById(id); break;
case 'profile-delete': C2.deleteProfile(id); break;
case 'payload-download': {
if (typeof window.__c2DownloadPayload === 'function') window.__c2DownloadPayload(id);
break;
}
}
}
/** 监听器表单:Malleable Profile 下拉选项 HTMLvalue / 文本已转义) */
function listenerProfileSelectHtml(selectedProfileId) {
const sel = selectedProfileId ? String(selectedProfileId) : '';
@@ -665,7 +738,7 @@
if (!p) continue;
const pid = p.id || p.ID;
if (!pid) continue;
const idEsc = escapeHtml(String(pid));
const idEsc = escapeAttr(String(pid));
const nameEsc = escapeHtml(p.name || pid);
const selected = sel && String(pid) === sel ? ' selected' : '';
opts += `<option value="${idEsc}"${selected}>${nameEsc}</option>`;
@@ -852,7 +925,7 @@
const profilePid = listenerResolvedProfileId(l);
const profileName = listenerProfileDisplayName(l);
const profileBadge = profilePid
? '<div class="c2-listener-profile-badge" title="' + escapeHtml(c2t('c2.listeners.profileBadgeTitle')) + '"><span class="c2-listener-profile-dot" aria-hidden="true"></span><span>' + escapeHtml(profileName) + '</span></div>'
? '<div class="c2-listener-profile-badge" title="' + escapeAttr(c2t('c2.listeners.profileBadgeTitle')) + '"><span class="c2-listener-profile-dot" aria-hidden="true"></span><span>' + escapeHtml(profileName) + '</span></div>'
: '';
const cb = C2.getListenerCallbackHost(l);
const cbRow = cb
@@ -868,7 +941,7 @@
const bindVal = escapeHtml(String(l.bindHost)) + ':' + escapeHtml(String(l.bindPort));
return `
<article class="c2-listener-card c2-listener-card--${stUi}" data-listener-id="${escapeHtml(l.id)}">
<article class="c2-listener-card c2-listener-card--${stUi}" data-listener-id="${escapeAttr(l.id)}">
<div class="c2-listener-card-head">
<div class="c2-ltype-mark ${typeVis}" title="${fullType}"><span>${typeMark}</span></div>
<div class="c2-listener-card-head-main">
@@ -877,7 +950,7 @@
<span class="c2-listener-pill c2-listener-pill--${stUi}">${pillLabel}</span>
</div>
<div class="c2-listener-id-row">
<code class="c2-listener-id-full" title="${escapeHtml(l.id)}">${escapeHtml(l.id)}</code>
<code class="c2-listener-id-full" title="${escapeAttr(l.id)}">${escapeHtml(l.id)}</code>
</div>
</div>
</div>
@@ -894,11 +967,11 @@
</div>
<div class="c2-listener-card-actions">
${l.status === 'stopped'
? `<button type="button" class="btn-primary btn-sm" data-require-permission="c2:write" onclick="C2.startListener('${l.id}')">▶ ${escapeHtml(c2t('c2.listeners.start'))}</button>`
: `<button type="button" class="btn-secondary btn-sm" data-require-permission="c2:write" onclick="C2.stopListener('${l.id}')">⏹ ${escapeHtml(c2t('c2.listeners.stop'))}</button>`
? `<button type="button" class="btn-primary btn-sm" data-require-permission="c2:write" data-c2-action="listener-start" data-c2-id="${escapeAttr(l.id)}">▶ ${escapeHtml(c2t('c2.listeners.start'))}</button>`
: `<button type="button" class="btn-secondary btn-sm" data-require-permission="c2:write" data-c2-action="listener-stop" data-c2-id="${escapeAttr(l.id)}">⏹ ${escapeHtml(c2t('c2.listeners.stop'))}</button>`
}
<button type="button" class="btn-secondary btn-sm" data-require-permission="c2:write" onclick="C2.editListener('${l.id}')">${escapeHtml(c2t('c2.listeners.edit'))}</button>
<button type="button" class="btn-danger btn-sm" data-require-permission="c2:delete" onclick="C2.deleteListener('${l.id}')">${escapeHtml(c2t('c2.listeners.delete'))}</button>
<button type="button" class="btn-secondary btn-sm" data-require-permission="c2:write" data-c2-action="listener-edit" data-c2-id="${escapeAttr(l.id)}">${escapeHtml(c2t('c2.listeners.edit'))}</button>
<button type="button" class="btn-danger btn-sm" data-require-permission="c2:delete" data-c2-action="listener-delete" data-c2-id="${escapeAttr(l.id)}">${escapeHtml(c2t('c2.listeners.delete'))}</button>
</div>
</article>`;
}).join('');
@@ -963,7 +1036,7 @@
<div class="c2-form-row">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
<input type="text" id="c2-listener-name" class="form-control" placeholder="${escapeHtml(c2t('c2.listeners.placeholderNameExample'))}">
<input type="text" id="c2-listener-name" class="form-control" placeholder="${escapeAttr(c2t('c2.listeners.placeholderNameExample'))}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.type'))}</label>
@@ -1003,7 +1076,7 @@
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.remark'))}</label>
<input type="text" id="c2-listener-remark" class="form-control" placeholder="${escapeHtml(c2t('c2.listeners.placeholderRemarkLong'))}">
<input type="text" id="c2-listener-remark" class="form-control" placeholder="${escapeAttr(c2t('c2.listeners.placeholderRemarkLong'))}">
</div>
<div class="c2-form-group" id="c2-listener-legacy-shell-group" style="display:none;">
<label class="c2-checkbox-label">
@@ -1191,7 +1264,7 @@
<div class="c2-modal-body">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
<input type="text" id="c2-listener-name" class="form-control" value="${escapeHtml(l.name)}">
<input type="text" id="c2-listener-name" class="form-control" value="${escapeAttr(l.name)}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
@@ -1200,7 +1273,7 @@
<div class="c2-form-row">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
<input type="text" id="c2-listener-host" class="form-control" value="${escapeHtml(String(l.bindHost))}">
<input type="text" id="c2-listener-host" class="form-control" value="${escapeAttr(String(l.bindHost))}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindPort'))}</label>
@@ -1215,12 +1288,12 @@
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.callbackHost'))}</label>
<input type="text" id="c2-listener-callback-host" class="form-control" value="${escapeHtml(cbHost)}">
<input type="text" id="c2-listener-callback-host" class="form-control" value="${escapeAttr(cbHost)}">
<div class="form-hint">${escapeHtml(c2t('c2.listeners.callbackHostHint'))}</div>
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.remark'))}</label>
<input type="text" id="c2-listener-remark" class="form-control" value="${escapeHtml(l.remark || '')}">
<input type="text" id="c2-listener-remark" class="form-control" value="${escapeAttr(l.remark || '')}">
</div>
${lt === 'tcp_reverse' ? `
<div class="c2-form-group" id="c2-listener-legacy-shell-group">
@@ -1233,7 +1306,7 @@
</div>
<div class="c2-modal-footer">
<button class="btn-secondary" onclick="C2.closeModal()">${escapeHtml(c2t('common.cancel'))}</button>
<button class="btn-primary" onclick="C2.saveListener('${l.id}')">${escapeHtml(c2t('common.save'))}</button>
<button class="btn-primary" data-c2-action="listener-save" data-c2-id="${escapeAttr(l.id)}">${escapeHtml(c2t('common.save'))}</button>
</div>
`;
C2.refreshFormSelects(content);
@@ -1304,11 +1377,11 @@
const listenerOpts = ['<option value="">' + escapeHtml(c2t('c2.sessions.filterAllListeners')) + '</option>']
.concat(listeners.map(l => {
const sel = f.listener_id === l.id ? ' selected' : '';
return `<option value="${escapeHtml(l.id)}"${sel}>${escapeHtml(l.name)}</option>`;
return `<option value="${escapeAttr(l.id)}"${sel}>${escapeHtml(l.name)}</option>`;
})).join('');
toolbar.innerHTML = `
<div class="c2-sessions-filter-row">
<select id="c2-session-filter-status" class="form-control c2-native-select" title="${escapeHtml(c2t('c2.sessions.status'))}" onchange="C2.applySessionFilter()">
<select id="c2-session-filter-status" class="form-control c2-native-select" title="${escapeAttr(c2t('c2.sessions.status'))}" onchange="C2.applySessionFilter()">
<option value="">${escapeHtml(c2t('c2.sessions.filterAllStatus'))}</option>
<option value="active"${f.status === 'active' ? ' selected' : ''}>${escapeHtml(c2t('c2.sessions.active'))}</option>
<option value="sleeping"${f.status === 'sleeping' ? ' selected' : ''}>${escapeHtml(c2t('c2.sessions.sleeping'))}</option>
@@ -1316,7 +1389,7 @@
</select>
<select id="c2-session-filter-listener" class="form-control c2-native-select" onchange="C2.applySessionFilter()">${listenerOpts}</select>
</div>
<input type="text" id="c2-session-filter-search" class="form-control" placeholder="${escapeHtml(c2t('c2.sessions.filterSearchPlaceholder'))}" value="${escapeHtml(f.search || '')}" onkeydown="if(event.key==='Enter'){C2.applySessionFilter();}">
<input type="text" id="c2-session-filter-search" class="form-control" placeholder="${escapeAttr(c2t('c2.sessions.filterSearchPlaceholder'))}" value="${escapeAttr(f.search || '')}" onkeydown="if(event.key==='Enter'){C2.applySessionFilter();}">
<div class="c2-sessions-toolbar-meta">
<label class="c2-sessions-select-all-label">
<input type="checkbox" id="c2-sessions-select-all" onchange="C2.onSessionsSelectAll(this.checked)">
@@ -1452,10 +1525,13 @@
const osEmpty = isEmptyInfoValue(s.os) && isEmptyInfoValue(s.arch);
return `
<div class="c2-session-item ${s.id === C2.selectedSessionId ? 'active' : ''}"
data-status="${escapeHtml(s.status || '')}"
onclick="C2.selectSession('${s.id}')">
<input type="checkbox" class="c2-session-item-check c2-session-row-check" data-id="${escapeHtml(s.id)}"
onclick="event.stopPropagation();" onchange="C2.syncSessionsToolbar()">
data-status="${escapeAttr(s.status || '')}"
data-c2-action="session-select"
data-c2-id="${escapeAttr(s.id)}"
role="button"
tabindex="0">
<input type="checkbox" class="c2-session-item-check c2-session-row-check" data-id="${escapeAttr(s.id)}"
data-c2-stop-action="1" onchange="C2.syncSessionsToolbar()">
<div class="c2-session-item-body">
<div class="c2-session-header">
<div class="c2-session-host-row">
@@ -1474,8 +1550,8 @@
<span class="c2-session-chip c2-session-chip--dim">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
</div>
<div class="c2-session-item-footer">
<span class="c2-session-meta c2-session-item-time" title="${escapeHtml(formatTime(s.lastCheckIn))}">${escapeHtml(formatRelativeTime(s.lastCheckIn) || formatTime(s.lastCheckIn))}</span>
<button type="button" class="c2-session-card-delete" data-require-permission="c2:delete" onclick="event.stopPropagation(); C2.deleteSessionRecord('${s.id}');">${escapeHtml(c2t('c2.sessions.cardDeleteSession'))}</button>
<span class="c2-session-meta c2-session-item-time" title="${escapeAttr(formatTime(s.lastCheckIn))}">${escapeHtml(formatRelativeTime(s.lastCheckIn) || formatTime(s.lastCheckIn))}</span>
<button type="button" class="c2-session-card-delete" data-require-permission="c2:delete" data-c2-action="session-delete" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('c2.sessions.cardDeleteSession'))}</button>
</div>
</div>
</div>`;
@@ -1531,7 +1607,7 @@
</div>
<div class="c2-session-hero__sub${isEmptyInfoValue(s.username) && isEmptyInfoValue(s.os) ? ' is-muted' : ''}">${escapeHtml(sessionMetaLine(s))}</div>
<div class="c2-session-hero__chips">
<span class="c2-session-hero-chip is-mono" title="${escapeHtml(c2t('c2.sessions.infoSessionId'))}">${escapeHtml(s.id)}</span>
<span class="c2-session-hero-chip is-mono" title="${escapeAttr(c2t('c2.sessions.infoSessionId'))}">${escapeHtml(s.id)}</span>
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '—')}</span>
<span class="c2-session-hero-chip">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
</div>
@@ -1544,8 +1620,8 @@
<span class="c2-session-hero__heartbeat-value">${escapeHtml(heartbeatRel)}</span>
</div>
<div class="c2-session-actions">
<button class="btn-secondary btn-sm" data-require-permission="c2:write" onclick="C2.setSessionSleep('${s.id}')">${escapeHtml(c2t('c2.sessions.btnSleep'))}</button>
<button class="btn-danger btn-sm" data-require-permission="c2:write" onclick="C2.killSession('${s.id}')">${escapeHtml(c2t('c2.sessions.kill'))}</button>
<button class="btn-secondary btn-sm" data-require-permission="c2:write" data-c2-action="session-sleep" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('c2.sessions.btnSleep'))}</button>
<button class="btn-danger btn-sm" data-require-permission="c2:write" data-c2-action="session-kill" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('c2.sessions.kill'))}</button>
</div>
</div>
</div>
@@ -1571,7 +1647,7 @@
<div class="c2-file-toolbar">
<button class="btn-ghost btn-sm" onclick="C2.goToParentDirectory()">${escapeHtml(c2t('c2.files.parent'))}</button>
<button class="btn-ghost btn-sm" onclick="C2.refreshFiles()">${escapeHtml(c2t('c2.files.refresh'))}</button>
<button type="button" class="btn-ghost btn-sm" id="c2-file-upload-btn" data-require-permission="c2:write" onclick="C2.openFileUploadPicker()" title="${escapeHtml(c2t('c2.files.upload'))}">${escapeHtml(c2t('c2.files.upload'))}</button>
<button type="button" class="btn-ghost btn-sm" id="c2-file-upload-btn" data-require-permission="c2:write" onclick="C2.openFileUploadPicker()" title="${escapeAttr(c2t('c2.files.upload'))}">${escapeHtml(c2t('c2.files.upload'))}</button>
<input type="file" id="c2-file-upload-input" style="display:none" onchange="C2.onC2FileUploadPick(event)" />
<span id="c2-current-path" class="c2-path-breadcrumb">/</span>
</div>
@@ -1698,7 +1774,7 @@
<p class="c2-sleep-modal__host">${escapeHtml(hostLabel)}</p>
</div>
</div>
<button type="button" class="c2-modal-close" onclick="C2.closeModal()" aria-label="${escapeHtml(c2t('common.close'))}">&times;</button>
<button type="button" class="c2-modal-close" onclick="C2.closeModal()" aria-label="${escapeAttr(c2t('common.close'))}">&times;</button>
</div>
<div class="c2-sleep-modal__current">${escapeHtml(currentLine)}</div>
<div class="c2-sleep-modal__body">
@@ -2778,16 +2854,16 @@
<td>
<div class="c2-file-name">
<span class="${iconCls}" aria-hidden="true"></span>
<span class="c2-file-name-text" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</span>
<span class="c2-file-name-text" title="${escapeAttr(entry.name)}">${escapeHtml(entry.name)}</span>
</div>
</td>
<td title="${escapeHtml(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
<td title="${escapeAttr(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
<td>${escapeHtml(entry.mode)}</td>
<td>
${entry.isDir
? `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
: `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
}
<button type="button"
class="btn-ghost btn-sm c2-file-action-btn"
data-c2-file-action="${entry.isDir ? 'open' : 'download'}"
data-c2-file-name="${escapeAttr(entry.name)}">${escapeHtml(c2t(entry.isDir ? 'c2.files.open' : 'c2.files.download'))}</button>
</td>
</tr>
`;
@@ -3366,7 +3442,7 @@
<span class="c2-session-tasks-heading">${escapeHtml(c2t('c2.tasks.sessionTaskHistory'))}</span>
<span class="c2-session-tasks-count">0</span>
</div>
<button type="button" class="btn-ghost btn-sm c2-session-tasks-refresh" onclick="C2.loadSessionTasks('${escapeHtml(sessionId)}')">${refreshBtn}</button>
<button type="button" class="btn-ghost btn-sm c2-session-tasks-refresh" data-c2-action="session-tasks-refresh" data-c2-id="${escapeAttr(sessionId)}">${refreshBtn}</button>
</div>
<div class="c2-empty-inline">
<div class="c2-empty-inline__icon" aria-hidden="true"></div>
@@ -3383,7 +3459,7 @@
<span class="c2-session-tasks-heading">${escapeHtml(c2t('c2.tasks.sessionTaskHistory'))}</span>
<span class="c2-session-tasks-count">${countLabel}</span>
</div>
<button type="button" class="btn-ghost btn-sm c2-session-tasks-refresh" onclick="C2.loadSessionTasks('${escapeHtml(sessionId)}')">${refreshBtn}</button>
<button type="button" class="btn-ghost btn-sm c2-session-tasks-refresh" data-c2-action="session-tasks-refresh" data-c2-id="${escapeAttr(sessionId)}">${refreshBtn}</button>
</div>
<div class="c2-session-tasks-rows">
${tasks.map(t => {
@@ -3395,11 +3471,11 @@
const isPending = status === 'queued' || status === 'sent' || status === 'running';
const timeStr = formatTime(t.completedAt || t.createdAt);
return `
<div class="c2-session-task-row ${isPending ? 'is-pending' : ''}" data-status="${escapeHtml(status)}">
<div class="c2-session-task-row ${isPending ? 'is-pending' : ''}" data-status="${escapeAttr(status)}">
<div class="c2-session-task-row__main">
<span class="c2-task-status-dot ${escapeHtml(status)}" title="${escapeHtml(taskStatusLabel(status))}"></span>
<span class="c2-task-status-dot ${escapeAttr(status)}" title="${escapeAttr(taskStatusLabel(status))}"></span>
<span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span>
<div class="c2-session-task-row__cmd" title="${escapeHtml(cmd || '')}">
<div class="c2-session-task-row__cmd" title="${escapeAttr(cmd || '')}">
${cmdShort
? `<code class="c2-session-task-command">${escapeHtml(cmdShort)}</code>`
: `<span class="c2-session-task-command c2-session-task-command--muted">—</span>`}
@@ -3408,8 +3484,8 @@
<div class="c2-session-task-row__meta">
<span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span>
<span class="c2-session-task-duration">${formatDuration(t.durationMs)}</span>
<span class="c2-session-task-time" title="${escapeHtml(timeStr)}">${escapeHtml(formatRelativeTime(t.completedAt || t.createdAt) || timeStr)}</span>
<button type="button" class="btn-secondary btn-small c2-session-task-view" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
<span class="c2-session-task-time" title="${escapeAttr(timeStr)}">${escapeHtml(formatRelativeTime(t.completedAt || t.createdAt) || timeStr)}</span>
<button type="button" class="btn-secondary btn-small c2-session-task-view" data-c2-task-action="view" data-task-id="${escapeAttr(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
</div>
</div>`;
}).join('')}
@@ -3446,7 +3522,7 @@
<thead>
<tr>
<th class="c2-tasks-table-col-check">
<label class="c2-task-check-label" title="${escapeHtml(c2t('c2.tasks.selectAll'))}">
<label class="c2-task-check-label" title="${escapeAttr(c2t('c2.tasks.selectAll'))}">
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
</label>
</th>
@@ -3461,10 +3537,11 @@
</tr>
</thead>
<tbody>
${C2.tasks.map(t => {
const rawId = t.id || '';
const tid = escapeHtml(rawId);
const status = String(t.status || '');
${C2.tasks.map(t => {
const rawId = t.id || '';
const tid = escapeHtml(rawId);
const tidAttr = escapeAttr(rawId);
const status = String(t.status || '');
const rowStatus = escapeHtml(status || 'queued');
const typeCat = taskTypeCategory(t.taskType);
const sessionFull = t.sessionId ? String(t.sessionId) : '';
@@ -3478,25 +3555,25 @@
const cmdEsc = escapeHtml(cmd);
const canCancel = status === 'queued' || status === 'sent';
return `
<tr class="c2-tasks-row c2-tasks-row--${rowStatus}" data-task-id="${tid}" onclick="C2.viewTask('${tid}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();C2.viewTask('${tid}')}" role="button" tabindex="0">
<td class="c2-tasks-table-col-check" onclick="event.stopPropagation();">
<tr class="c2-tasks-row c2-tasks-row--${rowStatus}" data-task-id="${tidAttr}" data-c2-action="task-view" data-c2-id="${tidAttr}" role="button" tabindex="0">
<td class="c2-tasks-table-col-check" data-c2-stop-action="1">
<label class="c2-task-check-label">
<input type="checkbox" class="c2-task-row-check" data-id="${tid}" onchange="C2.syncTasksToolbar()">
<input type="checkbox" class="c2-task-row-check" data-id="${tidAttr}" onchange="C2.syncTasksToolbar()">
</label>
</td>
<td class="c2-tasks-col-time">${escapeHtml(formatTime(t.createdAt))}</td>
<td><span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span></td>
<td><span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span></td>
<td class="c2-tasks-col-command" title="${cmdEsc}">${cmdEsc || dash}</td>
<td class="c2-tasks-col-mono" title="${escapeHtml(sessionFull)}">${sessionShort || dash}</td>
<td class="c2-tasks-col-mono" title="${tid}">${taskShort || dash}</td>
<td class="c2-tasks-col-command" title="${escapeAttr(cmd)}">${cmdEsc || dash}</td>
<td class="c2-tasks-col-mono" title="${escapeAttr(sessionFull)}">${sessionShort || dash}</td>
<td class="c2-tasks-col-mono" title="${tidAttr}">${taskShort || dash}</td>
<td class="c2-tasks-col-duration">${formatDuration(t.durationMs)}</td>
<td class="c2-tasks-table-col-actions" onclick="event.stopPropagation();">
<td class="c2-tasks-table-col-actions" data-c2-stop-action="1">
<div class="c2-tasks-row-actions">
${canCancel
? `<button type="button" class="c2-tasks-cancel-btn" data-c2-task-action="cancel" data-task-id="${tid}" title="${cancelTitle}" aria-label="${cancelTitle}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''}
<button type="button" class="c2-tasks-delete-btn" data-require-permission="c2:delete" data-c2-task-action="delete" data-task-id="${tid}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
? `<button type="button" class="c2-tasks-cancel-btn" data-c2-task-action="cancel" data-task-id="${tidAttr}" title="${cancelTitle}" aria-label="${cancelTitle}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''}
<button type="button" class="c2-tasks-delete-btn" data-require-permission="c2:delete" data-c2-task-action="delete" data-task-id="${tidAttr}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
</div>
</td>
</tr>`;
@@ -3717,7 +3794,7 @@
C2.renderPayloadPage = function() {
const optionsHtml = C2.listeners.length > 0
? C2.listeners.map(l =>
`<option value="${l.id}">${escapeHtml(l.name)} (${l.type} ${l.bindHost}:${l.bindPort})</option>`
`<option value="${escapeAttr(l.id)}">${escapeHtml(l.name)} (${escapeHtml(l.type)} ${escapeHtml(l.bindHost)}:${escapeHtml(l.bindPort)})</option>`
).join('')
: '<option value="">' + escapeHtml(c2t('c2.payloads.noListenersOption')) + '</option>';
@@ -3734,7 +3811,7 @@
let buildOptionsHtml;
if (listeners.length > 0) {
buildOptionsHtml = listeners.map(l =>
`<option value="${l.id}">${escapeHtml(l.name)} (${l.type} ${l.bindHost}:${l.bindPort})</option>`
`<option value="${escapeAttr(l.id)}">${escapeHtml(l.name)} (${escapeHtml(l.type)} ${escapeHtml(l.bindHost)}:${escapeHtml(l.bindPort)})</option>`
).join('');
} else {
buildOptionsHtml = '<option value="">' + escapeHtml(c2t('c2.payloads.noListenersOption')) + '</option>';
@@ -3831,7 +3908,7 @@
<div> ${escapeHtml(c2t('c2.payloads.buildSuccessTitle'))}</div>
<div>${escapeHtml(c2t('c2.payloads.buildMetaOsArch', { os: data.payload?.os, arch: data.payload?.arch }))}</div>
<div>${escapeHtml(c2t('c2.payloads.buildSize', { bytes: data.payload?.size_bytes }))}</div>
<button onclick="window.__c2DownloadPayload('${data.payload?.download_path?.split('/').pop()}')"
<button type="button" data-c2-action="payload-download" data-c2-id="${escapeAttr(data.payload?.download_path?.split('/').pop() || '')}"
class="btn-primary" style="margin-top:8px;display:inline-block;cursor:pointer;">${escapeHtml(c2t('c2.payloads.download'))}</button>
</div>
`;
@@ -4213,7 +4290,7 @@
<thead>
<tr>
<th class="c2-events-table-col-check">
<label class="c2-event-check-label" title="${escapeHtml(c2t('c2.events.selectAll'))}">
<label class="c2-event-check-label" title="${escapeAttr(c2t('c2.events.selectAll'))}">
<input type="checkbox" id="c2-events-select-all" onchange="C2.onEventsSelectAll(this.checked)">
</label>
</th>
@@ -4227,8 +4304,10 @@
</tr>
</thead>
<tbody>
${C2.events.map(e => {
const eid = escapeHtml(e.id || '');
${C2.events.map(e => {
const rawId = e.id || '';
const eid = escapeHtml(rawId);
const eidAttr = escapeAttr(rawId);
const levelCls = eventLevelBadgeClass(e.level);
const catCls = eventCategoryBadgeClass(e.category);
const sessionShort = e.sessionId ? escapeHtml(String(e.sessionId).substring(0, 10)) + (String(e.sessionId).length > 10 ? '\u2026' : '') : '';
@@ -4236,20 +4315,20 @@
const msg = escapeHtml(e.message || '');
const rowLevel = escapeHtml(e.level || 'info');
return `
<tr class="c2-events-row c2-events-row--${rowLevel}" data-event-id="${eid}" onclick="C2.viewEvent('${eid}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();C2.viewEvent('${eid}')}" role="button" tabindex="0">
<td class="c2-events-table-col-check" onclick="event.stopPropagation();">
<tr class="c2-events-row c2-events-row--${rowLevel}" data-event-id="${eidAttr}" data-c2-action="event-view" data-c2-id="${eidAttr}" role="button" tabindex="0">
<td class="c2-events-table-col-check" data-c2-stop-action="1">
<label class="c2-event-check-label">
<input type="checkbox" class="c2-event-check" data-id="${eid}" onchange="C2.syncEventsToolbar()">
<input type="checkbox" class="c2-event-check" data-id="${eidAttr}" onchange="C2.syncEventsToolbar()">
</label>
</td>
<td class="c2-events-col-time">${escapeHtml(formatTime(e.createdAt))}</td>
<td><span class="c2-event-level-badge ${levelCls}">${escapeHtml(eventLevelLabel(e.level))}</span></td>
<td><span class="${catCls}">${escapeHtml(eventCategoryLabel(e.category))}</span></td>
<td class="c2-events-col-message" title="${msg}">${msg || dash}</td>
<td class="c2-events-col-mono" title="${escapeHtml(e.sessionId || '')}">${sessionShort || dash}</td>
<td class="c2-events-col-mono" title="${escapeHtml(e.taskId || '')}">${taskShort || dash}</td>
<td class="c2-events-table-col-actions" onclick="event.stopPropagation();">
<button type="button" class="c2-events-delete-btn" data-require-permission="c2:delete" onclick="C2.deleteEventById('${eid}')" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
<td class="c2-events-col-message" title="${escapeAttr(e.message || '')}">${msg || dash}</td>
<td class="c2-events-col-mono" title="${escapeAttr(e.sessionId || '')}">${sessionShort || dash}</td>
<td class="c2-events-col-mono" title="${escapeAttr(e.taskId || '')}">${taskShort || dash}</td>
<td class="c2-events-table-col-actions" data-c2-stop-action="1">
<button type="button" class="c2-events-delete-btn" data-require-permission="c2:delete" data-c2-action="event-delete" data-c2-id="${eidAttr}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
</td>
</tr>`;
}).join('')}
@@ -4364,7 +4443,7 @@
<div class="c2-profile-card">
<div class="c2-profile-header">
<h4>${escapeHtml(p.name)}</h4>
<button class="btn-danger btn-sm" data-require-permission="c2:delete" onclick="C2.deleteProfile('${p.id}')">${escapeHtml(c2t('common.delete'))}</button>
<button class="btn-danger btn-sm" data-require-permission="c2:delete" data-c2-action="profile-delete" data-c2-id="${escapeAttr(p.id)}">${escapeHtml(c2t('common.delete'))}</button>
</div>
<div class="c2-profile-info">
<div><strong>UA:</strong> ${escapeHtml(p.userAgent || defVal)}</div>
@@ -4389,7 +4468,7 @@
<div class="c2-modal-body">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.profiles.profileNameLabel'))}</label>
<input type="text" id="c2-profile-name" class="form-control" placeholder="${escapeHtml(c2t('c2.profiles.placeholderProfileName'))}">
<input type="text" id="c2-profile-name" class="form-control" placeholder="${escapeAttr(c2t('c2.profiles.placeholderProfileName'))}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.profiles.userAgent'))}</label>
+7 -3
View File
@@ -13,6 +13,10 @@ let chatFilesPage = 1;
let chatFilesPageSize = 20;
let chatFilesSearchDebounceTimer = null;
function chatFilesEscapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const CHAT_FILES_GROUP_STORAGE_KEY = 'csai_chat_files_group_by';
const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path';
const CHAT_FILES_PAGE_SIZE_STORAGE_KEY = 'csai_chat_files_page_size';
@@ -1077,7 +1081,7 @@ function renderChatFilesTable() {
<td>${escapeHtml(f.date || '—')}</td>
<td class="chat-files-cell-conv"><code title="${convTitleEsc}">${convEsc}</code></td>
<td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td>
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}${sourceBadge}</td>
<td class="chat-files-cell-name" title="${chatFilesEscapeAttr(pathForTitle)}">${nameEsc}${sourceBadge}</td>
<td>${formatChatFileBytes(f.size || 0)}</td>
<td>${escapeHtml(dt)}</td>
<td class="chat-files-actions">
@@ -1178,7 +1182,7 @@ function renderChatFilesTable() {
? `<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>`
: '';
return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)">
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${escapeHtml(folderTitle)}">
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${chatFilesEscapeAttr(folderTitle)}">
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(folderDisplay.text)}</span></span>
</td>
<td class="chat-files-tree-muted"></td>
@@ -1228,7 +1232,7 @@ function renderChatFilesTable() {
const menuHtml = menuParts.join('');
return `<tr class="chat-files-tr-file">
<td class="chat-files-tree-name-cell" title="${escapeHtml(pathForTitle)}">
<td class="chat-files-tree-name-cell" title="${chatFilesEscapeAttr(pathForTitle)}">
<span class="chat-files-tree-name-inner">${svgFile}<span class="chat-files-tree-name-text">${nameEsc}${sourceBadge}</span></span>
</td>
<td>${formatChatFileBytes(f.size || 0)}</td>
+96 -252
View File
@@ -80,6 +80,7 @@ let chatAttachmentSeq = 0;
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
const AGENT_MODE_CONVERSATION_STORAGE_PREFIX = 'cyberstrike-chat-agent-mode:conversation';
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
@@ -733,6 +734,44 @@ function chatAgentModeNormalizeStored(stored, cfg) {
return CHAT_AGENT_MODE_EINO_SINGLE;
}
function normalizeConversationAgentModeForUI(mode) {
const v = String(mode || '').trim().toLowerCase().replace(/-/g, '_');
if (chatAgentModeIsEinoSingle(v)) return v;
if (chatAgentModeIsEino(v)) {
return multiAgentAPIEnabled ? v : CHAT_AGENT_MODE_EINO_SINGLE;
}
return '';
}
function conversationAgentModeStorageKey(conversationId) {
return `${AGENT_MODE_CONVERSATION_STORAGE_PREFIX}:${String(conversationId || '').trim()}`;
}
function readConversationAgentModePreference(conversationId) {
if (!conversationId) return '';
try {
return normalizeConversationAgentModeForUI(localStorage.getItem(conversationAgentModeStorageKey(conversationId)) || '');
} catch (e) {
return '';
}
}
function saveConversationAgentModePreference(conversationId, mode) {
const normalized = normalizeConversationAgentModeForUI(mode);
if (!conversationId || !normalized) return;
try {
localStorage.setItem(conversationAgentModeStorageKey(conversationId), normalized);
} catch (e) { /* ignore */ }
}
function applyConversationAgentMode(conversationId, conversation) {
const saved = readConversationAgentModePreference(conversationId);
const fromServer = normalizeConversationAgentModeForUI(conversation && (conversation.agentMode || conversation.agent_mode));
const mode = saved || fromServer;
if (!mode) return;
syncAgentModeFromValue(mode);
}
if (typeof window !== 'undefined') {
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
@@ -1098,6 +1137,7 @@ function toggleAgentModePanel() {
function selectAgentMode(mode) {
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
if (!ok) return;
saveConversationAgentModePreference(currentConversationId, mode);
try {
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
} catch (e) { /* ignore */ }
@@ -1351,6 +1391,10 @@ async function sendMessage() {
conversationId: currentConversationId,
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
body.finalization = window.__csNextChatFinalizationPolicy;
window.__csNextChatFinalizationPolicy = null;
}
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
const isStreamStillVisibleForRequest = function () {
if (!document.getElementById(progressId)) return false;
@@ -1405,6 +1449,7 @@ async function sendMessage() {
try {
const modeSel = document.getElementById('agent-mode-select');
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
saveConversationAgentModePreference(streamConversationId || currentConversationId, modeVal);
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
if (useMulti && modeVal) {
@@ -2963,10 +3008,7 @@ function renderProcessDetails(messageId, processDetails, options) {
const renderedMcpIds = collectMcpExecutionIdsFromProcessDetails(processDetails);
if (renderedMcpIds.length > 0) {
setPendingMcpExecutionIds(messageElement, renderedMcpIds);
}
const renderedToolCount = processDetails.filter((d) => d && d.eventType === 'tool_call').length;
if (renderedToolCount > 0) {
setMcpExecutionSummaryCount(messageElement, renderedToolCount);
setMcpExecutionSummaryCount(messageElement, renderedMcpIds.length);
}
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
@@ -3315,7 +3357,6 @@ function prefetchProcessDetailsSummaryHint(messageId, messageElement) {
const j = await res.json().catch(() => ({}));
if (!res.ok || !j.summary) return;
const s = j.summary;
const toolCount = parseInt(s.toolCount, 10) || 0;
const summaryMcpIds = Array.isArray(s.mcpExecutionIds) ? s.mcpExecutionIds : [];
const summaryTools = Array.isArray(s.toolExecutions) ? s.toolExecutions : [];
if (summaryTools.length > 0) {
@@ -3324,7 +3365,11 @@ function prefetchProcessDetailsSummaryHint(messageId, messageElement) {
if (summaryMcpIds.length > 0) {
setPendingMcpExecutionIds(messageElement, summaryMcpIds);
}
const buttonToolCount = summaryTools.length > 0 ? summaryTools.length : (toolCount || summaryMcpIds.length);
const summaryToolExecutionCount = summaryTools
.map(normalizeToolExecutionSummaryForButton)
.filter((item) => item.executionId)
.length;
const buttonToolCount = summaryToolExecutionCount > 0 ? summaryToolExecutionCount : summaryMcpIds.length;
if (buttonToolCount > 0) {
setMcpExecutionSummaryCount(messageElement, buttonToolCount);
}
@@ -3418,7 +3463,9 @@ function getPendingToolExecutionSummaryCount(messageElement) {
}
try {
const tools = JSON.parse(messageElement.dataset.pendingToolExecutionSummaries);
return Array.isArray(tools) ? tools.length : 0;
return Array.isArray(tools)
? tools.map(normalizeToolExecutionSummaryForButton).filter((item) => item.executionId).length
: 0;
} catch (e) {
return 0;
}
@@ -3539,20 +3586,21 @@ function setPendingMcpExecutionIds(messageElement, executionIds) {
function normalizeToolExecutionSummaryForButton(raw) {
const data = raw && typeof raw === 'object' ? raw : {};
return {
toolName: data.toolName || data.name || '',
status: data.status || '',
executionId: data.executionId || '',
toolCallId: data.toolCallId || '',
processDetailId: data.processDetailId || ''
};
return {
toolName: data.toolName || data.name || '',
status: data.status || '',
executionId: data.executionId || '',
toolCallId: data.toolCallId || '',
processDetailId: data.processDetailId || '',
resultDetailId: data.resultDetailId || ''
};
}
function cacheToolExecutionSummaries(messageElement, summaries) {
if (!messageElement || !messageElement.dataset || !Array.isArray(summaries)) return [];
const normalized = summaries
.map(normalizeToolExecutionSummaryForButton)
.filter((item) => item.toolName || item.executionId || item.toolCallId);
.filter((item) => item.executionId);
if (normalized.length > 0) {
messageElement.dataset.toolExecutionSummaries = JSON.stringify(normalized);
} else {
@@ -3712,128 +3760,6 @@ function syncMcpToolsToggleButton(messageElement) {
toolsToggle.innerHTML = '<span>' + formatMcpToolsToggleLabel(count, expanded) + '</span>';
}
function cssEscapeValue(value) {
const s = String(value || '');
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(s);
}
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
async function ensureProcessDetailsForToolFocus(messageElement, anchorId) {
if (!messageElement || !messageElement.id) return null;
const detailsId = 'process-details-' + messageElement.id;
let detailsContainer = document.getElementById(detailsId);
const backendId = messageElement.dataset ? String(messageElement.dataset.backendMessageId || '').trim() : '';
if (detailsContainer && detailsContainer.dataset && detailsContainer.dataset.lazyNotLoaded === '1' && backendId && typeof window.loadProcessDetailsPaginated === 'function') {
await window.loadProcessDetailsPaginated(messageElement.id, backendId, { autoLoadAll: false, anchorId: anchorId || '' });
detailsContainer = document.getElementById(detailsId);
} else if (!detailsContainer && backendId && typeof renderProcessDetails === 'function') {
renderProcessDetails(messageElement.id, null);
detailsContainer = document.getElementById(detailsId);
if (detailsContainer && typeof window.loadProcessDetailsPaginated === 'function') {
await window.loadProcessDetailsPaginated(messageElement.id, backendId, { autoLoadAll: false, anchorId: anchorId || '' });
detailsContainer = document.getElementById(detailsId);
}
}
if (typeof window.expandProcessDetailsTimeline === 'function') {
window.expandProcessDetailsTimeline(messageElement.id);
} else if (typeof toggleProcessDetails === 'function') {
const timeline = detailsContainer && detailsContainer.querySelector('.progress-timeline');
if (!timeline || !timeline.classList.contains('expanded')) {
toggleProcessDetails(null, messageElement.id);
}
}
return document.getElementById(detailsId);
}
async function focusToolExecutionInProcessDetails(messageElement, summary, index) {
const item = normalizeToolExecutionSummaryForButton(summary);
let detailsContainer = await ensureProcessDetailsForToolFocus(messageElement, item.processDetailId || '');
let timeline = detailsContainer && detailsContainer.querySelector('.progress-timeline');
if (!timeline) return;
let target = null;
if (item.processDetailId) {
target = timeline.querySelector('[data-process-detail-id="' + cssEscapeValue(item.processDetailId) + '"]');
}
if (!target && item.toolCallId) {
target = timeline.querySelector('[data-tool-call-id="' + cssEscapeValue(item.toolCallId) + '"]');
}
if (!target) {
const toolItems = timeline.querySelectorAll('.timeline-item-tool_call');
target = toolItems[index] || null;
}
if (!target && item.processDetailId && messageElement.dataset && messageElement.dataset.backendMessageId && typeof window.loadProcessDetailsPaginated === 'function') {
await window.loadProcessDetailsPaginated(messageElement.id, messageElement.dataset.backendMessageId, {
autoLoadAll: false,
anchorId: item.processDetailId
});
detailsContainer = document.getElementById('process-details-' + messageElement.id);
timeline = detailsContainer && detailsContainer.querySelector('.progress-timeline');
target = timeline ? timeline.querySelector('[data-process-detail-id="' + cssEscapeValue(item.processDetailId) + '"]') : null;
}
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.remove('timeline-item-focus-pulse');
void target.offsetWidth;
target.classList.add('timeline-item-focus-pulse');
setTimeout(() => {
target.classList.remove('timeline-item-focus-pulse');
}, 2200);
}
async function findToolExecutionTimelineItem(messageElement, summary, index) {
const item = normalizeToolExecutionSummaryForButton(summary);
let detailsContainer = await ensureProcessDetailsForToolFocus(messageElement, item.processDetailId || '');
let timeline = detailsContainer && detailsContainer.querySelector('.progress-timeline');
if (!timeline) return null;
let target = null;
if (item.processDetailId) {
target = timeline.querySelector('[data-process-detail-id="' + cssEscapeValue(item.processDetailId) + '"]');
}
if (!target && item.toolCallId) {
target = timeline.querySelector('[data-tool-call-id="' + cssEscapeValue(item.toolCallId) + '"]');
}
if (!target && item.processDetailId && messageElement.dataset && messageElement.dataset.backendMessageId && typeof window.loadProcessDetailsPaginated === 'function') {
await window.loadProcessDetailsPaginated(messageElement.id, messageElement.dataset.backendMessageId, {
autoLoadAll: false,
anchorId: item.processDetailId
});
detailsContainer = document.getElementById('process-details-' + messageElement.id);
timeline = detailsContainer && detailsContainer.querySelector('.progress-timeline');
target = timeline ? timeline.querySelector('[data-process-detail-id="' + cssEscapeValue(item.processDetailId) + '"]') : null;
}
return target;
}
async function resolveToolExecutionSummaryForFocus(messageElement, executionId, index) {
const wantedExecutionId = executionId == null ? '' : String(executionId).trim();
let summaries = getCachedToolExecutionSummaries(messageElement);
let item = wantedExecutionId
? summaries.find((summary) => summary.executionId === wantedExecutionId)
: summaries[index];
if (item && (item.processDetailId || item.toolCallId)) return item;
const backendId = messageElement && messageElement.dataset
? String(messageElement.dataset.backendMessageId || '').trim()
: '';
if (!backendId || typeof apiFetch !== 'function') return item || null;
try {
const res = await apiFetch('/api/messages/' + encodeURIComponent(backendId) + '/process-details?summary=1');
const payload = await res.json().catch(() => ({}));
if (!res.ok || !payload.summary || !Array.isArray(payload.summary.toolExecutions)) {
return item || null;
}
summaries = cacheToolExecutionSummaries(messageElement, payload.summary.toolExecutions);
item = wantedExecutionId
? summaries.find((summary) => summary.executionId === wantedExecutionId)
: summaries[index];
return item || null;
} catch (e) {
return item || null;
}
}
function toggleMcpToolList(assistantMessageId) {
const messageEl = document.getElementById(assistantMessageId);
if (!messageEl) return;
@@ -3845,9 +3771,7 @@ function toggleMcpToolList(assistantMessageId) {
!getPendingToolExecutionSummaryCount(messageEl) &&
!toolList.querySelector('.mcp-detail-btn')
) {
if (typeof toggleProcessDetails === 'function') {
toggleProcessDetails(null, assistantMessageId);
}
syncMcpToolsToggleButton(messageEl);
return;
}
const willExpand = !toolList.classList.contains('expanded');
@@ -3869,115 +3793,15 @@ window.setMcpExecutionSummaryCount = setMcpExecutionSummaryCount;
window.setPendingMcpExecutionIds = setPendingMcpExecutionIds;
window.setPendingToolExecutionSummaries = setPendingToolExecutionSummaries;
async function fetchProcessDetailDataForModal(detailId) {
const id = detailId != null ? String(detailId).trim() : '';
if (!id || typeof apiFetch !== 'function') return null;
const res = await apiFetch('/api/process-details/' + encodeURIComponent(id));
const j = await res.json().catch(() => ({}));
if (!res.ok) return null;
const detail = j && j.processDetail ? j.processDetail : null;
return detail && detail.data ? detail.data : null;
}
function processToolResultTextFromData(resultData) {
if (!resultData) return '';
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
const result = resultData.result != null
? resultData.result
: (resultData.error != null ? resultData.error : (resultData.resultPreview != null ? resultData.resultPreview : noResultText));
return typeof result === 'string' ? result : JSON.stringify(result);
}
function processToolResultToMCPResult(resultData, rawText) {
if (!resultData) return null;
const displayState = typeof window.getToolResultDisplayState === 'function'
? window.getToolResultDisplayState(resultData, { rawText: rawText })
: { kind: ((resultData.isError || resultData.success === false) ? 'error' : 'success'), isError: !!(resultData.isError || resultData.success === false) };
const isError = !!displayState.isError;
let text = rawText;
if (text == null || String(text) === '') {
text = processToolResultTextFromData(resultData);
}
return {
content: [{ type: 'text', text: String(text || '') }],
isError: isError
};
}
async function showMCPDetailFromProcessToolItem(messageElement, summary, index) {
const target = await findToolExecutionTimelineItem(messageElement, summary, index);
if (!target) {
alert(typeof window.t === 'function'
? window.t('chat.toolExecutionDetailPending')
: '工具执行详情尚未同步,请稍后重试。');
return;
}
const state = typeof window.getToolCallDetailState === 'function'
? window.getToolCallDetailState(target)
: {};
let args = state.args;
let resultData = state.resultData;
let rawText = state.rawText;
if ((!args || Object.keys(args).length === 0) && state.processDetailId) {
const fullCall = await fetchProcessDetailDataForModal(state.processDetailId);
if (fullCall) {
args = typeof window.parseToolCallArgsFromData === 'function'
? window.parseToolCallArgsFromData(fullCall)
: (fullCall.argumentsObj || {});
}
}
const resultDetailId = state.resultDetailId || target.dataset.toolResultDetailId || '';
const resultPayloadDeferred = resultData && resultData._payloadDeferred === true;
const resultOnlyHasPreview = resultData && resultData.result == null && resultData.error == null && resultData.resultPreview != null;
if (resultDetailId && (!resultData || resultPayloadDeferred || resultOnlyHasPreview)) {
const fullResult = await fetchProcessDetailDataForModal(resultDetailId);
if (fullResult) {
resultData = fullResult;
rawText = processToolResultTextFromData(fullResult);
}
}
const toolName = (summary && summary.toolName) || target.dataset.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const displayState = resultData && typeof window.getToolResultDisplayState === 'function'
? window.getToolResultDisplayState(resultData, { rawText: rawText })
: null;
const backgroundRunning = (displayState && displayState.kind === 'background_running') || target.dataset.toolDisplayStatus === 'background_running';
const success = resultData ? !(displayState ? displayState.isError : (resultData.isError || resultData.success === false)) : target.dataset.toolSuccess !== '0';
const status = backgroundRunning
? 'background_running'
: (resultData || target.classList.contains('tool-call-completed') || target.classList.contains('tool-call-failed')
? (success ? 'completed' : 'failed')
: 'running');
const exec = {
id: (resultData && resultData.executionId) || (summary && summary.executionId) || '',
toolName: toolName,
status: status,
startTime: target.dataset.createdAtIso || '',
arguments: args || {},
result: processToolResultToMCPResult(resultData, rawText),
error: resultData && resultData.error ? String(resultData.error) : ''
};
openAppModal('mcp-detail-modal', { focus: false });
deferModalContent(function () {
renderMCPDetailModal(exec);
});
}
async function openTaskToolExecutionDetail(messageElement, item, index) {
let detailItem = item;
if (!detailItem.executionId) {
const refreshedItem = await resolveToolExecutionSummaryForFocus(messageElement, '', index);
detailItem = refreshedItem || detailItem;
}
if (detailItem.executionId) {
await showMCPDetail(detailItem.executionId);
return;
}
await showMCPDetailFromProcessToolItem(messageElement, detailItem, index);
const detailItem = normalizeToolExecutionSummaryForButton(item);
if (!detailItem.executionId) return;
await showMCPDetail(detailItem.executionId);
}
/**
* 声明式渲染工具调用列表
* 过程摘要提供完整展示入口executionIds 只补充可直接打开监控详情的 ID
* 工具详情只以 executionId 作为稳定入口缺少 executionId 的历史摘要不渲染为工具详情入口
* 每次更新整体替换列表避免增量追加产生双重状态
*/
function renderMcpCallButtons(messageElement) {
@@ -3987,7 +3811,8 @@ function renderMcpCallButtons(messageElement) {
const toolList = chrome.toolList;
const executionIds = getCachedMcpExecutionIds(messageElement);
const summaries = getCachedToolExecutionSummaries(messageElement);
const items = selectToolExecutionSummariesForButtons(summaries, executionIds);
const items = selectToolExecutionSummariesForButtons(summaries, executionIds)
.filter((item) => item && item.executionId);
const renderVersion = String((parseInt(toolList.dataset.renderVersion, 10) || 0) + 1);
toolList.dataset.renderVersion = renderVersion;
@@ -4845,6 +4670,7 @@ async function loadConversation(conversationId) {
if (typeof window.setCurrentRole === 'function') {
window.setCurrentRole(conversationRoleName || '默认');
}
applyConversationAgentMode(conversationId, conversation);
try {
window.currentConversationId = conversationId;
} catch (e) { /* ignore */ }
@@ -8669,7 +8495,14 @@ function createConversationListItemWithMenu(conversation, isPinned) {
if (group) {
const groupTag = document.createElement('div');
groupTag.className = 'conversation-group-tag';
groupTag.innerHTML = `<span class="group-tag-icon">${group.icon || '📁'}</span><span class="group-tag-name">${group.name}</span>`;
const groupTagIcon = document.createElement('span');
groupTagIcon.className = 'group-tag-icon';
groupTagIcon.textContent = group.icon || '📁';
const groupTagName = document.createElement('span');
groupTagName.className = 'group-tag-name';
groupTagName.textContent = group.name;
groupTag.appendChild(groupTagIcon);
groupTag.appendChild(groupTagName);
groupTag.title = `分组: ${group.name}`;
contentWrapper.appendChild(groupTag);
}
@@ -9218,12 +9051,23 @@ async function showMoveToGroupSubmenu() {
const item = document.createElement('div');
item.className = 'context-submenu-item';
item.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span>${group.name}</span>
`;
const folderIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
folderIcon.setAttribute('width', '16');
folderIcon.setAttribute('height', '16');
folderIcon.setAttribute('viewBox', '0 0 24 24');
folderIcon.setAttribute('fill', 'none');
folderIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
const folderPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
folderPath.setAttribute('d', 'M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z');
folderPath.setAttribute('stroke', 'currentColor');
folderPath.setAttribute('stroke-width', '2');
folderPath.setAttribute('stroke-linecap', 'round');
folderPath.setAttribute('stroke-linejoin', 'round');
folderIcon.appendChild(folderPath);
const label = document.createElement('span');
label.textContent = group.name;
item.appendChild(folderIcon);
item.appendChild(label);
item.onclick = () => {
moveConversationToGroup(contextMenuConversationId, group.id);
};
+8 -3
View File
@@ -166,6 +166,10 @@ if (typeof escapeHtml === 'undefined') {
}
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function getFofaFormElements() {
return {
query: document.getElementById('fofa-query'),
@@ -1115,11 +1119,11 @@ function renderFofaResults(payload) {
if (f === 'host') {
const href = normalizeHttpLink(text);
if (href) {
const safeHref = escapeHtml(href);
return `<td class="info-collect-cell" data-field="${escapeHtml(f)}" data-full="${escapeHtml(text)}" title="${escapeHtml(text)}"><a class="info-collect-link" href="${safeHref}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation();">${escapeHtml(text)}</a></td>`;
const safeHref = escapeAttr(href);
return `<td class="info-collect-cell" data-field="${escapeAttr(f)}" data-full="${escapeAttr(text)}" title="${escapeAttr(text)}"><a class="info-collect-link" href="${safeHref}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation();">${escapeHtml(text)}</a></td>`;
}
}
return `<td class="info-collect-cell" data-field="${escapeHtml(f)}" data-full="${escapeHtml(text)}" title="${escapeHtml(text)}"><span class="info-collect-cell-text">${escapeHtml(text)}</span></td>`;
return `<td class="info-collect-cell" data-field="${escapeAttr(f)}" data-full="${escapeAttr(text)}" title="${escapeAttr(text)}"><span class="info-collect-cell-text">${escapeHtml(text)}</span></td>`;
}).join('');
const actionHtml = `
@@ -1334,6 +1338,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
}
if (autoSend) {
if (typeof sendMessage === 'function') {
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
sendMessage();
} else {
alert(_t('infoCollect.noSendMessage'));
+21 -10
View File
@@ -194,7 +194,7 @@ function renderKnowledgeItemsByCategories(categoriesWithItems) {
const categoryCount = categoryData.itemCount || categoryItems.length;
html += `
<div class="knowledge-category-section" data-category="${escapeHtml(category)}">
<div class="knowledge-category-section" data-category="${escapeAttr(category)}">
<div class="knowledge-category-header">
<div class="knowledge-category-info">
<h3 class="knowledge-category-title">${escapeHtml(category)}</h3>
@@ -245,7 +245,7 @@ function renderKnowledgeItems(items) {
const categoryCount = categoryItems.length;
html += `
<div class="knowledge-category-section" data-category="${escapeHtml(category)}">
<div class="knowledge-category-section" data-category="${escapeAttr(category)}">
<div class="knowledge-category-header">
<div class="knowledge-category-info">
<h3 class="knowledge-category-title">${escapeHtml(category)}</h3>
@@ -347,10 +347,10 @@ function renderKnowledgeItemCard(item) {
}
return `
<div class="knowledge-item-card" data-id="${item.id}" data-category="${escapeHtml(item.category)}">
<div class="knowledge-item-card" data-id="${escapeAttr(item.id)}" data-category="${escapeAttr(item.category)}">
<div class="knowledge-item-card-header">
<div class="knowledge-item-card-title-row">
<h4 class="knowledge-item-card-title" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</h4>
<h4 class="knowledge-item-card-title" title="${escapeAttr(item.title)}">${escapeHtml(item.title)}</h4>
<div class="knowledge-item-card-actions">
<button class="knowledge-item-action-btn" data-require-permission="knowledge:write" onclick="editKnowledgeItem('${item.id}')" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1435,13 +1435,13 @@ function renderRetrievalLogs(logs) {
${log.conversationId ? `
<div class="retrieval-log-detail-item">
<span class="detail-label">${_t('retrievalLogs.conversationId')}</span>
<code class="detail-value" title="${_t('retrievalLogs.clickToCopy')}" data-copy-title-copied="${_t('common.copied')}" data-copy-title-click="${_t('retrievalLogs.clickToCopy')}" onclick="var t=this; navigator.clipboard.writeText('${escapeHtml(log.conversationId)}').then(function(){ t.title=t.getAttribute('data-copy-title-copied')||'Copied!'; setTimeout(function(){ t.title=t.getAttribute('data-copy-title-click')||'Click to copy'; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.conversationId)}</code>
<code class="detail-value" title="${escapeAttr(_t('retrievalLogs.clickToCopy'))}" data-copy-title-copied="${escapeAttr(_t('common.copied'))}" data-copy-title-click="${escapeAttr(_t('retrievalLogs.clickToCopy'))}" onclick="var t=this; navigator.clipboard.writeText(${escapeJsStringAttr(log.conversationId)}).then(function(){ t.title=t.getAttribute('data-copy-title-copied')||'Copied!'; setTimeout(function(){ t.title=t.getAttribute('data-copy-title-click')||'Click to copy'; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.conversationId)}</code>
</div>
` : ''}
${log.messageId ? `
<div class="retrieval-log-detail-item">
<span class="detail-label">${_t('retrievalLogs.messageId')}</span>
<code class="detail-value" title="${_t('retrievalLogs.clickToCopy')}" data-copy-title-copied="${_t('common.copied')}" data-copy-title-click="${_t('retrievalLogs.clickToCopy')}" onclick="var el=this; navigator.clipboard.writeText('${escapeHtml(log.messageId)}').then(function(){ el.title=el.getAttribute('data-copy-title-copied')||el.title; setTimeout(function(){ el.title=el.getAttribute('data-copy-title-click')||el.title; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.messageId)}</code>
<code class="detail-value" title="${escapeAttr(_t('retrievalLogs.clickToCopy'))}" data-copy-title-copied="${escapeAttr(_t('common.copied'))}" data-copy-title-click="${escapeAttr(_t('retrievalLogs.clickToCopy'))}" onclick="var el=this; navigator.clipboard.writeText(${escapeJsStringAttr(log.messageId)}).then(function(){ el.title=el.getAttribute('data-copy-title-copied')||el.title; setTimeout(function(){ el.title=el.getAttribute('data-copy-title-click')||el.title; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.messageId)}</code>
</div>
` : ''}
<div class="retrieval-log-detail-item">
@@ -1470,7 +1470,7 @@ function renderRetrievalLogs(logs) {
</svg>
${_t('retrievalLogs.viewDetails')}
</button>
<button class="btn-secondary btn-sm retrieval-log-delete-btn" onclick="deleteRetrievalLog('${escapeHtml(log.id)}', ${index})" style="margin-top: 12px; margin-left: 8px; display: inline-flex; align-items: center; gap: 4px; color: var(--error-color, #dc3545); border-color: var(--error-color, #dc3545);" onmouseover="this.style.backgroundColor='rgba(220, 53, 69, 0.1)'; this.style.color='#dc3545';" onmouseout="this.style.backgroundColor=''; this.style.color='var(--error-color, #dc3545)';" title="${_t('common.delete')}">
<button class="btn-secondary btn-sm retrieval-log-delete-btn" onclick="deleteRetrievalLog(${escapeJsStringAttr(log.id)}, ${index})" style="margin-top: 12px; margin-left: 8px; display: inline-flex; align-items: center; gap: 4px; color: var(--error-color, #dc3545); border-color: var(--error-color, #dc3545);" onmouseover="this.style.backgroundColor='rgba(220, 53, 69, 0.1)'; this.style.color='#dc3545';" onmouseout="this.style.backgroundColor=''; this.style.color='var(--error-color, #dc3545)';" title="${escapeAttr(_t('common.delete'))}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1910,7 +1910,7 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.conversationId')}</div>
<code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;"
onclick="navigator.clipboard.writeText('${escapeHtml(log.conversationId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
onclick="navigator.clipboard.writeText(${escapeJsStringAttr(log.conversationId)}); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
title="点击复制">${escapeHtml(log.conversationId)}</code>
</div>
` : ''}
@@ -1918,7 +1918,7 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.messageId')}</div>
<code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;"
onclick="navigator.clipboard.writeText('${escapeHtml(log.messageId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
onclick="navigator.clipboard.writeText(${escapeJsStringAttr(log.messageId)}); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
title="点击复制">${escapeHtml(log.messageId)}</code>
</div>
` : ''}
@@ -2006,6 +2006,18 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
function formatTime(timeStr) {
if (!timeStr) return '';
@@ -2319,4 +2331,3 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
});
+158 -27
View File
@@ -198,6 +198,92 @@ function resolveFinalAssistantResponseText(finalMessage, streamState) {
return finalMessage;
}
function isFinalizedResponseData(data) {
return !!(data && data.finalized === true);
}
function hasFinalizationContract(data) {
if (!data || typeof data !== 'object') return false;
return Object.prototype.hasOwnProperty.call(data, 'finalized')
|| Object.prototype.hasOwnProperty.call(data, 'finalizable')
|| Object.prototype.hasOwnProperty.call(data, 'completionReason')
|| Object.prototype.hasOwnProperty.call(data, 'evidenceVerified')
|| Object.prototype.hasOwnProperty.call(data, 'missingChecks');
}
function finalizationCheckTitle(data) {
return isFinalizedResponseData(data) ? '最终回复检查通过' : '最终回复检查未通过';
}
function finalizationReasonLabel(reason, status) {
const key = String(reason || status || '').trim();
const labels = {
pending_tool_executions: '等待工具执行完成',
missing_execution_evidence: '缺少完成态证据',
awaiting_hitl: '等待人工确认',
empty_response: '未捕获到有效回复',
missing_finalization_contract: '缺少最终化证明',
in_progress: '仍在验证',
blocked: '检查未通过',
failed: '任务失败',
cancelled: '任务已取消',
verified: '已验证'
};
return labels[key] || key || '检查未通过';
}
function finalizationMissingCheckLabel(check) {
const s = String(check || '').trim();
if (!s) return '';
if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束';
if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录';
if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认';
if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本';
if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', '');
return s;
}
function compactStringList(values, limit) {
const arr = Array.isArray(values) ? values.filter(Boolean).map(String) : [];
const max = limit || 3;
if (arr.length <= max) return arr;
return arr.slice(0, max).concat('另 ' + (arr.length - max) + ' 项');
}
function finalizationNoticeMarkdown(responseData, eventMessage) {
const hasContract = hasFinalizationContract(responseData);
const reason = hasContract
? finalizationReasonLabel(responseData && responseData.completionReason, responseData && responseData.status)
: finalizationReasonLabel('missing_finalization_contract');
const lines = ['**仍在验证,暂不生成最终结论**', '', '状态:' + reason];
const pending = compactStringList(responseData && responseData.pendingExecutionIds, 3);
if (pending.length) {
lines.push('待完成工具:`' + pending.join('`, `') + '`');
}
const rawMissingChecks = responseData && responseData.missingChecks;
const missingChecks = Array.isArray(rawMissingChecks)
? rawMissingChecks
: (rawMissingChecks ? [rawMissingChecks] : []);
const missing = compactStringList(missingChecks.map(finalizationMissingCheckLabel).filter(Boolean), 3);
if (missing.length) {
lines.push('待完成检查:' + missing.join(''));
}
if (!hasContract && eventMessage != null && String(eventMessage).trim() !== '') {
lines.push('', '候选输出已移入过程详情,避免误判为最终结论。');
}
return lines.join('\n');
}
function markAssistantFinalizationState(assistantMessageId, responseData) {
const assistantElement = document.getElementById(assistantMessageId);
if (!assistantElement) return;
const finalized = isFinalizedResponseData(responseData);
assistantElement.dataset.finalized = finalized ? 'true' : 'false';
assistantElement.dataset.finalizationStatus = responseData && responseData.status ? String(responseData.status) : '';
assistantElement.classList.toggle('assistant-finalized', finalized);
assistantElement.classList.toggle('assistant-not-finalized', !finalized);
}
/**
* 主通道 response 结束时将流式占位条目固化为 planning与后端 flushResponsePlan 落库类型一致
* 避免 integrateProgressToMCPSection 快照前删除占位导致助手输出仅刷新后才出现
@@ -478,6 +564,18 @@ function escapeHtmlLocal(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttrLocal(text) {
return escapeHtmlLocal(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttrLocal(escapeJsString(text));
}
function formatTimelinePlainTextHtml(text) {
return '<pre class="timeline-plain-text">' + escapeHtml(text == null ? '' : String(text)) + '</pre>';
}
@@ -2440,6 +2538,26 @@ function handleStreamEvent(event, progressElement, progressId,
});
break;
case 'finalization_check':
const finalizationCheckData = event.data || {};
const finalizationCheckPassed = isFinalizedResponseData(finalizationCheckData);
addTimelineItem(timeline, 'finalization_check', {
title: finalizationCheckTitle(finalizationCheckData),
message: finalizationCheckPassed ? (event.message || '最终回复检查通过。') : finalizationNoticeMarkdown(finalizationCheckData, event.message),
data: event.data,
expanded: !finalizationCheckPassed
});
break;
case 'finalization_auto_continue':
addTimelineItem(timeline, 'progress', {
title: '继续验证',
message: event.message,
data: event.data,
expanded: false
});
break;
case 'hitl_interrupt':
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
if (hitlTargetItem && hitlTargetItem.id) {
@@ -2958,7 +3076,12 @@ function handleStreamEvent(event, progressElement, progressId,
const streamState = responseStreamStateByProgressId.get(progressId);
const existingAssistantId = streamState?.assistantId || getAssistantId();
let assistantIdFinal = existingAssistantId;
const bubbleText = resolveFinalAssistantResponseText(event.message, streamState);
const responseFinalized = isFinalizedResponseData(responseData);
const responseHasFinalizationContract = hasFinalizationContract(responseData);
const resolvedResponseText = resolveFinalAssistantResponseText(event.message, streamState);
const bubbleText = responseFinalized
? resolvedResponseText
: finalizationNoticeMarkdown(responseData, event.message);
if (!assistantIdFinal) {
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
@@ -2967,11 +3090,12 @@ function handleStreamEvent(event, progressElement, progressId,
setAssistantId(assistantIdFinal);
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
}
markAssistantFinalizationState(assistantIdFinal, responseData);
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
if (streamState && streamState.itemId) {
finalizeMainResponseStreamItem(streamState, event.message, responseData);
} else if (timeline && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
finalizeMainResponseStreamItem(streamState, responseFinalized ? event.message : '', responseData);
} else if (timeline && responseFinalized && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
addTimelineItem(timeline, 'planning', {
title: typeof einoMainStreamPlanningTitle === 'function'
? einoMainStreamPlanningTitle(responseData)
@@ -2980,6 +3104,13 @@ function handleStreamEvent(event, progressElement, progressId,
data: responseData,
expanded: false
});
} else if (timeline && !responseFinalized && !responseHasFinalizationContract && resolvedResponseText && String(resolvedResponseText).trim()) {
addTimelineItem(timeline, 'finalization_check', {
title: '候选输出缺少最终化证明',
message: resolvedResponseText,
data: Object.assign({}, responseData, { missingFinalizationContract: true }),
expanded: true
});
}
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
@@ -5562,7 +5693,7 @@ function updateMonitorTimelineSection() {
}
const MCP_STATS_TOP_N = 3;
const MCP_STATS_TOP_N = 6;
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
function getMcpMonitorTimelineRange() {
@@ -5667,7 +5798,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
const isPeak = c.i === peakIdx && (c.p.total || 0) > 0;
const dotClass = 'mcp-stats-timeline-dot' + (isPeak ? ' mcp-stats-timeline-dot--peak' : '');
return `<circle class="${dotClass}" cx="${c.x.toFixed(2)}" cy="${c.y.toFixed(2)}" r="${isPeak ? 2 : 1.5}"
data-time="${escapeHtml(tipTime)}"
data-time="${escapeAttrLocal(tipTime)}"
data-total="${c.p.total || 0}"
data-failed="${c.p.failed || 0}" />`;
}).join('');
@@ -5681,9 +5812,9 @@ function buildMcpTimelineSvg(points, rangeKey) {
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
return `<g class="mcp-stats-timeline-bar-group">
<rect class="mcp-stats-timeline-bar${total > 0 ? ' is-active' : ''}" x="${(c.x - barW / 2).toFixed(2)}" y="${y.toFixed(2)}" width="${barW.toFixed(2)}" height="${h.toFixed(2)}" rx="1.6"
data-time="${escapeHtml(tipTime)}" data-total="${total}" data-failed="${failed}" />
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" />
${failedH > 0 ? `<rect class="mcp-stats-timeline-bar-fail" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${failedH.toFixed(2)}" rx="1.6"
data-time="${escapeHtml(tipTime)}" data-total="${total}" data-failed="${failed}" />` : ''}
data-time="${escapeAttrLocal(tipTime)}" data-total="${total}" data-failed="${failed}" />` : ''}
</g>`;
}).join('');
@@ -6302,11 +6433,11 @@ function renderMcpStatsInsightPanel(topTools, totals, activeToolFilter = '', opt
|| `${s.name}${s.calls} 次调用,占 ${s.pct}%`;
return `<li class="mcp-stats-dist-legend-item-wrap">
<button type="button" class="mcp-stats-dist-legend-item${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(s.name)}"
data-tool-name="${escapeAttrLocal(s.name)}"
data-pct="${s.pct}"
data-calls="${s.calls}"
data-is-others="0"
aria-label="${escapeHtml(rowAria)}"
aria-label="${escapeAttrLocal(rowAria)}"
aria-pressed="${isActive ? 'true' : 'false'}">${inner}</button>
</li>`;
}).join('');
@@ -6333,8 +6464,8 @@ function renderMcpStatsInsightPanel(topTools, totals, activeToolFilter = '', opt
</div>`;
return `
<div class="mcp-stats-dist-panel${embedded ? ' mcp-stats-dist-panel--embedded' : ''}" aria-label="${escapeHtml(distTitle)}"
data-center-label="${escapeHtml(centerLabel)}"
<div class="mcp-stats-dist-panel${embedded ? ' mcp-stats-dist-panel--embedded' : ''}" aria-label="${escapeAttrLocal(distTitle)}"
data-center-label="${escapeAttrLocal(centerLabel)}"
data-center-value="${top6SharePct}"
data-center-suffix="%">
${headerHtml}
@@ -6545,13 +6676,13 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
|| `${name}${total} 次调用,成功率 ${toolRate}%`;
rowsHtml += `
<tr class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(rawName)}"
data-tool-name="${escapeAttrLocal(rawName)}"
tabindex="0"
role="button"
aria-label="${escapeHtml(rowAria)}"
aria-label="${escapeAttrLocal(rowAria)}"
aria-pressed="${isActive ? 'true' : 'false'}">
<td class="col-rank"><span class="mcp-stats-rank${rankClass}">${index + 1}</span></td>
<td class="col-tool" title="${escapeHtml(name)}">
<td class="col-tool" title="${escapeAttrLocal(name)}">
<span class="mcp-stats-tool-dot" style="background:${dotColor}" aria-hidden="true"></span>
<span class="mcp-stats-tool-label">${escapeHtml(name)}</span>
</td>
@@ -6600,8 +6731,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const segAria = mcpMonitorT('distSegmentAria', { name: displayName, pct: s.pct, calls: s.calls })
|| `${displayName},占 ${s.pct}%${s.calls}`;
return `<span class="mcp-stats-proportion-seg${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(s.name)}" data-pct="${s.pct}" data-calls="${s.calls}" data-is-others="0"
role="button" tabindex="0" aria-label="${escapeHtml(segAria)}"
data-tool-name="${escapeAttrLocal(s.name)}" data-pct="${s.pct}" data-calls="${s.calls}" data-is-others="0"
role="button" tabindex="0" aria-label="${escapeAttrLocal(segAria)}"
style="flex:${s.pctNum} 1 0;background:${s.color}" title="${escapeHtml(title)}"></span>`;
}).join('');
@@ -6629,12 +6760,12 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const successLabel = mcpMonitorT('successCount', { n: success }) || `成功 ${success}`;
const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`;
return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(rawName)}" tabindex="0" role="button"
aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
data-tool-name="${escapeAttrLocal(rawName)}" tabindex="0" role="button"
aria-label="${escapeAttrLocal(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
<div class="mcp-stats-tool-item__top">
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
<span class="mcp-stats-tool-item__name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
<span class="mcp-stats-tool-item__name" title="${escapeAttrLocal(name)}">${escapeHtml(name)}</span>
<span class="mcp-stats-tool-item__share">${sharePct}%</span>
</div>
<div class="mcp-stats-tool-item__middle">
@@ -6694,8 +6825,8 @@ function renderMcpStatsChartAside(topTools, totals, activeToolFilter = '') {
return `
<div class="mcp-stats-dist-panel mcp-stats-dist-panel--compact"
aria-label="${escapeHtml(distTitle)}"
data-center-label="${escapeHtml(centerLabel)}"
aria-label="${escapeAttrLocal(distTitle)}"
data-center-label="${escapeAttrLocal(centerLabel)}"
data-center-value="${top6SharePct}"
data-center-suffix="%">
<p class="mcp-stats-panel__aside-title">${escapeHtml(distTitle)}</p>
@@ -6856,11 +6987,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const duration = formatExecutionDuration(exec.startTime, exec.endTime);
const toolName = escapeHtml(formatMonitorToolName(exec.toolName) || unknownToolLabel);
const rawExecId = exec.id || '';
const executionId = escapeHtml(rawExecId);
const executionId = escapeAttrLocal(rawExecId);
const jsExecId = escapeJsStringAttr(rawExecId);
const terminateBtn = status === 'running'
? `<button type="button" class="btn-secondary btn-monitor-abort" data-require-permission="monitor:write" onclick="cancelMCPToolExecution('${rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')">${escapeHtml(terminateLabel)}</button>`
? `<button type="button" class="btn-secondary btn-monitor-abort" data-require-permission="monitor:write" onclick="cancelMCPToolExecution(${jsExecId})">${escapeHtml(terminateLabel)}</button>`
: '';
const jsExecId = rawExecId.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
const isSelected = monitorState.selectedExecutions.has(rawExecId);
const rowKey = monitorRenderKey([exec, isSelected, locale || 'en-US']);
return {
@@ -6869,7 +7000,7 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
html: `
<tr data-execution-id="${executionId}">
<td>
<input type="checkbox" class="monitor-execution-checkbox theme-checkbox" value="${executionId}" ${isSelected ? 'checked' : ''} onchange="toggleExecutionSelection('${jsExecId}', this.checked)" />
<input type="checkbox" class="monitor-execution-checkbox theme-checkbox" value="${executionId}" ${isSelected ? 'checked' : ''} onchange="toggleExecutionSelection(${jsExecId}, this.checked)" />
</td>
<td>${toolName}</td>
<td><span class="${statusClass}">${escapeHtml(statusLabel)}</span></td>
@@ -6877,9 +7008,9 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
<td class="monitor-execution-duration">${escapeHtml(duration)}</td>
<td>
<div class="monitor-execution-actions">
<button class="btn-secondary" onclick="showMCPDetail('${executionId}')">${escapeHtml(viewDetailLabel)}</button>
<button class="btn-secondary" onclick="showMCPDetail(${jsExecId})">${escapeHtml(viewDetailLabel)}</button>
${terminateBtn}
<button class="btn-secondary btn-delete" data-require-permission="monitor:delete" onclick="deleteExecution('${executionId}')" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
<button class="btn-secondary btn-delete" data-require-permission="monitor:delete" onclick="deleteExecution(${jsExecId})" title="${escapeHtml(deleteExecTitle)}">${escapeHtml(deleteLabel)}</button>
</div>
</td>
</tr>
+16 -4
View File
@@ -897,12 +897,12 @@ function renderProjectsSidebar() {
p.pinned ? `<span class="projects-list-item-badge">${escapeHtml(tp('projects.pinned'))}</span>` : '',
p.status === 'archived' ? `<span class="projects-list-item-badge">${escapeHtml(tp('projects.archived'))}</span>` : '',
].join('');
return `<div class="projects-list-item${active}${archived}" data-id="${escapeHtml(p.id)}" onclick="selectProject('${escapeHtml(p.id)}')">
return `<div class="projects-list-item${active}${archived}" data-id="${escapeAttr(p.id)}" onclick="selectProject(${escapeJsStringAttr(p.id)})">
<div class="projects-list-item-body">
<div class="projects-list-item-name">${escapeHtml(p.name)}${badges}</div>
<div class="projects-list-item-meta">${formatProjectTime(p.updated_at)}</div>
</div>
<button type="button" class="projects-list-item-menu" title="${escapeHtml(tp('projects.projectActions'))}" aria-label="${escapeHtml(tp('projects.projectActions'))}" onclick="showProjectListActionMenu(event, '${escapeHtml(p.id)}')"></button>
<button type="button" class="projects-list-item-menu" title="${escapeHtml(tp('projects.projectActions'))}" aria-label="${escapeHtml(tp('projects.projectActions'))}" onclick="showProjectListActionMenu(event, ${escapeJsStringAttr(p.id)})"></button>
</div>`;
}).join('');
updateProjectsDetailVisibility();
@@ -1324,8 +1324,8 @@ function renderGraphEdgesListHtml(factKey, graphData, selectedEdgeId) {
const synthetic = isSyntheticGraphEdge(e);
const deleteBtn = synthetic
? `<span class="project-fact-graph-edge-synthetic" title="${escapeHtml(tp('projects.graphEdgeSynthetic'))}">—</span>`
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeHtml(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeHtml(tp('projects.graphDeleteEdge'))}"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>`;
return `<div class="project-fact-graph-edge-item${selected}" data-edge-id="${escapeHtml(e.id)}" onclick="focusProjectFactGraphEdge(${JSON.stringify(e.id)})">
: `<button type="button" class="project-fact-graph-edge-delete" data-edge-id="${escapeAttr(e.id)}" onclick="event.stopPropagation(); deleteProjectFactEdge(this.dataset.edgeId)" title="${escapeAttr(tp('projects.graphDeleteEdge'))}"><svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></button>`;
return `<div class="project-fact-graph-edge-item${selected}" data-edge-id="${escapeAttr(e.id)}" onclick="focusProjectFactGraphEdge(${escapeJsStringAttr(e.id)})">
<span class="project-fact-graph-edge-dir">${escapeHtml(dirLabel)}</span>
<span class="project-fact-graph-edge-type">${escapeHtml(e.type || '')}</span>
<span class="project-fact-graph-edge-peer" title="${escapeHtml(src + ' → ' + tgt)}">${escapeHtml(src)} ${escapeHtml(tgt)}</span>
@@ -2461,6 +2461,18 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;');
}
function escapeAttr(s) {
return escapeHtml(s).replace(/'/g, '&#39;');
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
function getChatProjectSelection() {
const convId = window.currentConversationId;
if (convId) {
+22 -10
View File
@@ -552,6 +552,18 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
// 刷新角色列表
async function refreshRoles() {
await loadRoles();
@@ -651,8 +663,8 @@ function renderRolesList() {
<span class="role-card-tools-value">${toolsDisplay}</span>
</div>
<div class="role-card-actions">
<button class="btn-secondary btn-small" onclick="editRole('${escapeHtml(role.name)}')">${_t('common.edit')}</button>
${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole('${escapeHtml(role.name)}')">${_t('common.delete')}</button>` : ''}
<button class="btn-secondary btn-small" onclick="editRole(${escapeJsStringAttr(role.name)})">${_t('common.edit')}</button>
${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole(${escapeJsStringAttr(role.name)})">${_t('common.delete')}</button>` : ''}
</div>
</div>
`;
@@ -924,7 +936,7 @@ function renderRoleToolsList() {
return;
}
const chkTitle = escapeHtml(_t('roleModal.checkboxLinkTitle'));
const chkTitle = escapeAttr(_t('roleModal.checkboxLinkTitle'));
allRoleTools.forEach(tool => {
const toolKey = getToolKey(tool);
@@ -948,19 +960,19 @@ function renderRoleToolsList() {
const externalMcpName = toolState.external_mcp || tool.external_mcp || '';
const badgeText = externalMcpName ? `外部 (${escapeHtml(externalMcpName)})` : '外部';
const badgeTitle = externalMcpName ? `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}` : '外部MCP工具';
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
externalBadge = `<span class="external-tool-badge" title="${escapeAttr(badgeTitle)}">${badgeText}</span>`;
}
let mcpDisabledBadge = '';
if (tool.enabled === false) {
mcpDisabledBadge = `<span class="role-tool-mcp-disabled-badge" title="${escapeHtml(_t('roleModal.mcpDisabledBadgeTitle'))}">${escapeHtml(_t('roleModal.mcpDisabledBadge'))}</span>`;
}
// 生成唯一的checkbox id
const checkboxId = `role-tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
const checkboxId = `role-tool-${escapeAttr(toolKey).replace(/::/g, '--')}`;
toolItem.innerHTML = `
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''}
title="${chkTitle}" aria-label="${chkTitle}"
onchange="handleRoleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
onchange="handleRoleToolCheckboxChange(${escapeJsStringAttr(toolKey)}, this.checked)" />
<div class="role-tool-item-info">
<div class="role-tool-item-name">
${escapeHtml(tool.name)}
@@ -1011,11 +1023,11 @@ function renderRoleToolsPagination() {
</select>
</div>
<div class="pagination-controls">
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.firstPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.prevPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(1, ${escapeJsStringAttr(roleToolsSearchKeyword)})" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.firstPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, ${escapeJsStringAttr(roleToolsSearchKeyword)})" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.prevPage')}</button>
<span class="pagination-page">${_t('roleModal.pageOf', { page: page, total: totalPages })}</span>
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, ${escapeJsStringAttr(roleToolsSearchKeyword)})" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, ${escapeJsStringAttr(roleToolsSearchKeyword)})" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
</div>
`;
+26 -14
View File
@@ -21,6 +21,18 @@ function settingsT(key, fallback) {
return fallback;
}
function settingsEscapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function settingsEscapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function settingsEscapeJsStringAttr(text) {
return settingsEscapeAttr(settingsEscapeJsString(text));
}
const settingsCustomSelects = new Map();
let settingsCustomSelectsDocBound = false;
@@ -1371,23 +1383,23 @@ function renderToolsList() {
const badgeText = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalFrom', { name: escapeHtml(externalMcpName) }) : `外部 (${escapeHtml(externalMcpName)})`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部');
const badgeTitle = externalMcpName ? (typeof window.t === 'function' ? window.t('mcp.externalToolFrom', { name: escapeHtml(externalMcpName) }) + ' — 点击跳转' : `外部MCP工具 - 来源:${escapeHtml(externalMcpName)} — 点击跳转`) : (typeof window.t === 'function' ? window.t('mcp.externalBadge') : '外部MCP工具');
if (externalMcpName) {
externalBadge = `<span class="external-tool-badge clickable" onclick="scrollToExternalMCP('${escapeHtml(externalMcpName)}', event)" title="${badgeTitle}">${badgeText}</span>`;
externalBadge = `<span class="external-tool-badge clickable" onclick="scrollToExternalMCP(${settingsEscapeJsStringAttr(externalMcpName)}, event)" title="${settingsEscapeAttr(badgeTitle)}">${badgeText}</span>`;
} else {
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
externalBadge = `<span class="external-tool-badge" title="${settingsEscapeAttr(badgeTitle)}">${badgeText}</span>`;
}
}
// 生成唯一的checkbox id,使用工具唯一标识符
const checkboxId = `tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
const checkboxId = `tool-${settingsEscapeAttr(toolKey).replace(/::/g, '--')}`;
toolItem.innerHTML = `
<input type="checkbox" class="theme-checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''} ${toolState.is_external || tool.is_external ? 'data-external="true"' : ''} onchange="handleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
<input type="checkbox" class="theme-checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''} ${toolState.is_external || tool.is_external ? 'data-external="true"' : ''} onchange="handleToolCheckboxChange(${settingsEscapeJsStringAttr(toolKey)}, this.checked)" />
<div class="tool-item-info">
<div class="tool-item-name">
${escapeHtml(tool.name)}
${externalBadge}
<label class="tool-resident-toggle" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleHint') : '始终常驻在 Tool Search 可见列表'}" onclick="event.stopPropagation()">
<input type="checkbox" class="theme-checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange('${escapeHtml(toolKey)}', this.checked)" />
<input type="checkbox" class="theme-checkbox" ${alwaysVisibleChecked ? 'checked' : ''} ${alwaysVisibleLocked ? 'disabled' : ''} onchange="handleToolAlwaysVisibleChange(${settingsEscapeJsStringAttr(toolKey)}, this.checked)" />
<span>${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleLabel') : '常驻'}</span>
</label>
${alwaysVisibleLocked ? `<span class="external-tool-badge" title="${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinHint') : '后端内置工具默认常驻,不可关闭'}">${typeof window.t === 'function' ? window.t('mcp.alwaysVisibleBuiltinLabel') : '内置默认'}</span>` : ''}
@@ -1644,11 +1656,11 @@ function renderToolsPagination() {
</select>
</div>
<div class="pagination-controls">
<button class="btn-secondary" onclick="loadToolsList(1, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${t('mcp.firstPage')}</button>
<button class="btn-secondary" onclick="loadToolsList(${page - 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === 1 ? 'disabled' : ''}>${t('mcp.prevPage')}</button>
<button class="btn-secondary" onclick="loadToolsList(1, ${settingsEscapeJsStringAttr(toolsSearchKeyword)})" ${page === 1 ? 'disabled' : ''}>${t('mcp.firstPage')}</button>
<button class="btn-secondary" onclick="loadToolsList(${page - 1}, ${settingsEscapeJsStringAttr(toolsSearchKeyword)})" ${page === 1 ? 'disabled' : ''}>${t('mcp.prevPage')}</button>
<span class="pagination-page">${paginationT('mcp.pageInfo', { page: page, total: totalPages })}</span>
<button class="btn-secondary" onclick="loadToolsList(${page + 1}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${t('mcp.nextPage')}</button>
<button class="btn-secondary" onclick="loadToolsList(${totalPages}, '${escapeHtml(toolsSearchKeyword)}')" ${page === totalPages ? 'disabled' : ''}>${t('mcp.lastPage')}</button>
<button class="btn-secondary" onclick="loadToolsList(${page + 1}, ${settingsEscapeJsStringAttr(toolsSearchKeyword)})" ${page === totalPages ? 'disabled' : ''}>${t('mcp.nextPage')}</button>
<button class="btn-secondary" onclick="loadToolsList(${totalPages}, ${settingsEscapeJsStringAttr(toolsSearchKeyword)})" ${page === totalPages ? 'disabled' : ''}>${t('mcp.lastPage')}</button>
</div>
`;
@@ -4096,7 +4108,7 @@ function renderExternalMCPList(servers) {
const selectedClass = toolsExternalMcpFilter === name ? ' selected' : '';
html += `
<div class="${cardClass}${selectedClass}" data-mcp-name="${escapeHtml(name)}"${hasTools ? ` onclick="scrollToExternalMCPTools('${escapeHtml(name)}', event)" title="${cardClickTitle}"` : ''}>
<div class="${cardClass}${selectedClass}" data-mcp-name="${settingsEscapeAttr(name)}"${hasTools ? ` onclick="scrollToExternalMCPTools(${settingsEscapeJsStringAttr(name)}, event)" title="${settingsEscapeAttr(cardClickTitle)}"` : ''}>
<div class="external-mcp-item-header">
<div class="external-mcp-item-info">
<h4>${transportIcon} ${escapeHtml(name)}${server.tool_count !== undefined && server.tool_count > 0 ? `<span class="tool-count-badge" title="${escapeHtml(statusT('mcp.toolCount'))}">🔧 ${server.tool_count}</span>` : ''}</h4>
@@ -4104,15 +4116,15 @@ function renderExternalMCPList(servers) {
</div>
<div class="external-mcp-item-actions">
${status === 'connected' || status === 'disconnected' || status === 'error' || status === 'disabled' ?
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" onclick="toggleExternalMCP('${escapeHtml(name)}', '${status}')" title="${status === 'connected' ? statusT('mcp.stopConnection') : statusT('mcp.startConnection')}">
`<button class="btn-small" id="btn-toggle-${settingsEscapeAttr(name)}" onclick="toggleExternalMCP(${settingsEscapeJsStringAttr(name)}, ${settingsEscapeJsStringAttr(status)})" title="${settingsEscapeAttr(status === 'connected' ? statusT('mcp.stopConnection') : statusT('mcp.startConnection'))}">
${status === 'connected' ? '⏸ ' + statusT('mcp.stop') : '▶ ' + statusT('mcp.start')}
</button>` :
status === 'connecting' ?
`<button class="btn-small" id="btn-toggle-${escapeHtml(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
`<button class="btn-small" id="btn-toggle-${settingsEscapeAttr(name)}" disabled style="opacity: 0.6; cursor: not-allowed;">
${statusT('mcp.connecting')}
</button>` : ''}
<button class="btn-small" onclick="editExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.editConfig')}" ${status === 'connecting' ? 'disabled' : ''}> ${statusT('common.edit')}</button>
<button class="btn-small btn-danger" onclick="deleteExternalMCP('${escapeHtml(name)}')" title="${statusT('mcp.deleteConfig')}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
<button class="btn-small" onclick="editExternalMCP(${settingsEscapeJsStringAttr(name)})" title="${settingsEscapeAttr(statusT('mcp.editConfig'))}" ${status === 'connecting' ? 'disabled' : ''}> ${statusT('common.edit')}</button>
<button class="btn-small btn-danger" onclick="deleteExternalMCP(${settingsEscapeJsStringAttr(name)})" title="${settingsEscapeAttr(statusT('mcp.deleteConfig'))}" ${status === 'connecting' ? 'disabled' : ''}>🗑 ${statusT('common.delete')}</button>
</div>
</div>
${(status === 'error' || status === 'disconnected') && server.error ? `
+15 -4
View File
@@ -168,6 +168,12 @@ function renderSkillsList() {
? _t('skills.cardFiles', { count: skill.file_count })
: '';
const metaItems = [ver, fc, sc].filter(Boolean);
const tags = Array.isArray(skill.tags) ? skill.tags.filter(Boolean) : [];
const visibleTags = tags.slice(0, 4);
const hiddenTagCount = Math.max(0, tags.length - visibleTags.length);
const tagsHtml = visibleTags.length
? `<div class="skill-card-tags">${visibleTags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}${hiddenTagCount ? `<span>+${hiddenTagCount}</span>` : ''}</div>`
: '';
return `
<div class="skill-card">
<div class="skill-card-body">
@@ -177,11 +183,12 @@ function renderSkillsList() {
${metaItems.length ? `<div class="skill-card-meta">${metaItems.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>` : ''}
</div>
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
${tagsHtml}
</div>
<div class="skill-card-actions">
<button type="button" class="btn-secondary btn-small" data-skill-view="${escapeHtml(sid)}">${_t('common.view')}</button>
<button type="button" class="btn-secondary btn-small" data-skill-edit="${escapeHtml(sid)}">${_t('common.edit')}</button>
<button type="button" class="btn-secondary btn-small btn-danger" data-skill-delete="${escapeHtml(sid)}">${_t('common.delete')}</button>
<button type="button" class="btn-secondary btn-small" data-skill-view="${escapeAttr(sid)}">${_t('common.view')}</button>
<button type="button" class="btn-secondary btn-small" data-skill-edit="${escapeAttr(sid)}">${_t('common.edit')}</button>
<button type="button" class="btn-secondary btn-small btn-danger" data-skill-delete="${escapeAttr(sid)}">${_t('common.delete')}</button>
</div>
</div>
</div>
@@ -519,7 +526,7 @@ function renderSkillPackageTree() {
`</div>`;
}
const selected = path === skillActivePath ? ' is-selected' : '';
return `<div class="skill-tree-row skill-tree-file${selected}" style="padding-left:${indent}px" data-skill-tree-path="${escapeHtml(path)}" title="${escapeHtml(_t('skillModal.clickToEdit'))}">` +
return `<div class="skill-tree-row skill-tree-file${selected}" style="padding-left:${indent}px" data-skill-tree-path="${escapeAttr(path)}" title="${escapeAttr(_t('skillModal.clickToEdit'))}">` +
`<span class="skill-tree-icon" aria-hidden="true">📄</span>` +
`<span class="skill-tree-label">${escapeHtml(path)}</span>` +
`</div>`;
@@ -1073,6 +1080,10 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// 语言切换时重新渲染当前页(技能列表与分页使用 _t,需随语言更新)
document.addEventListener('languagechange', function () {
const page = document.getElementById('page-skills-management');
+34 -22
View File
@@ -108,6 +108,18 @@ if (typeof escapeHtml === 'undefined') {
}
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
// 任务管理状态
const tasksState = {
allTasks: [],
@@ -526,33 +538,33 @@ function renderTaskItem(task, statusMap, isHistory = false) {
: '';
return `
<div class="task-item ${isHistory ? 'task-item-history' : ''}" data-task-id="${task.conversationId}" data-started-at="${task.startedAt}" data-status="${task.status}">
<div class="task-item ${isHistory ? 'task-item-history' : ''}" data-task-id="${escapeAttr(task.conversationId)}" data-started-at="${escapeAttr(task.startedAt)}" data-status="${escapeAttr(task.status)}">
<div class="task-header">
<div class="task-info">
${canCancel ? `
<label class="task-checkbox">
<input type="checkbox" ${isSelected ? 'checked' : ''}
onchange="toggleTaskSelection('${task.conversationId}', this.checked)">
onchange="toggleTaskSelection(${escapeJsStringAttr(task.conversationId)}, this.checked)">
</label>
` : '<div class="task-checkbox-placeholder"></div>'}
<span class="task-status ${status.class}">${status.text}</span>
${isHistory ? '<span class="task-history-badge" title="' + _t('tasks.historyBadge') + '">📜</span>' : ''}
<span class="task-message" title="${escapeHtml((task.title || task.message || _t('tasks.unnamedTask')))}">${escapeHtml((task.title || task.message || _t('tasks.unnamedTask')))}</span>
<span class="task-message" title="${escapeAttr((task.title || task.message || _t('tasks.unnamedTask')))}">${escapeHtml((task.title || task.message || _t('tasks.unnamedTask')))}</span>
</div>
<div class="task-actions">
${duration ? `<span class="task-duration" title="${_t('tasks.duration')}">⏱ ${duration}</span>` : ''}
<span class="task-time" title="${isHistory && completedText ? _t('tasks.completedAt') : _t('tasks.startedAt')}">
${isHistory && completedText ? completedText : timeText}
</span>
${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask('${task.conversationId}', this)">` + _t('tasks.cancelTask') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="navigateToVulnerabilitiesFromTasksPage('conversation', '${task.conversationId}')">` + _t('tasks.viewVulnerabilities') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation('${task.conversationId}')">` + _t('tasks.viewConversation') + `</button>` : ''}
${canCancel ? `<button class="btn-secondary btn-small" onclick="cancelTask(${escapeJsStringAttr(task.conversationId)}, this)">` + _t('tasks.cancelTask') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="navigateToVulnerabilitiesFromTasksPage('conversation', ${escapeJsStringAttr(task.conversationId)})">` + _t('tasks.viewVulnerabilities') + `</button>` : ''}
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewConversation(${escapeJsStringAttr(task.conversationId)})">` + _t('tasks.viewConversation') + `</button>` : ''}
</div>
</div>
${task.conversationId ? `
<div class="task-details">
<span class="task-id-label">` + _t('tasks.conversationIdLabel') + `:</span>
<span class="task-id-value" title="` + _t('tasks.clickToCopy') + `" onclick="copyTaskId('${task.conversationId}')">${escapeHtml(task.conversationId)}</span>
<span class="task-id-value" title="` + _t('tasks.clickToCopy') + `" onclick="copyTaskId(${escapeJsStringAttr(task.conversationId)})">${escapeHtml(task.conversationId)}</span>
</div>
` : ''}
</div>
@@ -1593,7 +1605,7 @@ function renderBatchQueues() {
const doneCount = stats.completed + stats.failed + stats.cancelled;
return `
<div class="batch-queue-item batch-queue-item--compact${cardMod}${noActionsClass}" data-queue-id="${queue.id}" onclick="showBatchQueueDetail('${queue.id}')">
<div class="batch-queue-item batch-queue-item--compact${cardMod}${noActionsClass}" data-queue-id="${escapeAttr(queue.id)}" onclick="showBatchQueueDetail(${escapeJsStringAttr(queue.id)})">
<div class="batch-queue-item__inner batch-queue-item__inner--grid">
<div class="batch-queue-item__lead">
<div class="batch-queue-item__title-row">
@@ -1601,7 +1613,7 @@ function renderBatchQueues() {
<div class="batch-queue-item__titles">${titleBlock}</div>
</div>
<p class="batch-queue-item__config">${configLine}${cronPausedNote}</p>
<p class="batch-queue-item__idline batch-queue-item__idline--lead"><code title="${escapeHtml(queue.id)}">${shortId}</code><span class="batch-queue-item__idsep">\u00b7</span><span>${escapeHtml(_t('tasks.createdTimeLabel'))}\u00a0${escapeHtml(new Date(queue.createdAt).toLocaleString())}</span></p>
<p class="batch-queue-item__idline batch-queue-item__idline--lead"><code title="${escapeAttr(queue.id)}">${shortId}</code><span class="batch-queue-item__idsep">\u00b7</span><span>${escapeHtml(_t('tasks.createdTimeLabel'))}\u00a0${escapeHtml(new Date(queue.createdAt).toLocaleString())}</span></p>
</div>
<div class="batch-queue-item__cluster">
<div class="batch-queue-item__status-inline">
@@ -1616,8 +1628,8 @@ function renderBatchQueues() {
</div>
</div>
<div class="batch-queue-item__actions-col" onclick="event.stopPropagation();">
<button type="button" class="batch-queue-icon-btn" onclick="navigateToVulnerabilitiesFromTasksPage('queue', '${queue.id}')" title="${escapeHtml(_t('tasks.viewVulnerabilitiesQueueTitle'))}" aria-label="${escapeHtml(_t('tasks.viewVulnerabilitiesQueueTitle'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg></button>
${canDelete ? `<button type="button" class="batch-queue-icon-btn batch-queue-icon-btn--danger" onclick="deleteBatchQueueFromList('${queue.id}')" title="${escapeHtml(_t('tasks.deleteQueue'))}" aria-label="${escapeHtml(_t('tasks.deleteQueue'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>` : ''}
<button type="button" class="batch-queue-icon-btn" onclick="navigateToVulnerabilitiesFromTasksPage('queue', ${escapeJsStringAttr(queue.id)})" title="${escapeHtml(_t('tasks.viewVulnerabilitiesQueueTitle'))}" aria-label="${escapeHtml(_t('tasks.viewVulnerabilitiesQueueTitle'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg></button>
${canDelete ? `<button type="button" class="batch-queue-icon-btn batch-queue-icon-btn--danger" onclick="deleteBatchQueueFromList(${escapeJsStringAttr(queue.id)})" title="${escapeHtml(_t('tasks.deleteQueue'))}" aria-label="${escapeHtml(_t('tasks.deleteQueue'))}"><svg class="batch-queue-icon-btn__svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>` : ''}
</div>
</div>
</div>
@@ -1835,7 +1847,7 @@ async function showBatchQueueDetail(queueId) {
deferModalContent(function () {
content.innerHTML = `
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeHtml(queue.id)}">
<div class="batch-queue-detail-layout" data-bq-detail-for="${escapeAttr(queue.id)}">
<section class="batch-queue-detail-hero">
<span class="batch-queue-status ${pres.class}">${escapeHtml(pres.text)}</span>
${pres.sublabel ? `<p class="batch-queue-detail-hero__sub">${escapeHtml(pres.sublabel)}</p>` : ''}
@@ -1872,15 +1884,15 @@ async function showBatchQueueDetail(queueId) {
const canEdit = allowSubtaskMutation && task.status !== 'running';
const canRunSingle = batchQueueCanRunSingleTask(queue, task);
const runSingleUnavailableTitle = escapeHtml(batchQueueRunSingleTaskDisabledReason(queue, task));
const taskMessageEscaped = escapeHtml(task.message).replace(/'/g, "&#39;").replace(/"/g, "&quot;").replace(/\n/g, "\\n");
const taskMessageEscaped = escapeAttr(task.message).replace(/\n/g, "\\n");
return `
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${queue.id}" data-task-id="${task.id}" data-task-message="${taskMessageEscaped}">
<div class="batch-task-item ${task.status === 'running' ? 'batch-task-item-active' : ''}" data-queue-id="${escapeAttr(queue.id)}" data-task-id="${escapeAttr(task.id)}" data-task-message="${taskMessageEscaped}">
<div class="batch-task-header">
<span class="batch-task-index">#${index + 1}</span>
<span class="batch-task-status ${taskStatus.class}">${taskStatus.text}</span>
<span class="batch-task-message" title="${escapeHtml(task.message)}">${escapeHtml(task.message)}</span>
<button class="btn-secondary btn-small batch-task-run-btn" ${canRunSingle ? `onclick="runSingleBatchTask('${queue.id}', '${task.id}'); event.stopPropagation();"` : `disabled title="${runSingleUnavailableTitle}"`}>` + _t('tasks.runSingleTask') + `</button>
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation('${task.conversationId}'); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
<span class="batch-task-message" title="${escapeAttr(task.message)}">${escapeHtml(task.message)}</span>
<button class="btn-secondary btn-small batch-task-run-btn" ${canRunSingle ? `onclick="runSingleBatchTask(${escapeJsStringAttr(queue.id)}, ${escapeJsStringAttr(task.id)}); event.stopPropagation();"` : `disabled title="${runSingleUnavailableTitle}"`}>` + _t('tasks.runSingleTask') + `</button>
${task.conversationId ? `<button class="btn-secondary btn-small" onclick="viewBatchTaskConversation(${escapeJsStringAttr(task.conversationId)}); event.stopPropagation();">` + _t('tasks.viewConversation') + `</button>` : ''}
${canEdit ? `<button class="btn-secondary btn-small batch-task-edit-btn" onclick="editBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.edit') + `</button>` : ''}
${canEdit ? `<button class="btn-secondary btn-small btn-danger batch-task-delete-btn" onclick="deleteBatchTaskFromElement(this); event.stopPropagation();">` + _t('common.delete') + `</button>` : ''}
</div>
@@ -2182,7 +2194,7 @@ function editBatchTaskFromElement(button) {
// 替换消息为内联编辑区域
const editDiv = document.createElement('div');
editDiv.className = 'batch-task-inline-edit';
editDiv.innerHTML = `<textarea id="bq-task-edit-${escapeHtml(taskId)}">${escapeHtml(decodedMessage)}</textarea>`;
editDiv.innerHTML = `<textarea id="bq-task-edit-${escapeAttr(taskId)}">${escapeHtml(decodedMessage)}</textarea>`;
msgSpan.style.display = 'none';
msgSpan.parentNode.insertBefore(editDiv, msgSpan.nextSibling);
@@ -2474,7 +2486,7 @@ function startInlineEditTitle() {
const untitledText = _t('tasks.batchQueueUntitled');
const val = currentTitle === untitledText ? '' : currentTitle;
container.innerHTML = `<span class="bq-inline-edit-controls">
<input type="text" id="bq-edit-title" value="${escapeHtml(val)}" placeholder="${escapeHtml(_t('batchImportModal.queueTitleHint') || '')}" style="width:180px;" />
<input type="text" id="bq-edit-title" value="${escapeAttr(val)}" placeholder="${escapeAttr(_t('batchImportModal.queueTitleHint') || '')}" style="width:180px;" />
</span>`;
const inp = document.getElementById('bq-edit-title');
if (inp) {
@@ -2534,8 +2546,8 @@ function startInlineEditRole() {
const currentRole = queue.role || '';
const roles = (Array.isArray(batchQueuesState.loadedRoles) ? batchQueuesState.loadedRoles : []).filter(r => r.name !== '默认' && r.enabled !== false).sort((a, b) => (a.name || '').localeCompare(b.name || '', 'zh-CN'));
const currentInList = !currentRole || roles.some(r => r.name === currentRole);
const orphanOpt = !currentInList ? `<option value="${escapeHtml(currentRole)}" selected>${escapeHtml(currentRole)} (${escapeHtml(_t('batchQueueDetailModal.roleNotFound') || '已移除')})</option>` : '';
const opts = roles.map(r => `<option value="${escapeHtml(r.name)}" ${r.name === currentRole ? 'selected' : ''}>${escapeHtml(r.name)}</option>`).join('');
const orphanOpt = !currentInList ? `<option value="${escapeAttr(currentRole)}" selected>${escapeHtml(currentRole)} (${escapeHtml(_t('batchQueueDetailModal.roleNotFound') || '已移除')})</option>` : '';
const opts = roles.map(r => `<option value="${escapeAttr(r.name)}" ${r.name === currentRole ? 'selected' : ''}>${escapeHtml(r.name)}</option>`).join('');
container.innerHTML = `<span class="bq-inline-edit-controls">
<select id="bq-edit-role">
<option value="">${escapeHtml(_t('batchImportModal.defaultRole'))}</option>
@@ -2762,7 +2774,7 @@ function startInlineEditSchedule() {
<option value="manual" ${!isCron ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.scheduleModeManual'))}</option>
<option value="cron" ${isCron ? 'selected' : ''}>${escapeHtml(_t('batchImportModal.scheduleModeCron'))}</option>
</select>
<input type="text" id="bq-edit-cron-expr" class="bq-edit-cron-expr" value="${escapeHtml(queue.cronExpr || '')}" placeholder="${_t('batchImportModal.cronExprPlaceholder', { interpolation: { escapeValue: false } })}" style="${!isCron ? 'display:none;' : ''}" />
<input type="text" id="bq-edit-cron-expr" class="bq-edit-cron-expr" value="${escapeAttr(queue.cronExpr || '')}" placeholder="${escapeAttr(_t('batchImportModal.cronExprPlaceholder', { interpolation: { escapeValue: false } }))}" style="${!isCron ? 'display:none;' : ''}" />
</span>`;
refreshBatchFormSelect('bq-edit-schedule-mode', { inline: true });
let schedCancelled = false;
+28 -15
View File
@@ -1385,18 +1385,19 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
? escapeHtml(typeof getProjectName === 'function' ? getProjectName(vuln.project_id) : vuln.project_id)
: escapeHtml(vulnT('vulnerabilityPage.projectUnbound'));
const projectBadge = vuln.project_id
? `<span class="vulnerability-project-badge" title="${escapeHtml(vuln.project_id)}">${escapeHtml(vulnT('vulnerabilityPage.detailProject'))}: ${projectLabel}</span>`
? `<span class="vulnerability-project-badge" title="${escapeAttr(vuln.project_id)}">${escapeHtml(vulnT('vulnerabilityPage.detailProject'))}: ${projectLabel}</span>`
: `<span class="vulnerability-project-badge vulnerability-project-badge--unbound">${escapeHtml(vulnT('vulnerabilityPage.projectUnbound'))}</span>`;
const dlTitle = escapeHtml(vulnT('vulnerabilityPage.downloadMarkdownTitle'));
const editTitle = escapeHtml(vulnT('common.edit'));
const deleteTitle = escapeHtml(vulnT('common.delete'));
const vulnIdJs = escapeJsStringAttr(vuln.id);
return `
<div class="vulnerability-card ${severityClass}" id="vulnerability-card-${vuln.id}" data-vuln-id="${escapeHtml(vuln.id)}">
<div class="vulnerability-header" onclick="toggleVulnerabilityDetails('${vuln.id}')" style="cursor: pointer;">
<div class="vulnerability-card ${severityClass}" id="vulnerability-card-${escapeAttr(vuln.id)}" data-vuln-id="${escapeAttr(vuln.id)}">
<div class="vulnerability-header" onclick="toggleVulnerabilityDetails(${vulnIdJs})" style="cursor: pointer;">
<div class="vulnerability-title-section">
<div style="display: flex; align-items: center; gap: 8px;">
<svg class="vulnerability-expand-icon" id="expand-icon-${vuln.id}" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="transition: transform 0.2s ease; flex-shrink: 0;">
<svg class="vulnerability-expand-icon" id="expand-icon-${escapeAttr(vuln.id)}" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style="transition: transform 0.2s ease; flex-shrink: 0;">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h3 class="vulnerability-title">${escapeHtml(vuln.title)}</h3>
@@ -1409,20 +1410,20 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
</div>
</div>
<div class="vulnerability-actions" onclick="event.stopPropagation();">
<button class="btn-ghost" onclick="downloadVulnerabilityAsMarkdown('${vuln.id}', event)" title="${dlTitle}">
<button class="btn-ghost" onclick="downloadVulnerabilityAsMarkdown(${vulnIdJs}, event)" title="${dlTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="7 10 12 15 17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="btn-ghost" onclick="editVulnerability('${vuln.id}')" title="${editTitle}">
<button class="btn-ghost" onclick="editVulnerability(${vulnIdJs})" title="${editTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="btn-ghost" data-require-permission="vulnerability:delete" onclick="deleteVulnerability('${vuln.id}')" title="${deleteTitle}">
<button class="btn-ghost" data-require-permission="vulnerability:delete" onclick="deleteVulnerability(${vulnIdJs})" title="${deleteTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1450,7 +1451,7 @@ function renderVulnerabilities(vulnerabilities, renderOptions) {
${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 class="vulnerability-related-facts" id="vuln-related-facts-${escapeAttr(vuln.id)}" data-project-id="${escapeAttr(vuln.project_id || '')}" data-vuln-id="${escapeAttr(vuln.id)}" hidden></div>
</div>
</div>
`;
@@ -1557,10 +1558,10 @@ function buildVulnerabilityProjectOptionsHtml(selectedId) {
entries.forEach(([id, name]) => {
if (!id) return;
const selected = id === sel ? ' selected' : '';
html += `<option value="${escapeHtml(id)}"${selected}>${escapeHtml(name || id)}</option>`;
html += `<option value="${escapeAttr(id)}"${selected}>${escapeHtml(name || id)}</option>`;
});
if (sel && !entries.some(([id]) => id === sel)) {
html += `<option value="${escapeHtml(sel)}" selected>${escapeHtml(sel)}</option>`;
html += `<option value="${escapeAttr(sel)}" selected>${escapeHtml(sel)}</option>`;
}
return html;
}
@@ -1974,8 +1975,8 @@ async function loadVulnerabilityRelatedFacts(vulnId) {
.map((f) => {
const key = escapeHtml(f.fact_key);
const sum = escapeHtml((f.summary || '').slice(0, 120));
const pid = escapeHtml(projectId);
const rawKey = escapeHtml(f.fact_key);
const pid = escapeAttr(projectId);
const rawKey = escapeAttr(f.fact_key);
return `<li><a role="button" href="#" data-project-id="${pid}" data-fact-key="${rawKey}" onclick="event.preventDefault();openProjectFactFromVulnerability(this.dataset.projectId,this.dataset.factKey)"><code>${key}</code></a> — ${sum}</li>`;
})
.join('');
@@ -2016,6 +2017,18 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
/** 复制详情字段(编码由 encodeURIComponent 传入,避免引号截断) */
function vulnerabilityCopyEncoded(evt, encoded) {
if (evt && evt.stopPropagation) {
@@ -2076,9 +2089,9 @@ function vulnDetailProjectField(vuln) {
return `<div class="vuln-detail-field">
<div class="vuln-detail-field__label">${escapeHtml(label)}</div>
<div class="vuln-detail-field__row">
<select class="vulnerability-project-bind-select" data-vuln-id="${escapeHtml(vuln.id)}"
<select class="vulnerability-project-bind-select" data-vuln-id="${escapeAttr(vuln.id)}"
onchange="bindVulnerabilityProject(this.dataset.vulnId, this.value, true)"
title="${hint}" aria-label="${escapeHtml(label)}">
title="${escapeAttr(hint)}" aria-label="${escapeAttr(label)}">
${buildVulnerabilityProjectOptionsHtml(vuln.project_id || '')}
</select>
<span class="vuln-detail-field__copy-spacer" aria-hidden="true"></span>
@@ -2450,7 +2463,7 @@ async function refreshVulnerabilityProjectFilter() {
if (!p.id) return;
const selected = p.id === cur ? ' selected' : '';
const arch = p.status === 'archived' ? ' [' + vulnT('projects.archived') + ']' : '';
html += `<option value="${escapeHtml(p.id)}"${selected}>${escapeHtml(p.name || p.id)}${arch}</option>`;
html += `<option value="${escapeAttr(p.id)}"${selected}>${escapeHtml(p.name || p.id)}${arch}</option>`;
});
sel.innerHTML = html;
if (cur) sel.value = cur;
+77 -1
View File
@@ -1338,6 +1338,55 @@ function escapeHtmlAttr(s) {
return escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function webshellFinalizationReasonLabel(reason, status) {
var key = String(reason || status || '').trim();
var labels = {
pending_tool_executions: '等待工具执行完成',
missing_execution_evidence: '缺少完成态证据',
awaiting_hitl: '等待人工确认',
empty_response: '未捕获到有效回复',
missing_finalization_contract: '缺少最终化证明',
in_progress: '仍在验证',
blocked: '检查未通过',
failed: '任务失败',
cancelled: '任务已取消',
verified: '已验证'
};
return labels[key] || key || '检查未通过';
}
function webshellFinalizationMissingCheckLabel(check) {
var s = String(check || '').trim();
if (!s) return '';
if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束';
if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录';
if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认';
if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本';
if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', '');
return s;
}
function webshellFinalizationNotice(data, eventMessage, hasContract) {
var reason = hasContract
? webshellFinalizationReasonLabel(data && data.completionReason, data && data.status)
: webshellFinalizationReasonLabel('missing_finalization_contract');
var lines = ['仍在验证,暂不生成最终结论', '状态:' + reason];
var pending = Array.isArray(data && data.pendingExecutionIds) ? data.pendingExecutionIds.filter(Boolean).map(String) : [];
if (pending.length) {
lines.push('待完成工具:' + pending.slice(0, 3).join(', ') + (pending.length > 3 ? ' 等' : ''));
}
var missing = Array.isArray(data && data.missingChecks)
? data.missingChecks.map(webshellFinalizationMissingCheckLabel).filter(Boolean)
: [];
if (missing.length) {
lines.push('待完成检查:' + missing.slice(0, 2).join('') + (missing.length > 2 ? ' 等' : ''));
}
if (!hasContract && eventMessage) {
lines.push('候选输出已移入过程详情。');
}
return lines.join('\n');
}
function escapeSingleQuotedShellArg(value) {
var s = value == null ? '' : String(value);
return "'" + s.replace(/'/g, "'\\''") + "'";
@@ -3393,7 +3442,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
message: message,
webshellConnectionId: conn.id,
conversationId: convId,
role: wsRole
role: wsRole,
finalization: {
requireExecutionEvidence: true
}
};
if (!convId) {
var wsPid = getWebshellAiProjectSelection(conn);
@@ -3465,6 +3517,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
streamingTarget = '';
webshellStreamingTypingId += 1;
streamingTypingId = webshellStreamingTypingId;
assistantDiv.dataset.finalized = 'false';
assistantDiv.classList.add('webshell-ai-candidate-output');
assistantDiv.textContent = '…';
messagesContainer.scrollTop = messagesContainer.scrollHeight;
} else if (_et === 'response_delta') {
@@ -3485,6 +3539,23 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
}
} else if (_et === 'response') {
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
var finalized = !!(_ed && _ed.finalized === true);
var hasFinalizationContract = !!(_ed && (
Object.prototype.hasOwnProperty.call(_ed, 'finalized') ||
Object.prototype.hasOwnProperty.call(_ed, 'finalizable') ||
Object.prototype.hasOwnProperty.call(_ed, 'completionReason') ||
Object.prototype.hasOwnProperty.call(_ed, 'evidenceVerified') ||
Object.prototype.hasOwnProperty.call(_ed, 'missingChecks')
));
assistantDiv.dataset.finalized = finalized ? 'true' : 'false';
assistantDiv.classList.toggle('webshell-ai-candidate-output', !finalized);
assistantDiv.classList.toggle('webshell-ai-finalized-output', finalized);
if (!finalized && !hasFinalizationContract && text) {
appendTimelineItem('finalization_check', '候选输出缺少最终化证明', text, Object.assign({}, _ed || {}, { missingFinalizationContract: true }));
text = webshellFinalizationNotice(_ed || {}, text, false);
} else if (!finalized && hasFinalizationContract) {
text = webshellFinalizationNotice(_ed || {}, text, true);
}
if (text) {
streamingTarget = String(text);
webshellStreamingTypingId += 1;
@@ -3493,6 +3564,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
}
// ─── Terminal events ───
} else if (_et === 'finalization_check') {
var finalizationOk = !!(_ed && _ed.finalized === true);
appendTimelineItem('finalization_check', finalizationOk ? '最终回复检查通过' : '最终回复检查未通过', finalizationOk ? (_em || '最终回复检查通过。') : webshellFinalizationNotice(_ed || {}, _em, true), _ed);
} else if (_et === 'finalization_auto_continue') {
appendTimelineItem('progress', '继续验证', _em, _ed);
} else if (_et === 'error' && _em) {
streamingTypingId += 1;
var errLabel = wsTOr('chat.error', '错误');
+586 -4
View File
@@ -37,6 +37,12 @@
requestSignature: '',
dragDepth: 0
};
const workflowAiState = {
draft: null,
result: null,
activeStep: '',
animating: false
};
const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id';
const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id';
@@ -67,6 +73,18 @@
const NODE_PLACEMENT_PADDING = 20;
const WORKFLOW_EDIT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
const WORKFLOW_AI_TOOL_HINTS = [
{ keywords: ['子域名', 'subdomain', 'subfinder', 'amass'], tools: ['subfinder', 'amass'], label: '子域名发现' },
{ keywords: ['端口', 'port', 'nmap', 'rustscan', 'masscan'], tools: ['nmap', 'rustscan', 'masscan'], label: '端口扫描' },
{ keywords: ['漏洞', 'vuln', '漏洞扫描', 'nuclei', 'nikto', 'zap'], tools: ['nuclei', 'nikto', 'zap'], label: '漏洞扫描' },
{ keywords: ['目录', '路径', '暴露页面', 'dir', 'ffuf', 'gobuster', 'feroxbuster'], tools: ['ffuf', 'gobuster', 'feroxbuster', 'dirsearch'], label: '暴露面探测' },
{ keywords: ['证书', 'certificate', 'crt'], tools: ['subfinder'], label: '证书与域名线索收集' },
{ keywords: ['云', 'cloud', '配置审计', 'prowler', 'scout'], tools: ['prowler', 'scout-suite'], label: '云配置审计' },
{ keywords: ['容器', '镜像', 'k8s', 'kubernetes', 'trivy', 'kube'], tools: ['trivy', 'kube-bench', 'kube-hunter'], label: '容器安全检查' },
{ keywords: ['情报', '威胁情报', 'threat', 'ioc', 'virustotal', 'shodan', 'fofa'], tools: ['virustotal_search', 'shodan_search', 'fofa_search'], label: '威胁情报收集' }
];
const WORKFLOW_AI_HIGH_RISK_RE = /(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)/i;
const WORKFLOW_AI_PROGRESS_STEPS = ['understand', 'match', 'draft', 'audit'];
function esc(text) {
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
@@ -166,6 +184,10 @@
function graphToElements(graph) {
const nodes = (graph.nodes || []).map((node, index) => ({
group: 'nodes',
classes: [
node.config && node.config.generated_by === 'natural_language' ? 'ai-generated' : '',
node.config && (node.config.risk_level === 'high' || node.config.requires_human_confirmation === 'true') ? 'high-risk' : ''
].filter(Boolean).join(' '),
data: {
id: node.id || `node-${index + 1}`,
label: node.label || wfNodeLabel(node.type) || node.id || _t('workflows.nodeFallback', { n: index + 1 }),
@@ -265,6 +287,8 @@
{ 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: 'node.ai-generated', style: { 'border-width': 2, 'border-color': '#38bdf8' } },
{ selector: 'node.high-risk', style: { 'border-width': 3, 'border-color': '#f97316' } },
{
selector: 'edge',
style: {
@@ -1350,6 +1374,559 @@
}
}
function workflowAiOption(id) {
const el = document.getElementById(id);
return !!(el && el.checked);
}
function workflowAiSleep(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
function workflowAiStepLabel(step) {
return _t('workflows.ai.steps.' + step);
}
function workflowAiSetProgress(activeStep, done) {
workflowAiState.activeStep = activeStep || '';
const wrap = document.getElementById('workflow-ai-progress');
if (!wrap) return;
if (!activeStep && !done) {
wrap.hidden = true;
wrap.innerHTML = '';
return;
}
wrap.hidden = false;
const activeIndex = WORKFLOW_AI_PROGRESS_STEPS.indexOf(activeStep);
wrap.innerHTML = WORKFLOW_AI_PROGRESS_STEPS.map(function (step, index) {
const complete = done || (activeIndex >= 0 && index < activeIndex);
const active = !done && step === activeStep;
return `<span class="${complete ? 'is-complete' : ''} ${active ? 'is-active' : ''}">
<b>${complete ? '✓' : index + 1}</b>
<small>${esc(workflowAiStepLabel(step))}</small>
</span>`;
}).join('');
}
function workflowAiPreviewItems(graph) {
const nodes = (graph.nodes || []).slice().sort(function (a, b) {
const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0;
const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0;
const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0;
const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0;
return ax === bx ? ay - by : ax - bx;
});
return nodes.slice(0, 9);
}
function workflowAiPreviewHtml(graph) {
const items = workflowAiPreviewItems(graph);
if (!items.length) return '';
return `<div class="workflow-ai-preview" aria-label="${esc(_t('workflows.ai.preview'))}">
<div class="workflow-ai-preview-title">${esc(_t('workflows.ai.preview'))}</div>
<div class="workflow-ai-preview-flow">
${items.map(function (node, index) {
const type = node.type || 'tool';
const cfg = node.config || {};
const risky = cfg.risk_level === 'high' || cfg.requires_human_confirmation === 'true';
return `<span class="workflow-ai-preview-node is-${esc(type)} ${risky ? 'is-risky' : ''}">
<small>${esc(wfNodeLabel(type))}</small>
<strong>${esc(node.label || wfNodeLabel(type))}</strong>
</span>${index < items.length - 1 ? '<i aria-hidden="true">→</i>' : ''}`;
}).join('')}
</div>
</div>`;
}
function workflowAiSlug(text) {
const raw = String(text || '').trim().toLowerCase();
const ascii = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (ascii) return ascii.slice(0, 48);
let hash = 0;
for (let i = 0; i < raw.length; i++) hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
return 'ai-workflow-' + Math.abs(hash || Date.now()).toString(36);
}
function workflowAiMatchTool(toolNames) {
if (!workflowToolOptions.length) return '';
const enabled = workflowToolOptions.filter(tool => tool.enabled !== false);
const all = enabled.length ? enabled : workflowToolOptions;
for (const wanted of toolNames || []) {
const lower = String(wanted || '').toLowerCase();
const found = all.find(tool => String(tool.key || '').toLowerCase().includes(lower));
if (found) return found.key;
}
return '';
}
function workflowAiDetectCapabilities(prompt) {
const lower = String(prompt || '').toLowerCase();
const capabilities = [];
WORKFLOW_AI_TOOL_HINTS.forEach(function (hint) {
if (hint.keywords.some(keyword => lower.indexOf(String(keyword).toLowerCase()) !== -1)) {
capabilities.push({
label: hint.label,
tool_name: workflowAiMatchTool(hint.tools),
tool_candidates: hint.tools.slice()
});
}
});
if (!capabilities.length) {
capabilities.push({ label: _t('workflows.ai.resultCapabilities'), tool_name: '', tool_candidates: [] });
}
return capabilities;
}
function workflowAiNode(id, type, label, x, y, config) {
return {
id,
type,
label,
position: { x, y },
config: Object.assign(configWithDefaults(type, {}), config || {}, {
generated_by: 'natural_language',
needs_review: 'true'
})
};
}
function workflowAiEdge(id, source, target, label, config) {
return {
id,
source,
target,
label: label || '',
config: config || {}
};
}
function workflowAiBuildDraft(prompt, options) {
const capabilities = workflowAiDetectCapabilities(prompt);
const wantsApproval = /(审批|确认|审核|负责人|人工|review|approve|approval|human)/i.test(prompt);
const wantsReport = /(报告|汇总|输出|通知|任务|工单|report|summary|notify|ticket)/i.test(prompt);
const wantsCondition = /(如果|发现|存在|高危|新增|失败|通过|否则|if|when|high|critical|new|fail)/i.test(prompt);
const highRisk = WORKFLOW_AI_HIGH_RISK_RE.test(prompt);
const nodes = [];
const edges = [];
const assumptions = [];
const riskWarnings = [];
let x = 120;
const y = 150;
let nodeIndex = 1;
let edgeIndex = 1;
let openConditionId = '';
function nextId(prefix) {
const id = prefix + '-' + nodeIndex;
nodeIndex += 1;
return id;
}
function add(type, label, config, yy) {
const id = nextId(type);
nodes.push(workflowAiNode(id, type, label, x, yy || y, config));
x += 210;
return id;
}
function connect(source, target, label, config) {
edges.push(workflowAiEdge('edge-ai-' + edgeIndex, source, target, label, config));
edgeIndex += 1;
}
const start = add('start', wfNodeLabel('start'), { input_keys: 'message, conversationId, projectId, target' });
let previous = start;
capabilities.forEach(function (capability) {
const hasTool = !!capability.tool_name;
const id = add(hasTool ? 'tool' : 'agent', capability.label, hasTool ? {
tool_name: capability.tool_name,
arguments: '{"target":"{{inputs.target}}","message":"{{inputs.message}}"}',
timeout_seconds: '120',
join_strategy: 'all_merge'
} : {
agent_mode: 'eino_single',
input_binding: { from: 'previous', field: 'output' },
instruction: capability.label + '。根据用户需求执行安全流程步骤,并输出结构化结果:' + prompt,
output_key: 'agent_result',
join_strategy: 'all_merge',
missing_tool_candidates: capability.tool_candidates.join(', ')
});
connect(previous, id);
previous = id;
if (!hasTool && capability.tool_candidates.length) {
assumptions.push(capability.label + ' 未匹配到已启用工具,已生成 Agent 草稿节点。');
}
});
if (wantsCondition) {
const condition = add('condition', highRisk ? '是否需要高风险处置' : '是否满足触发条件', {
expression: highRisk ? '{{previous.output}} contains "高危"' : '{{previous.output}} != ""',
join_strategy: 'all_merge'
});
connect(previous, condition);
openConditionId = condition;
const report = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), {
output_key: 'result',
source_binding: { from: 'previous', field: 'output' },
static_value: '',
join_strategy: 'all_merge'
}, y + 130);
connect(condition, report, _t('workflows.edges.no'), { condition: '{{previous.matched}} == "false"', branch: 'false' });
previous = condition;
}
if (highRisk) {
let insertedApproval = false;
if (!options.allowHighRisk || wantsApproval) {
const approval = add('hitl', '人工审批', {
prompt: '请确认是否允许继续执行高风险处置:' + prompt,
prompt_binding: { from: 'previous', field: 'output' },
reviewer: 'human',
join_strategy: 'all_merge',
risk_level: 'high'
});
connect(previous, approval, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
if (previous === openConditionId) openConditionId = '';
previous = approval;
insertedApproval = true;
}
const action = add('agent', '执行受控处置', {
agent_mode: 'eino_single',
input_binding: { from: 'previous', field: 'output' },
instruction: '仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:' + prompt,
output_key: 'remediation_plan',
join_strategy: 'all_merge',
risk_level: 'high',
requires_human_confirmation: 'true'
});
connect(previous, action, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
if (previous === openConditionId) openConditionId = '';
previous = action;
riskWarnings.push(insertedApproval
? '检测到高风险动作,已加入人工确认与 requires_human_confirmation 标记。'
: '检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。');
} else if (wantsApproval && !nodes.some(node => node.type === 'hitl')) {
const approval = add('hitl', '人工审批', {
prompt: '请审核工作流阶段结果:' + prompt,
prompt_binding: { from: 'previous', field: 'output' },
reviewer: 'human',
join_strategy: 'all_merge'
});
connect(previous, approval);
previous = approval;
}
const output = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), {
output_key: 'result',
source_binding: { from: 'previous', field: 'output' },
static_value: '',
join_strategy: 'all_merge'
});
connect(previous, output, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
const graph = {
nodes,
edges,
config: {
schema_version: 1,
generated_by: 'natural_language',
source_prompt: prompt,
objective: options.includeObjective ? prompt : ''
}
};
if (options.allowSchedule && /(每天|每周|定时|周期|持续|daily|weekly|schedule|monitor)/i.test(prompt)) {
graph.config.trigger_suggestion = /(每天|daily)/i.test(prompt) ? 'daily' : 'scheduled';
assumptions.push('已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。');
}
return {
graph,
meta: {
id: workflowAiSlug(prompt),
name: prompt.length > 22 ? prompt.slice(0, 22) + '...' : prompt,
description: prompt,
enabled: true
},
generator: 'deterministic-fallback',
audit: {
risk_warnings: riskWarnings,
assumptions: assumptions,
high_risk: highRisk,
needs_hitl: nodes.some(node => node.type === 'hitl'),
savable: validateWorkflowGraph(graph).length === 0
},
assumptions,
riskWarnings,
capabilities,
stats: { nodes: nodes.length, edges: edges.length }
};
}
function workflowAiAvailableToolsPayload() {
return (workflowToolOptions || []).map(function (tool) {
return {
key: tool.key || tool.name || '',
name: tool.name || '',
enabled: tool.enabled !== false
};
}).filter(function (tool) {
return tool.key || tool.name;
});
}
async function workflowAiGenerateDraftOnServer(prompt, options) {
const response = await apiFetch('/api/workflows/generate-draft', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
options: {
include_objective: !!options.includeObjective,
allow_schedule: !!options.allowSchedule,
allow_high_risk: !!options.allowHighRisk
},
available_tools: workflowAiAvailableToolsPayload()
})
});
const data = await response.json().catch(function () { return {}; });
if (!response.ok) {
throw new Error(data.error || _t('workflows.ai.generateFailed'));
}
const result = data.result || data;
if (!result || !result.graph) {
throw new Error(_t('workflows.ai.generateFailed'));
}
result.generator = result.generator || 'server';
return result;
}
function workflowAiRenderResult(result) {
const target = document.getElementById('workflow-ai-result');
const applyBtn = document.getElementById('workflow-ai-apply-btn');
if (!target) return;
if (!result) {
target.innerHTML = '';
if (applyBtn) applyBtn.disabled = true;
return;
}
const graph = result.graph || defaultGraph();
const errors = validateWorkflowGraph(graph);
const audit = result.audit || {};
const risks = audit.risk_warnings || result.riskWarnings || [];
const assumptions = audit.assumptions || result.assumptions || [];
const missing = audit.missing_fields || [];
const stats = result.stats || {};
const generator = String(result.generator || '');
const generatorKey = generator === 'llm'
? 'workflows.ai.generatorLLM'
: (generator === 'deterministic'
? 'workflows.ai.generatorServer'
: (generator.indexOf('fallback') !== -1 ? 'workflows.ai.generatorFallback' : 'workflows.ai.generatorUnknown'));
const capabilityText = (result.capabilities || []).map(function (cap) {
return cap.label + (cap.tool_name ? ' -> ' + cap.tool_name : (cap.toolName ? ' -> ' + cap.toolName : ''));
});
target.innerHTML = `
${workflowAiPreviewHtml(graph)}
<div class="workflow-ai-result-grid">
<span>${esc(_t('workflows.ai.resultNodes'))}<strong>${stats.nodes || (graph.nodes || []).length}</strong></span>
<span>${esc(_t('workflows.ai.resultEdges'))}<strong>${stats.edges || (graph.edges || []).length}</strong></span>
<span>${esc(_t('workflows.ai.resultRisk'))}<strong>${risks.length ? esc(_t('workflows.ai.riskHigh')) : esc(_t('workflows.ai.riskLow'))}</strong></span>
<span>${esc(_t('workflows.ai.resultSaveState'))}<strong>${errors.length ? esc(_t('workflows.ai.no')) : esc(_t('workflows.ai.yes'))}</strong></span>
</div>
<div class="workflow-ai-result-section"><strong>${esc(_t(generatorKey))}</strong><p>${esc(generator === 'llm' ? _t('workflows.ai.llmTitle') : (generator === 'deterministic' ? _t('workflows.ai.serverTitle') : _t('workflows.ai.fallbackTitle')))}</p></div>
${capabilityText.length ? '<div class="workflow-ai-result-section"><strong>' + esc(_t('workflows.ai.capabilityTrace')) + '</strong><ul>' + capabilityText.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
${missing.length ? '<div class="workflow-ai-result-section is-attention"><strong>' + esc(_t('workflows.ai.missingFields')) + '</strong><ul>' + missing.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
${assumptions.length ? '<div class="workflow-ai-result-section"><strong>' + esc(_t('workflows.ai.assumptions')) + '</strong><ul>' + assumptions.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
${risks.length ? '<div class="workflow-ai-result-section is-warning"><strong>' + esc(_t('workflows.ai.riskWarnings')) + '</strong><ul>' + risks.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
${errors.length ? '<div class="workflow-ai-result-section is-warning"><strong>' + esc(_t('workflows.audit.validationIssues')) + '</strong><ul>' + errors.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
`;
if (applyBtn) applyBtn.disabled = !!errors.length;
}
function workflowAiApplyGraph(result) {
if (!result || !result.graph) return;
fillWorkflowForm({
id: '',
name: result.meta.name,
description: result.meta.description,
enabled: true,
graph_json: result.graph
});
syncWorkflowMetaIdField(false, result.meta.id);
updateWorkflowCanvasTitle();
if (cy && cy.nodes().length) {
cy.fit(cy.elements(), 60);
const generated = cy.nodes('.ai-generated');
if (generated.length) {
generated.addClass('just-added');
const risky = generated.filter('.high-risk');
if (risky.length) risky.select();
setTimeout(function () {
if (cy) cy.nodes('.ai-generated').removeClass('just-added');
}, 1200);
}
}
}
function workflowAiSortedNodes(graph) {
return (graph.nodes || []).slice().sort(function (a, b) {
const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0;
const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0;
const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0;
const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0;
return ax === bx ? ay - by : ax - bx;
});
}
async function workflowAiAnimateGraph(result) {
if (!result || !result.graph || workflowAiState.animating) return;
workflowAiState.animating = true;
const status = document.getElementById('workflow-ai-canvas-status');
if (status) status.hidden = false;
try {
initCy();
if (!cy) {
workflowAiApplyGraph(result);
return;
}
const graph = parseGraph(result.graph);
syncWorkflowMetaForm({
id: '',
name: result.meta && result.meta.name ? result.meta.name : '',
description: result.meta && result.meta.description ? result.meta.description : '',
enabled: true
});
currentWorkflowId = '';
syncWorkflowMetaIdField(false, result.meta && result.meta.id ? result.meta.id : '');
updateWorkflowCanvasTitle();
resetSequences(graph);
cy.elements().remove();
updateEmptyState();
closeWorkflowDryRunPanel();
renderWorkflowList();
const nodeElements = graphToElements({ nodes: workflowAiSortedNodes(graph), edges: [] });
const edgeElements = graphToElements({ nodes: [], edges: graph.edges || [] });
for (const ele of nodeElements) {
const added = cy.add(ele);
selectWorkflowElement(added);
added.addClass('just-added');
cy.animate({ center: { eles: added }, duration: 180 });
await workflowAiSleep(120);
added.removeClass('just-added');
}
for (const edge of edgeElements) {
const added = cy.add(edge);
added.select();
await workflowAiSleep(80);
added.unselect();
}
if (cy.nodes().length) {
layoutWorkflowGraph(true);
await workflowAiSleep(320);
const risky = cy.nodes('.high-risk');
if (risky.length) {
selectWorkflowElement(risky.first());
} else {
selectWorkflowElement(null);
}
}
updateEmptyState();
} finally {
workflowAiState.animating = false;
if (status) status.hidden = true;
}
}
window.openWorkflowAiModal = function () {
if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return;
workflowAiState.draft = null;
workflowAiState.result = null;
workflowAiSetProgress('', false);
workflowAiRenderResult(null);
const prompt = document.getElementById('workflow-ai-prompt');
const generateBtn = document.getElementById('workflow-ai-generate-btn');
if (generateBtn) generateBtn.disabled = false;
if (typeof openAppModal === 'function') {
openAppModal('workflow-ai-modal', { focusEl: prompt });
}
};
window.closeWorkflowAiModal = function () {
if (typeof closeAppModal === 'function') closeAppModal('workflow-ai-modal');
};
window.useWorkflowAiExample = function (key) {
const prompt = document.getElementById('workflow-ai-prompt');
if (!prompt) return;
prompt.value = _t('workflows.ai.examples.' + key);
prompt.focus();
};
window.generateWorkflowFromNaturalLanguage = async function () {
const promptEl = document.getElementById('workflow-ai-prompt');
const generateBtn = document.getElementById('workflow-ai-generate-btn');
const prompt = promptEl ? promptEl.value.trim() : '';
if (!prompt) {
if (typeof showNotification === 'function') showNotification(_t('workflows.ai.promptRequired'), 'warning');
return;
}
if (generateBtn) {
generateBtn.disabled = true;
generateBtn.textContent = _t('workflows.ai.generating');
}
try {
workflowAiRenderResult(null);
workflowAiSetProgress('understand');
await workflowAiSleep(140);
await loadWorkflowTools();
workflowAiSetProgress('match');
await workflowAiSleep(140);
const options = {
includeObjective: workflowAiOption('workflow-ai-include-objective'),
allowSchedule: workflowAiOption('workflow-ai-allow-schedule'),
allowHighRisk: workflowAiOption('workflow-ai-allow-high-risk')
};
workflowAiSetProgress('draft');
const result = await workflowAiGenerateDraftOnServer(prompt, options);
workflowAiSetProgress('audit');
await workflowAiSleep(120);
workflowAiState.draft = result.graph;
workflowAiState.result = result;
workflowAiRenderResult(result);
workflowAiSetProgress('', true);
if (validateWorkflowGraph(result.graph).length && typeof showNotification === 'function') {
showNotification(_t('workflows.ai.generatedWithIssues'), 'warning');
}
} catch (error) {
workflowAiState.draft = null;
workflowAiState.result = null;
workflowAiSetProgress('', false);
workflowAiRenderResult(null);
if (typeof showNotification === 'function') showNotification(error.message || _t('workflows.ai.generateFailed'), 'error');
} finally {
if (generateBtn) {
generateBtn.disabled = false;
generateBtn.textContent = _t('workflows.ai.generate');
}
}
};
window.applyWorkflowAiDraft = async function () {
if (!workflowAiState.result) return;
const applyBtn = document.getElementById('workflow-ai-apply-btn');
if (applyBtn) {
applyBtn.disabled = true;
applyBtn.textContent = _t('workflows.ai.rendering');
}
closeWorkflowAiModal();
try {
await workflowAiAnimateGraph(workflowAiState.result);
if (typeof showNotification === 'function') showNotification(_t('workflows.ai.applied'), 'success');
} finally {
if (applyBtn) {
applyBtn.disabled = false;
applyBtn.textContent = _t('workflows.ai.apply');
}
}
};
window.refreshWorkflows = async function () {
initCy();
const list = document.getElementById('workflow-list');
@@ -1818,15 +2395,20 @@
window.layoutWorkflowGraph = function (animate) {
if (!cy || !cy.nodes().length) return;
cy.layout({
const layout = cy.layout({
name: 'breadthfirst',
directed: true,
padding: 40,
spacingFactor: 1.25,
animate: animate !== false,
animationDuration: 250
}).run();
cy.fit(undefined, 40);
});
layout.one('layoutstop', function () {
if (cy && cy.elements().length) cy.fit(cy.elements(), 40);
});
layout.run();
if (animate === false && cy.elements().length) cy.fit(cy.elements(), 40);
return layout;
};
window.updateWorkflowSelectedProperty = function () {
@@ -2458,7 +3040,7 @@
if (page && typeof window.applyTranslations === 'function') {
window.applyTranslations(page);
}
['workflow-dry-run-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
['workflow-dry-run-modal', 'workflow-ai-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
const modal = document.getElementById(id);
if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal);
});
+41 -1
View File
@@ -3088,6 +3088,7 @@
<div class="page-header-actions">
<button class="btn-secondary" onclick="refreshWorkflows()" data-i18n="common.refresh">刷新</button>
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowPackageImportModal()" data-i18n="workflows.package.importLocal">导入本地包</button>
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowAiModal()" data-i18n="workflows.ai.open">用自然语言创建</button>
<button class="btn-primary" onclick="newWorkflowDraft()" data-i18n="workflows.newGraph">新建工作流</button>
</div>
</div>
@@ -3139,6 +3140,10 @@
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.4v13.2a1 1 0 0 0 1.55.84l9.2-6.6a1 1 0 0 0 0-1.68l-9.2-6.6A1 1 0 0 0 8 5.4Z"/></svg>
<span class="workflow-toolbar-label" data-i18n="workflows.dryRun">试运行</span>
</button>
<button class="btn-secondary btn-small workflow-toolbar-button workflow-ai-button" data-require-permission="workflow:write" type="button" onclick="openWorkflowAiModal()">
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z"/><path d="M19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z"/></svg>
<span class="workflow-toolbar-label" data-i18n="workflows.ai.generate">生成草稿</span>
</button>
<details class="workflow-more-actions" id="workflow-more-actions" data-require-permission-any="workflow:read workflow:delete">
<summary class="btn-secondary btn-small workflow-more-actions-trigger" aria-haspopup="menu">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
@@ -3164,7 +3169,8 @@
</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" data-i18n="workflows.canvasEmpty">从左侧拖拽节点到画布,或点击节点按钮快速添加</div>
<div id="workflow-canvas-empty" class="workflow-canvas-empty" data-i18n="workflows.canvasEmpty">从左侧拖拽节点到画布,或用自然语言生成工作流</div>
<div id="workflow-ai-canvas-status" class="workflow-ai-canvas-status" hidden data-i18n="workflows.ai.canvasRendering">AI 正在编排画布…</div>
</section>
</main>
<aside class="workflow-properties">
@@ -5568,6 +5574,40 @@
</div>
</form>
</div>
<div id="workflow-ai-modal" class="modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-ai-modal-title" onclick="if(event.target===this)closeWorkflowAiModal()" onkeydown="if(event.key==='Escape')closeWorkflowAiModal()">
<form class="modal-content workflow-ai-modal-content" onsubmit="event.preventDefault(); generateWorkflowFromNaturalLanguage()" onclick="event.stopPropagation()">
<div class="modal-header workflow-ai-modal-header">
<div>
<h2 id="workflow-ai-modal-title" data-i18n="workflows.ai.title">用自然语言创建工作流</h2>
<p class="workflow-ai-subtitle" data-i18n="workflows.ai.subtitle">AI 会生成可编辑草稿,不会自动保存或运行。</p>
</div>
<button type="button" class="modal-close" onclick="closeWorkflowAiModal()" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true" aria-label="关闭"></button>
</div>
<div class="modal-body workflow-ai-modal-body">
<div class="form-group workflow-ai-prompt-field">
<label for="workflow-ai-prompt" data-i18n="workflows.ai.promptLabel">工作流需求</label>
<textarea id="workflow-ai-prompt" class="form-input" rows="5" data-i18n="workflows.ai.promptPlaceholder" data-i18n-attr="placeholder" placeholder="例如:持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告"></textarea>
</div>
<div class="workflow-ai-examples" aria-label="示例需求" data-i18n="workflows.ai.examplesLabel" data-i18n-attr="aria-label">
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('domainMonitor')" data-i18n="workflows.ai.exampleDomain">域名监控</button>
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('reportReview')" data-i18n="workflows.ai.exampleReport">报告审批</button>
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('assetTriage')" data-i18n="workflows.ai.exampleTriage">资产研判</button>
</div>
<div class="workflow-ai-options">
<label><input type="checkbox" id="workflow-ai-include-objective" checked> <span data-i18n="workflows.ai.includeObjective">同时生成 objective 配置</span></label>
<label><input type="checkbox" id="workflow-ai-allow-schedule"> <span data-i18n="workflows.ai.allowSchedule">允许生成定时触发建议</span></label>
<label><input type="checkbox" id="workflow-ai-allow-high-risk"> <span data-i18n="workflows.ai.allowHighRisk">允许包含高风险节点草稿</span></label>
</div>
<div id="workflow-ai-progress" class="workflow-ai-progress" hidden aria-live="polite"></div>
<div id="workflow-ai-result" class="workflow-ai-result" aria-live="polite"></div>
</div>
<div class="modal-footer workflow-ai-modal-footer">
<button type="button" class="btn-secondary btn-small" onclick="closeWorkflowAiModal()" data-i18n="common.cancel">取消</button>
<button id="workflow-ai-apply-btn" type="button" class="btn-secondary btn-small" onclick="applyWorkflowAiDraft()" disabled data-i18n="workflows.ai.apply">渲染到画布</button>
<button id="workflow-ai-generate-btn" type="submit" class="btn-primary btn-small" data-i18n="workflows.ai.generate">生成草稿</button>
</div>
</form>
</div>
<div id="workflow-package-import-modal" class="modal workflow-package-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-package-import-title">
<div class="modal-content workflow-package-modal-content">
<div class="modal-header workflow-package-modal-header">