mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-09 11:19:03 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c345880765 | ||
|
|
a1615143be | ||
|
|
7c45011074 | ||
|
|
eb6bab574f | ||
|
|
b170f2c4b1 | ||
|
|
02216a4660 | ||
|
|
a53e4a4a64 | ||
|
|
3bb8ec57fd | ||
|
|
95c8e3b1a2 | ||
|
|
ed47ee202e | ||
|
|
d3714c9913 | ||
|
|
30513dbbfd | ||
|
|
aff9c9301f | ||
|
|
f7ba7070ca | ||
|
|
5d1f5d2886 | ||
|
|
0f92817261 | ||
|
|
2ec5953416 | ||
|
|
b4fe2e795e | ||
|
|
904d860797 | ||
|
|
dc08199af6 | ||
|
|
84e99220ff | ||
|
|
86f1d10a8b | ||
|
|
5c643a1606 | ||
|
|
b9854192c6 | ||
|
|
5a5762e1d1 | ||
|
|
8882d70393 | ||
|
|
5747ebc612 | ||
|
|
a6b3773f00 | ||
|
|
c2b950ad53 | ||
|
|
0283fff743 | ||
|
|
018835d6b8 | ||
|
|
4b7df4e0f3 | ||
|
|
0324b41a01 | ||
|
|
4a19620137 | ||
|
|
f8110413c0 | ||
|
|
59dc7cf858 | ||
|
|
52595e07e5 | ||
|
|
e0965594bb |
+80
-26
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.7.10"
|
||||
version: "v1.7.13"
|
||||
# 服务器配置
|
||||
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, get_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 一致
|
||||
|
||||
@@ -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 A’s 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 |
|
||||
|--------|------|
|
||||
|
||||
@@ -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 policy;Eino 主链路在 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) 当最终回复。
|
||||
```
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -443,6 +443,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
conversationHandler := handler.NewConversationHandler(db, log.Logger)
|
||||
conversationHandler.SetAudit(auditSvc)
|
||||
conversationHandler.SetTaskStopper(agentHandler)
|
||||
conversationHandler.SetTaskStateProvider(agentHandler)
|
||||
auditHandler := handler.NewAuditHandler(db, auditSvc, log.Logger)
|
||||
robotHandler := handler.NewRobotHandler(cfg, db, agentHandler, log.Logger)
|
||||
robotHandler.SetAudit(auditSvc)
|
||||
@@ -1029,6 +1030,7 @@ func setupRoutes(
|
||||
protected.POST("/conversations", conversationHandler.CreateConversation)
|
||||
protected.GET("/conversations", conversationHandler.ListConversations)
|
||||
protected.GET("/conversations/:id", conversationHandler.GetConversation)
|
||||
protected.GET("/conversations/:id/plan-tasks", conversationHandler.GetConversationPlanTasks)
|
||||
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
||||
protected.GET("/process-details/:id", conversationHandler.GetProcessDetail)
|
||||
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
|
||||
@@ -1362,6 +1364,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 事件总线辅助
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1120,29 +1120,56 @@ const hitlAuditAgentPromptBase = `你是 CyberStrikeAI 人机协同审计 Agent
|
||||
|
||||
你会收到 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)
|
||||
- 仅因「信息不足」——若无上述高危迹象,应 approve 并可在 comment 中提示注意点`
|
||||
以下情形必须 approve(即使看起来“攻击性强”或包含写操作):
|
||||
- 各类漏洞探测与利用 payload(SQL 注入含读写型、命令注入、反序列化、文件包含、路径遍历、SSRF、SSTI、XXE 等)
|
||||
- 文件上传测试,包括上传 webshell、写入 webshell 文件
|
||||
- 命令执行探测、反弹 Shell、dnslog / 数据外带探测
|
||||
- 数据库命令执行、写文件、写计划任务、内存马注入等 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 一致
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 ?`,
|
||||
@@ -1315,6 +1353,39 @@ func (db *DB) AddProcessDetailWithID(messageID, conversationID, eventType, messa
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// UpdateProcessDetailContent 更新流式聚合详情的正文与元数据。使用固定记录 ID,
|
||||
// 避免每个 token 新增一行,同时让页面刷新能读取到尚未结束的规划输出。
|
||||
func (db *DB) UpdateProcessDetailContent(id, message string, data interface{}) error {
|
||||
var dataJSON string
|
||||
if data != nil {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化过程详情数据失败: %w", err)
|
||||
}
|
||||
dataJSON = string(jsonData)
|
||||
}
|
||||
result, err := db.Exec(
|
||||
"UPDATE process_details SET message = ?, data = ? WHERE id = ?",
|
||||
message, dataJSON, strings.TrimSpace(id),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新过程详情失败: %w", err)
|
||||
}
|
||||
if affected, affectedErr := result.RowsAffected(); affectedErr == nil && affected == 0 {
|
||||
return fmt.Errorf("过程详情不存在: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProcessDetail 删除被判定为工具结果回显的临时规划记录。
|
||||
func (db *DB) DeleteProcessDetail(id string) error {
|
||||
_, err := db.Exec("DELETE FROM process_details WHERE id = ?", strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除过程详情失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProcessDetails 获取消息的过程详情
|
||||
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
||||
rows, err := db.Query(
|
||||
@@ -1382,10 +1453,15 @@ type ProcessDetailsSummary struct {
|
||||
ToolCount int `json:"toolCount"`
|
||||
ToolExecutions []ProcessDetailsToolExecution `json:"toolExecutions,omitempty"`
|
||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -1403,6 +1479,54 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
||||
}
|
||||
|
||||
summary := &ProcessDetailsSummary{Total: total}
|
||||
var messageCreatedAt, messageUpdatedAt sql.NullString
|
||||
var messageContent string
|
||||
if err := db.QueryRow(
|
||||
"SELECT created_at, updated_at, content FROM messages WHERE id = ?",
|
||||
messageID,
|
||||
).Scan(&messageCreatedAt, &messageUpdatedAt, &messageContent); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("查询过程详情耗时失败: %w", err)
|
||||
}
|
||||
if messageCreatedAt.Valid {
|
||||
if startedAt := parseDBTime(messageCreatedAt.String); !startedAt.IsZero() {
|
||||
summary.StartedAt = &startedAt
|
||||
}
|
||||
}
|
||||
var terminalEvent, terminalCreatedAt string
|
||||
terminalErr := db.QueryRow(`
|
||||
SELECT event_type, created_at
|
||||
FROM process_details
|
||||
WHERE message_id = ? AND event_type IN ('cancelled', 'timeout', 'error')
|
||||
ORDER BY created_at DESC, rowid DESC
|
||||
LIMIT 1`, messageID).Scan(&terminalEvent, &terminalCreatedAt)
|
||||
if terminalErr != nil && !errors.Is(terminalErr, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("查询过程详情终态失败: %w", terminalErr)
|
||||
}
|
||||
if terminalEvent != "" {
|
||||
switch terminalEvent {
|
||||
case "cancelled":
|
||||
summary.Status = "cancelled"
|
||||
case "timeout":
|
||||
summary.Status = "timeout"
|
||||
default:
|
||||
summary.Status = "failed"
|
||||
}
|
||||
if completedAt := parseDBTime(terminalCreatedAt); !completedAt.IsZero() {
|
||||
summary.CompletedAt = &completedAt
|
||||
}
|
||||
} else if strings.TrimSpace(messageContent) == "处理中..." || strings.TrimSpace(messageContent) == "Processing..." {
|
||||
summary.Status = "running"
|
||||
} else {
|
||||
summary.Status = "completed"
|
||||
if messageUpdatedAt.Valid {
|
||||
if completedAt := parseDBTime(messageUpdatedAt.String); !completedAt.IsZero() {
|
||||
summary.CompletedAt = &completedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
if summary.StartedAt != nil && summary.CompletedAt != nil && !summary.CompletedAt.Before(*summary.StartedAt) {
|
||||
summary.DurationMs = summary.CompletedAt.Sub(*summary.StartedAt).Milliseconds()
|
||||
}
|
||||
if total == 0 {
|
||||
return summary, nil
|
||||
}
|
||||
@@ -1514,6 +1638,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ConversationPlanTask mirrors the public fields persisted by Eino plantask.
|
||||
// Keeping the transport model here avoids coupling the HTTP layer to Eino's
|
||||
// private task type.
|
||||
type ConversationPlanTask struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Blocks []string `json:"blocks,omitempty"`
|
||||
BlockedBy []string `json:"blockedBy,omitempty"`
|
||||
ActiveForm string `json:"activeForm,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
// ListConversationPlanTasks returns the live Eino task board for one
|
||||
// conversation. A missing task directory is the normal state for short or
|
||||
// legacy conversations and therefore returns an empty list.
|
||||
func (db *DB) ListConversationPlanTasks(conversationID string) ([]ConversationPlanTask, error) {
|
||||
return db.ListConversationPlanTasksSince(conversationID, time.Time{})
|
||||
}
|
||||
|
||||
// ListConversationPlanTasksSince limits the board to files written during the
|
||||
// current agent run. The Eino backend intentionally keeps older task files for
|
||||
// model continuity, but the conversation UI must not surface those files before
|
||||
// the new run has called TaskCreate.
|
||||
func (db *DB) ListConversationPlanTasksSince(conversationID string, since time.Time) ([]ConversationPlanTask, error) {
|
||||
if db == nil {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" {
|
||||
return nil, fmt.Errorf("conversation id is required")
|
||||
}
|
||||
base := strings.TrimSpace(db.einoPlantaskBaseDir)
|
||||
if base == "" {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID))
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read conversation plan tasks: %w", err)
|
||||
}
|
||||
|
||||
type numberedTask struct {
|
||||
number int
|
||||
task ConversationPlanTask
|
||||
}
|
||||
numbered := make([]numberedTask, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
idText := strings.TrimSuffix(entry.Name(), ".json")
|
||||
number, parseErr := strconv.Atoi(idText)
|
||||
if parseErr != nil || number < 1 {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() {
|
||||
info, infoErr := entry.Info()
|
||||
if infoErr != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(since) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
content, readErr := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if readErr != nil {
|
||||
if db.logger != nil {
|
||||
db.logger.Debug("读取 Eino 任务文件失败",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("file", entry.Name()),
|
||||
zap.Error(readErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
var task ConversationPlanTask
|
||||
if decodeErr := json.Unmarshal(content, &task); decodeErr != nil {
|
||||
// TaskUpdate writes files concurrently with this read. A partial read
|
||||
// is transient, so skip it and let the next poll recover.
|
||||
if db.logger != nil {
|
||||
db.logger.Debug("解析 Eino 任务文件失败",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("file", entry.Name()),
|
||||
zap.Error(decodeErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(task.ID) == "" {
|
||||
task.ID = idText
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(task.Status), "deleted") {
|
||||
continue
|
||||
}
|
||||
numbered = append(numbered, numberedTask{number: number, task: task})
|
||||
}
|
||||
|
||||
sort.SliceStable(numbered, func(i, j int) bool {
|
||||
return numbered[i].number < numbered[j].number
|
||||
})
|
||||
tasks := make([]ConversationPlanTask, 0, len(numbered))
|
||||
for _, item := range numbered {
|
||||
tasks = append(tasks, item.task)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestListConversationPlanTasksSortedAndToleratesMissingDirectory(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := NewDB(filepath.Join(tmp, "plantask.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
base := filepath.Join(tmp, "skills", ".eino", "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
missing, err := db.ListConversationPlanTasks("missing")
|
||||
if err != nil || len(missing) != 0 {
|
||||
t.Fatalf("missing task board = %#v, err=%v", missing, err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, "conversation-1")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
files := map[string]string{
|
||||
"10.json": `{"id":"10","subject":"最后检查","status":"pending"}`,
|
||||
"2.json": `{"id":"2","subject":"实现接口","status":"in_progress","activeForm":"正在实现接口"}`,
|
||||
"1.json": `{"id":"1","subject":"梳理需求","status":"completed"}`,
|
||||
"bad.json": `{`,
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".highwatermark"), []byte("10"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(highwatermark): %v", err)
|
||||
}
|
||||
|
||||
tasks, err := db.ListConversationPlanTasks("conversation-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasks: %v", err)
|
||||
}
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("tasks = %#v, want 3", tasks)
|
||||
}
|
||||
if tasks[0].ID != "1" || tasks[1].ID != "2" || tasks[2].ID != "10" {
|
||||
t.Fatalf("task order = %q, %q, %q", tasks[0].ID, tasks[1].ID, tasks[2].ID)
|
||||
}
|
||||
if tasks[1].ActiveForm != "正在实现接口" {
|
||||
t.Fatalf("activeForm = %q", tasks[1].ActiveForm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConversationPlanTasksSinceHidesPreviousRunUntilTaskCreate(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := NewDB(filepath.Join(tmp, "plantask-current-run.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
base := filepath.Join(tmp, "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
dir := filepath.Join(base, "conversation-current-run")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "1.json")
|
||||
if err := os.WriteFile(oldPath, []byte(`{"id":"1","subject":"上一轮任务","status":"in_progress"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(old): %v", err)
|
||||
}
|
||||
runStartedAt := time.Now().Add(-time.Second)
|
||||
oldTime := runStartedAt.Add(-time.Minute)
|
||||
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("Chtimes(old): %v", err)
|
||||
}
|
||||
|
||||
tasks, err := db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasksSince(before TaskCreate): %v", err)
|
||||
}
|
||||
if len(tasks) != 0 {
|
||||
t.Fatalf("stale tasks shown before current TaskCreate: %#v", tasks)
|
||||
}
|
||||
|
||||
newPath := filepath.Join(dir, "2.json")
|
||||
if err := os.WriteFile(newPath, []byte(`{"id":"2","subject":"本轮任务","status":"pending"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(new): %v", err)
|
||||
}
|
||||
tasks, err = db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasksSince(after TaskCreate): %v", err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "2" {
|
||||
t.Fatalf("current tasks = %#v, want task 2 only", tasks)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package database
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -22,10 +23,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 +43,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" {
|
||||
@@ -99,6 +106,63 @@ func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
|
||||
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
completedAt := "2026-08-10T08:12:59Z"
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||
"done", startedAt, completedAt, messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update message timing: %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "completed" {
|
||||
t.Fatalf("status = %q, want completed", summary.Status)
|
||||
}
|
||||
if summary.StartedAt == nil || summary.CompletedAt == nil {
|
||||
t.Fatalf("timing missing: %#v", summary)
|
||||
}
|
||||
if want := int64((12*time.Minute + 59*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryTreatsCancelledPlaceholderAsTerminal(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||
"处理中...", startedAt, startedAt, messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update running placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
|
||||
VALUES ('cancelled-detail', ?, ?, 'cancelled', 'interrupted', '{}', '2026-08-10T08:02:05Z')`,
|
||||
messageID, conversationID); err != nil {
|
||||
t.Fatalf("insert cancelled detail: %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "cancelled" {
|
||||
t.Fatalf("status = %q, want cancelled", summary.Status)
|
||||
}
|
||||
if summary.CompletedAt == nil {
|
||||
t.Fatal("cancelled summary should expose a fixed completion time")
|
||||
}
|
||||
if want := int64((2*time.Minute + 5*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func setupProcessDetailsSummaryTest(t *testing.T) (*DB, string, string) {
|
||||
t.Helper()
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "process-details.db"), zap.NewNop())
|
||||
|
||||
@@ -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{})
|
||||
|
||||
+109
-43
@@ -75,8 +75,11 @@ found:
|
||||
|
||||
// responsePlanAgg buffers main-assistant response_stream chunks for one "planning" process_detail row.
|
||||
type responsePlanAgg struct {
|
||||
meta map[string]interface{}
|
||||
b strings.Builder
|
||||
meta map[string]interface{}
|
||||
b strings.Builder
|
||||
detailID string
|
||||
lastPersistAt time.Time
|
||||
lastPersistSize int
|
||||
}
|
||||
|
||||
// thinkingBuf aggregates thinking_stream_* / reasoning_chain_stream_* before flush to process_details.
|
||||
@@ -145,30 +148,36 @@ func responseStreamIterationFromMeta(m map[string]interface{}) int {
|
||||
}
|
||||
}
|
||||
|
||||
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) {
|
||||
func discardPlanningIfEchoesToolResult(respPlan *responsePlanAgg, toolData interface{}) string {
|
||||
if respPlan == nil {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
plan := normalizeProcessDetailText(respPlan.b.String())
|
||||
if plan == "" {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
dataMap, ok := toolData.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
res, ok := dataMap["result"].(string)
|
||||
if !ok {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
r := normalizeProcessDetailText(res)
|
||||
if r == "" {
|
||||
return
|
||||
return ""
|
||||
}
|
||||
if plan == r || strings.HasSuffix(plan, r) {
|
||||
detailID := respPlan.detailID
|
||||
respPlan.meta = nil
|
||||
respPlan.b.Reset()
|
||||
respPlan.detailID = ""
|
||||
respPlan.lastPersistAt = time.Time{}
|
||||
respPlan.lastPersistSize = 0
|
||||
return detailID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AgentHandler Agent处理器
|
||||
@@ -221,6 +230,20 @@ func (h *AgentHandler) CancelRunningTaskForConversation(conversationID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ConversationTaskRuntimeState exposes the authoritative live state and start
|
||||
// time used to scope persisted TaskCreate files to the current run. A task
|
||||
// already entering cancellation must stop driving progress UI immediately.
|
||||
func (h *AgentHandler) ConversationTaskRuntimeState(conversationID string) (bool, time.Time) {
|
||||
if h == nil || h.tasks == nil || strings.TrimSpace(conversationID) == "" {
|
||||
return false, time.Time{}
|
||||
}
|
||||
task := h.tasks.GetTaskSnapshot(strings.TrimSpace(conversationID))
|
||||
if task == nil || !strings.EqualFold(strings.TrimSpace(task.Status), "running") {
|
||||
return false, time.Time{}
|
||||
}
|
||||
return true, task.StartedAt
|
||||
}
|
||||
|
||||
func (h *AgentHandler) cancelRunningMCPToolsForConversation(conversationID string) {
|
||||
if h == nil || h.agent == nil {
|
||||
return
|
||||
@@ -333,17 +356,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/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||
Orchestration string `json:"orchestration,omitempty"`
|
||||
}
|
||||
@@ -668,10 +698,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 +725,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 +869,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)
|
||||
@@ -957,14 +999,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
syncHitlCognition := func() {
|
||||
h.syncHitlCognitionFromProgress(conversationID, assistantMessageID, thinkingStreams, &respPlan)
|
||||
}
|
||||
flushResponsePlan := func() {
|
||||
persistResponsePlan := func(reset bool) {
|
||||
if assistantMessageID == "" {
|
||||
return
|
||||
}
|
||||
content := strings.TrimSpace(respPlan.b.String())
|
||||
if content == "" {
|
||||
respPlan.meta = nil
|
||||
respPlan.b.Reset()
|
||||
if reset {
|
||||
respPlan = responsePlanAgg{}
|
||||
}
|
||||
return
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
@@ -973,13 +1016,26 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
for k, v := range respPlan.meta {
|
||||
data[k] = v
|
||||
}
|
||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
|
||||
var err error
|
||||
if respPlan.detailID == "" {
|
||||
respPlan.detailID, err = h.db.AddProcessDetailWithID(
|
||||
assistantMessageID, conversationID, "planning", content, data,
|
||||
)
|
||||
} else {
|
||||
err = h.db.UpdateProcessDetailContent(respPlan.detailID, content, data)
|
||||
}
|
||||
if err != nil {
|
||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
|
||||
} else {
|
||||
respPlan.lastPersistAt = time.Now()
|
||||
respPlan.lastPersistSize = respPlan.b.Len()
|
||||
}
|
||||
syncHitlCognition()
|
||||
respPlan.meta = nil
|
||||
respPlan.b.Reset()
|
||||
if reset {
|
||||
respPlan = responsePlanAgg{}
|
||||
}
|
||||
}
|
||||
flushResponsePlan := func() { persistResponsePlan(true) }
|
||||
|
||||
flushThinkingStreams := func() {
|
||||
if assistantMessageID == "" {
|
||||
@@ -1049,15 +1105,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
|
||||
deferToolProgressSend := eventType == "tool_call" || eventType == "tool_result"
|
||||
// 流式:写 HTTP SSE;非流式(机器人等):镜像到 taskEventBus 供 Web 订阅。
|
||||
// 工具事件需先落库拿 processDetailId,再向前端发送摘要,避免大 payload 默认进入浏览器。
|
||||
// 主 HTTP SSE 与 taskEventBus 必须同时写入:页面刷新会切断原连接,刷新后的
|
||||
// GET task-events 订阅依赖 eventBus 才能继续收到后续迭代。机器人等无主 SSE
|
||||
// 的来源同样只写 eventBus。工具事件需先落库拿 processDetailId,再发送摘要。
|
||||
if !deferToolProgressSend {
|
||||
clientData := enrichProgressEventData(data, conversationID, assistantMessageID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, clientData)
|
||||
} else {
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
|
||||
// 保存tool_call事件中的参数
|
||||
@@ -1310,6 +1366,14 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
respPlan.meta[k] = v
|
||||
}
|
||||
}
|
||||
// 运行中的主回复不能只保存在内存:刷新会销毁旧页面,新的 task-events
|
||||
// 订阅只能收到未来增量。按时间或增量大小节流更新同一条 planning 记录,
|
||||
// 这样刷新时能从数据库恢复刷新前已经展示的全部文本。
|
||||
if respPlan.lastPersistAt.IsZero() ||
|
||||
time.Since(respPlan.lastPersistAt) >= 300*time.Millisecond ||
|
||||
respPlan.b.Len()-respPlan.lastPersistSize >= 1024 {
|
||||
persistResponsePlan(false)
|
||||
}
|
||||
syncHitlCognition()
|
||||
return
|
||||
}
|
||||
@@ -1420,7 +1484,11 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
eventType != "eino_agent_reply_stream_delta" &&
|
||||
eventType != "eino_agent_reply_stream_end" {
|
||||
if eventType == "tool_result" {
|
||||
discardPlanningIfEchoesToolResult(&respPlan, data)
|
||||
if detailID := discardPlanningIfEchoesToolResult(&respPlan, data); detailID != "" {
|
||||
if err := h.db.DeleteProcessDetail(detailID); err != nil {
|
||||
h.logger.Warn("删除工具结果回显规划失败", zap.Error(err), zap.String("processDetailId", detailID))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 在关键过程事件落库前,先把「规划中」与聚合中的 thinking / reasoning_chain 流落库
|
||||
flushResponsePlan()
|
||||
@@ -1436,17 +1504,15 @@ func (h *AgentHandler) createProgressCallback(runCtx context.Context, cancelRun
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, clientData)
|
||||
} else {
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
} else if deferToolProgressSend {
|
||||
clientData := enrichProgressEventData(summarizeProcessDetailData(eventType, data), conversationID, assistantMessageID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, clientData)
|
||||
} else {
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
h.publishProgressToTaskEventBus(conversationID, eventType, message, clientData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
@@ -51,6 +53,77 @@ func TestCreateProgressCallback_ConcurrentToolEvents(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestCreateProgressCallback_MirrorsWebStreamEvents 页面刷新后 task-events 订阅必须
|
||||
// 继续收到原 Web SSE 任务的后续事件,不能只等数据库最终结果。
|
||||
func TestCreateProgressCallback_MirrorsWebStreamEvents(t *testing.T) {
|
||||
bus := NewTaskEventBus()
|
||||
h := &AgentHandler{logger: zap.NewNop(), config: &config.Config{}, taskEventBus: bus}
|
||||
_, events := bus.Subscribe("conv-refresh-stream")
|
||||
primaryCalls := 0
|
||||
cb := h.createProgressCallback(
|
||||
context.Background(), nil, "conv-refresh-stream", "",
|
||||
func(eventType, message string, data interface{}) { primaryCalls++ },
|
||||
)
|
||||
|
||||
cb("progress", "第 3 轮", map[string]interface{}{"iteration": 3})
|
||||
if primaryCalls != 1 {
|
||||
t.Fatalf("expected primary SSE callback once, got %d", primaryCalls)
|
||||
}
|
||||
select {
|
||||
case payload := <-events:
|
||||
body := string(payload)
|
||||
if !strings.Contains(body, `"type":"progress"`) || !strings.Contains(body, `"conversationId":"conv-refresh-stream"`) {
|
||||
t.Fatalf("unexpected mirrored event: %s", body)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected progress event mirrored to task event bus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProgressCallback_PersistsRunningResponseBeforeDone(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "test.sqlite"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
conv, err := db.CreateConversation("refresh-running", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
asst, err := db.AddMessage(conv.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
|
||||
h := &AgentHandler{logger: zap.NewNop(), db: db}
|
||||
cb := h.createProgressCallback(context.Background(), nil, conv.ID, asst.ID, nil)
|
||||
meta := map[string]interface{}{
|
||||
"streamId": "response-refresh-1",
|
||||
"einoAgent": "cyberstrike-eino-single",
|
||||
"orchestration": "eino_single",
|
||||
}
|
||||
cb("response_start", "", meta)
|
||||
cb("response_delta", "刷新前已生成的第一部分", openai.WithSSEAccumulated(meta, "刷新前已生成的第一部分"))
|
||||
|
||||
details, err := db.GetProcessDetails(asst.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetails: %v", err)
|
||||
}
|
||||
if len(details) != 1 || details[0].EventType != "planning" || details[0].Message != "刷新前已生成的第一部分" {
|
||||
t.Fatalf("expected one running planning snapshot, got %+v", details)
|
||||
}
|
||||
|
||||
longer := "刷新前已生成的第一部分" + strings.Repeat("继续迭代", 300)
|
||||
cb("response_delta", "继续迭代", openai.WithSSEAccumulated(meta, longer))
|
||||
details, err = db.GetProcessDetails(asst.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetails after update: %v", err)
|
||||
}
|
||||
if len(details) != 1 || details[0].Message != longer {
|
||||
t.Fatalf("running snapshot should update in-place, rows=%d len=%d", len(details), len(details[0].Message))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateProgressCallback_FlushesReasoningOnDone 流式推理聚合须在 done/response 时落库,刷新后可回放。
|
||||
func TestCreateProgressCallback_FlushesReasoningOnDone(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"cyberstrike-ai/internal/audit"
|
||||
"cyberstrike-ai/internal/authctx"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
|
||||
@@ -118,8 +119,7 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
||||
}
|
||||
principal := authctx.NewPrincipalWithScopes(access.User.ID, access.User.Username, access.Scope, access.Permissions, access.PermissionScopes)
|
||||
title := safeTruncateString(task.Message, 50)
|
||||
batchMeta := audit.ConversationCreateMeta("batch_task")
|
||||
batchMeta.ProjectID = effectiveProjectID(h.config, queue.ProjectID)
|
||||
batchMeta := batchSubTaskConversationMeta(h.config, queue)
|
||||
conv, err := h.db.CreateConversation(title, batchMeta)
|
||||
if err != nil {
|
||||
h.logger.Error("创建对话失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(err))
|
||||
@@ -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,9 +314,24 @@ 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)
|
||||
}
|
||||
|
||||
func batchSubTaskConversationMeta(cfg *config.Config, queue *BatchTaskQueue) database.ConversationCreateMeta {
|
||||
meta := audit.ConversationCreateMeta("batch_task")
|
||||
if queue == nil {
|
||||
meta.ProjectID = effectiveProjectID(cfg, "")
|
||||
return meta
|
||||
}
|
||||
meta.ProjectID = effectiveProjectID(cfg, queue.ProjectID)
|
||||
meta.RoleName = strings.TrimSpace(queue.Role)
|
||||
return meta
|
||||
}
|
||||
|
||||
func (h *AgentHandler) handleBatchSubTaskRunError(
|
||||
queueID string,
|
||||
task *BatchTask,
|
||||
|
||||
@@ -61,6 +61,18 @@ func TestBatchQueueExecutionShouldStop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchSubTaskConversationMetaKeepsQueueRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
meta := batchSubTaskConversationMeta(nil, &BatchTaskQueue{Role: " 渗透测试 "})
|
||||
if meta.Source != "batch_task" {
|
||||
t.Fatalf("expected batch_task source, got %q", meta.Source)
|
||||
}
|
||||
if meta.RoleName != "渗透测试" {
|
||||
t.Fatalf("expected queue role to be stored on child conversation, got %q", meta.RoleName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteQueueBlockedWhileExecutorActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := NewBatchTaskManager(zap.NewNop())
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/audit"
|
||||
"cyberstrike-ai/internal/database"
|
||||
@@ -18,12 +19,20 @@ type ConversationTaskStopper interface {
|
||||
CancelRunningTaskForConversation(conversationID string)
|
||||
}
|
||||
|
||||
// ConversationTaskStateProvider reports whether the in-memory agent task for
|
||||
// a conversation is still genuinely running. Plan files may survive a service
|
||||
// restart or cancellation, so their status alone is not authoritative.
|
||||
type ConversationTaskStateProvider interface {
|
||||
ConversationTaskRuntimeState(conversationID string) (running bool, startedAt time.Time)
|
||||
}
|
||||
|
||||
// ConversationHandler 对话处理器
|
||||
type ConversationHandler struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
audit *audit.Service
|
||||
taskStopper ConversationTaskStopper
|
||||
taskState ConversationTaskStateProvider
|
||||
}
|
||||
|
||||
// SetAudit wires platform audit logging.
|
||||
@@ -36,6 +45,12 @@ func (h *ConversationHandler) SetTaskStopper(stopper ConversationTaskStopper) {
|
||||
h.taskStopper = stopper
|
||||
}
|
||||
|
||||
// SetTaskStateProvider wires the live agent task registry used by supplemental
|
||||
// conversation UI such as the agent-maintained plan list.
|
||||
func (h *ConversationHandler) SetTaskStateProvider(provider ConversationTaskStateProvider) {
|
||||
h.taskState = provider
|
||||
}
|
||||
|
||||
// NewConversationHandler 创建新的对话处理器
|
||||
func NewConversationHandler(db *database.DB, logger *zap.Logger) *ConversationHandler {
|
||||
return &ConversationHandler{
|
||||
@@ -206,6 +221,70 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, conv)
|
||||
}
|
||||
|
||||
// GetConversationPlanTasks returns the task list maintained by the agent's
|
||||
// TaskCreate/TaskUpdate tools for this conversation.
|
||||
func (h *ConversationHandler) GetConversationPlanTasks(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
session, ok := security.CurrentSession(c)
|
||||
if !ok || !h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该对话"})
|
||||
return
|
||||
}
|
||||
if _, err := h.db.GetConversationLite(id); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
|
||||
return
|
||||
}
|
||||
running := false
|
||||
startedAt := time.Time{}
|
||||
if h.taskState != nil {
|
||||
running, startedAt = h.taskState.ConversationTaskRuntimeState(id)
|
||||
}
|
||||
if !running {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": []database.ConversationPlanTask{}, "total": 0,
|
||||
"completed": 0, "activeStep": 0, "running": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
tasks, err := h.db.ListConversationPlanTasksSince(id, startedAt)
|
||||
if err != nil {
|
||||
h.logger.Error("获取对话任务列表失败", zap.String("conversationId", id), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取任务列表失败"})
|
||||
return
|
||||
}
|
||||
|
||||
completed := 0
|
||||
activeStep := 0
|
||||
for i, task := range tasks {
|
||||
status := strings.ToLower(strings.TrimSpace(task.Status))
|
||||
if status == "completed" {
|
||||
completed++
|
||||
}
|
||||
if activeStep == 0 && status == "in_progress" {
|
||||
activeStep = i + 1
|
||||
}
|
||||
}
|
||||
if activeStep == 0 {
|
||||
for i, task := range tasks {
|
||||
if strings.ToLower(strings.TrimSpace(task.Status)) != "completed" {
|
||||
activeStep = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if activeStep == 0 && len(tasks) > 0 {
|
||||
activeStep = len(tasks)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"tasks": tasks,
|
||||
"total": len(tasks),
|
||||
"completed": completed,
|
||||
"activeStep": activeStep,
|
||||
"running": true,
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
defaultProcessDetailsPageLimit = 50
|
||||
maxProcessDetailsPageLimit = 500
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type staticConversationTaskState struct {
|
||||
running bool
|
||||
startedAt time.Time
|
||||
}
|
||||
|
||||
func (s staticConversationTaskState) ConversationTaskRuntimeState(string) (bool, time.Time) {
|
||||
return s.running, s.startedAt
|
||||
}
|
||||
|
||||
func TestGetConversationPlanTasksRequiresAccessAndReportsProgress(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
conversation, err := db.CreateConversation("plan", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
user, err := db.CreateRBACUser("plan-user", "Plan User", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRBACUser: %v", err)
|
||||
}
|
||||
base := filepath.Join(tmp, "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
dir := filepath.Join(base, conversation.ID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
for name, content := range map[string]string{
|
||||
"1.json": `{"id":"1","subject":"完成项","status":"completed"}`,
|
||||
"2.json": `{"id":"2","subject":"当前项","status":"in_progress"}`,
|
||||
"3.json": `{"id":"3","subject":"等待项","status":"pending"}`,
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewConversationHandler(db, zap.NewNop())
|
||||
handler.SetTaskStateProvider(staticConversationTaskState{running: true})
|
||||
request := func() *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: conversation.ID}}
|
||||
c.Set(security.ContextSessionKey, security.Session{
|
||||
UserID: user.ID,
|
||||
Scope: database.RBACScopeAssigned,
|
||||
})
|
||||
handler.GetConversationPlanTasks(c)
|
||||
return w
|
||||
}
|
||||
|
||||
w := request()
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unassigned status = %d, want %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
|
||||
t.Fatalf("AssignResourceToUser: %v", err)
|
||||
}
|
||||
w = request()
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("assigned status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Total int `json:"total"`
|
||||
Completed int `json:"completed"`
|
||||
ActiveStep int `json:"activeStep"`
|
||||
Tasks []database.ConversationPlanTask `json:"tasks"`
|
||||
Running bool `json:"running"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Total != 3 || response.Completed != 1 || response.ActiveStep != 2 || !response.Running {
|
||||
t.Fatalf("progress = %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConversationPlanTasksReportsStoppedLiveTask(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "conversation-plantask-stopped.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
conversation, err := db.CreateConversation("stopped plan", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
user, err := db.CreateRBACUser("stopped-plan-user", "Stopped Plan User", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRBACUser: %v", err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "conversation", conversation.ID); err != nil {
|
||||
t.Fatalf("AssignResourceToUser: %v", err)
|
||||
}
|
||||
base := filepath.Join(tmp, "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
dir := filepath.Join(base, conversation.ID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "1.json"), []byte(`{"id":"1","subject":"残留项","status":"in_progress"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
handler := NewConversationHandler(db, zap.NewNop())
|
||||
handler.SetTaskStateProvider(staticConversationTaskState{running: false})
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/conversations/"+conversation.ID+"/plan-tasks", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: conversation.ID}}
|
||||
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
|
||||
handler.GetConversationPlanTasks(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Running bool `json:"running"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if response.Running || response.Total != 0 {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() == "分组名称已存在" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+280
-57
@@ -74,6 +74,7 @@ CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
||||
tool_call_id TEXT,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL,
|
||||
reviewer TEXT NOT NULL DEFAULT 'human',
|
||||
decision TEXT,
|
||||
decision_comment TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
@@ -98,15 +99,179 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
|
||||
// On startup, cancel all orphaned pending interrupts from previous process.
|
||||
// Their in-memory channels are gone, so they can never be resolved.
|
||||
res, err := m.db.Exec(`UPDATE hitl_interrupts SET status='cancelled', decision='reject',
|
||||
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP WHERE status='pending'`)
|
||||
decision_comment='process restarted', decided_at=CURRENT_TIMESTAMP, decided_by='system'
|
||||
WHERE status='pending'`)
|
||||
if err != nil {
|
||||
m.logger.Warn("failed to cancel orphaned HITL interrupts", zap.Error(err))
|
||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||
m.logger.Info("cancelled orphaned HITL interrupts from previous process", zap.Int64("count", n))
|
||||
}
|
||||
if err := m.reconcileRestartInterruptedMessages(); err != nil {
|
||||
m.logger.Warn("failed to finalize assistant messages interrupted by process restart", zap.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileRestartInterruptedMessages completes durable terminal state for
|
||||
// historical assistant placeholders that have explicit evidence of being over:
|
||||
// a terminal HITL/process event, or a later message in the same conversation.
|
||||
// The evidence requirement avoids rewriting a placeholder that could still be
|
||||
// recoverable by another runtime.
|
||||
func (m *HITLManager) reconcileRestartInterruptedMessages() error {
|
||||
rows, err := m.db.Query(`
|
||||
SELECT msg.id, msg.conversation_id,
|
||||
COALESCE((
|
||||
SELECT pd.event_type
|
||||
FROM process_details pd
|
||||
WHERE pd.message_id = msg.id
|
||||
AND pd.event_type IN ('cancelled', 'timeout', 'error')
|
||||
ORDER BY pd.created_at DESC LIMIT 1
|
||||
), '') AS terminal_event,
|
||||
COALESCE((
|
||||
SELECT hi.status
|
||||
FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||
), '') AS hitl_status,
|
||||
COALESCE((
|
||||
SELECT hi.decision
|
||||
FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||
), '') AS hitl_decision,
|
||||
COALESCE((
|
||||
SELECT hi.decision_comment
|
||||
FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
ORDER BY COALESCE(hi.decided_at, hi.created_at) DESC LIMIT 1
|
||||
), '') AS decision_comment,
|
||||
COALESCE((
|
||||
SELECT MAX(COALESCE(hi.decided_at, hi.created_at))
|
||||
FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
), (
|
||||
SELECT MIN(later.created_at)
|
||||
FROM messages later
|
||||
WHERE later.conversation_id = msg.conversation_id
|
||||
AND later.created_at > msg.created_at
|
||||
), (
|
||||
SELECT MAX(pd.created_at)
|
||||
FROM process_details pd
|
||||
WHERE pd.message_id = msg.id
|
||||
), msg.updated_at, msg.created_at) AS interrupted_at
|
||||
FROM messages msg
|
||||
WHERE msg.role = 'assistant'
|
||||
AND TRIM(msg.content) IN ('处理中...', 'Processing...')
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM hitl_interrupts hi
|
||||
WHERE hi.message_id = msg.id
|
||||
AND (hi.status IN ('cancelled', 'timeout')
|
||||
OR (hi.status = 'decided' AND hi.decision = 'reject'))
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM process_details pd
|
||||
WHERE pd.message_id = msg.id
|
||||
AND pd.event_type IN ('cancelled', 'timeout', 'error')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM messages later
|
||||
WHERE later.conversation_id = msg.conversation_id
|
||||
AND later.created_at > msg.created_at
|
||||
)
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type interruptedMessage struct {
|
||||
messageID string
|
||||
conversationID string
|
||||
terminalEvent string
|
||||
hitlStatus string
|
||||
hitlDecision string
|
||||
decisionComment string
|
||||
interruptedAt string
|
||||
}
|
||||
var interrupted []interruptedMessage
|
||||
for rows.Next() {
|
||||
var item interruptedMessage
|
||||
if err := rows.Scan(&item.messageID, &item.conversationID, &item.terminalEvent,
|
||||
&item.hitlStatus, &item.hitlDecision, &item.decisionComment, &item.interruptedAt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
interrupted = append(interrupted, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(interrupted) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := m.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, item := range interrupted {
|
||||
eventType := strings.ToLower(strings.TrimSpace(item.terminalEvent))
|
||||
decision := strings.ToLower(strings.TrimSpace(item.hitlDecision))
|
||||
comment := strings.ToLower(strings.TrimSpace(item.decisionComment))
|
||||
if eventType == "" {
|
||||
if strings.EqualFold(strings.TrimSpace(item.hitlStatus), "timeout") || strings.Contains(comment, "timeout") {
|
||||
eventType = "timeout"
|
||||
} else {
|
||||
eventType = "cancelled"
|
||||
}
|
||||
}
|
||||
|
||||
notice := "任务因服务重启已中断。"
|
||||
reason := "process_restarted"
|
||||
switch eventType {
|
||||
case "timeout":
|
||||
notice = "任务等待审批超时,已自动拒绝。"
|
||||
reason = "hitl_timeout"
|
||||
case "error":
|
||||
notice = "任务执行失败,已停止。"
|
||||
reason = "execution_error"
|
||||
case "cancelled":
|
||||
if decision == "reject" && comment != "process restarted" {
|
||||
notice = "任务审批已拒绝,执行已停止。"
|
||||
reason = "hitl_rejected"
|
||||
} else if comment == "process restarted" {
|
||||
notice = "任务因服务重启已中断,审批已取消。"
|
||||
}
|
||||
default:
|
||||
eventType = "cancelled"
|
||||
}
|
||||
detailData, _ := json.Marshal(map[string]string{"reason": reason, "status": eventType})
|
||||
result, err := tx.Exec(`
|
||||
UPDATE messages
|
||||
SET content = ?, updated_at = ?
|
||||
WHERE id = ? AND TRIM(content) IN ('处理中...', 'Processing...')`,
|
||||
notice, item.interruptedAt, item.messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, _ := result.RowsAffected()
|
||||
if updated == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM process_details
|
||||
WHERE message_id = ? AND event_type IN ('cancelled', 'timeout', 'error')
|
||||
)`, uuid.NewString(), item.messageID, item.conversationID, eventType, notice, string(detailData),
|
||||
item.interruptedAt, item.messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func normalizeHitlMode(mode string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(mode))
|
||||
if v == "" {
|
||||
@@ -234,13 +399,14 @@ func (m *HITLManager) NeedsToolApproval(conversationID, toolName string) bool {
|
||||
return need
|
||||
}
|
||||
|
||||
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload string) (*pendingInterrupt, error) {
|
||||
func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID, mode, toolName, toolCallID, payload, reviewer string) (*pendingInterrupt, error) {
|
||||
now := time.Now()
|
||||
id := "hitl_" + strings.ReplaceAll(uuid.New().String(), "-", "")
|
||||
reviewer = normalizeHitlReviewer(reviewer)
|
||||
if _, err := m.db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
||||
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, now); err != nil {
|
||||
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, reviewer, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
|
||||
id, conversationID, assistantMessageID, mode, toolName, toolCallID, payload, reviewer, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 刷新页面后侧栏依赖 DB 配置;若仅内存 Activate 未落库,会导致「有待审批却显示关闭」
|
||||
@@ -253,9 +419,12 @@ func (m *HITLManager) CreatePendingInterrupt(conversationID, assistantMessageID,
|
||||
ToolCallID: toolCallID,
|
||||
decideCh: make(chan hitlDecision, 1),
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.pending[id] = p
|
||||
m.mu.Unlock()
|
||||
// Agent 审查不会等待人工决策,也不应进入人工审批的内存待办队列。
|
||||
if reviewer != "audit_agent" {
|
||||
m.mu.Lock()
|
||||
m.pending[id] = p
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -471,66 +640,107 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
||||
return nil, nil
|
||||
}
|
||||
h.enrichHitlApprovalPayload(conversationID, assistantMessageID, payload)
|
||||
approvalStartedAt := time.Now().UTC()
|
||||
timeoutSeconds := int(cfg.Timeout / time.Second)
|
||||
var approvalExpiresAt *time.Time
|
||||
if timeoutSeconds > 0 {
|
||||
expiresAt := approvalStartedAt.Add(cfg.Timeout)
|
||||
approvalExpiresAt = &expiresAt
|
||||
}
|
||||
payload["hitlApproval"] = map[string]interface{}{
|
||||
"createdAt": approvalStartedAt,
|
||||
"timeoutSeconds": timeoutSeconds,
|
||||
"expiresAt": approvalExpiresAt,
|
||||
}
|
||||
payloadRaw, _ := json.Marshal(payload)
|
||||
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw))
|
||||
p, err := h.hitlManager.CreatePendingInterrupt(conversationID, assistantMessageID, cfg.Mode, toolName, toolCallID, string(payloadRaw), cfg.Reviewer)
|
||||
if err != nil {
|
||||
h.logger.Warn("创建 HITL 中断失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
emitHITL := func(eventType, message string, eventData map[string]interface{}) {
|
||||
clientData := enrichProgressEventData(eventData, conversationID, assistantMessageID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc(eventType, message, clientData)
|
||||
}
|
||||
if strings.TrimSpace(assistantMessageID) != "" && h.db != nil {
|
||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, clientData); err != nil {
|
||||
h.logger.Warn("保存 HITL 过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Reviewer == "audit_agent" {
|
||||
emitHITL("hitl_audit_agent_started", "审计 Agent 正在审查此请求", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"reviewer": "audit_agent",
|
||||
"status": "audit_running",
|
||||
"payload": payload,
|
||||
})
|
||||
ad := h.auditAgentReview(runCtx, cfg.Mode, toolName, payload)
|
||||
now := time.Now()
|
||||
_, _ = h.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=?, decided_by='audit_agent' WHERE id=?`,
|
||||
ad.Decision, ad.Comment, now, p.InterruptID)
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
|
||||
emitHITL("hitl_audit_agent", "审计 Agent 已裁决", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"status": "decided",
|
||||
"decision": ad.Decision,
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
"reviewer": "audit_agent",
|
||||
})
|
||||
if ad.Decision == "reject" {
|
||||
emitHITL("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"decision": ad.Decision,
|
||||
"decision": "reject",
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
"reviewer": "audit_agent",
|
||||
})
|
||||
}
|
||||
if ad.Decision == "reject" {
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_rejected", "审计 Agent 拒绝本次工具调用", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": ad.Comment,
|
||||
"decidedBy": "audit_agent",
|
||||
})
|
||||
}
|
||||
return &ad, nil
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
})
|
||||
}
|
||||
emitHITL("hitl_resumed", "审计 Agent 已通过,继续执行", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"decision": "approve",
|
||||
"comment": ad.Comment,
|
||||
"editedArgs": ad.EditedArguments,
|
||||
"decidedBy": "audit_agent",
|
||||
"reviewer": "audit_agent",
|
||||
})
|
||||
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||
return &ad, nil
|
||||
}
|
||||
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"mode": cfg.Mode,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
emitHITL("hitl_interrupt", "命中人机协同审批", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"mode": cfg.Mode,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"reviewer": "human",
|
||||
"status": "pending",
|
||||
"createdAt": approvalStartedAt,
|
||||
"timeoutSeconds": timeoutSeconds,
|
||||
"expiresAt": approvalExpiresAt,
|
||||
"payload": payload,
|
||||
})
|
||||
d, waitErr := h.hitlManager.waitDecision(runCtx, p, cfg.Timeout)
|
||||
if waitErr != nil {
|
||||
if cancelRun != nil && (errors.Is(waitErr, context.Canceled) || errors.Is(waitErr, context.DeadlineExceeded)) {
|
||||
@@ -550,28 +760,41 @@ func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun contex
|
||||
}
|
||||
if d.Decision == "reject" {
|
||||
rejectMsg := "人工拒绝本次工具调用,模型将基于反馈继续迭代"
|
||||
if strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout") {
|
||||
timedOut := strings.Contains(strings.ToLower(strings.TrimSpace(d.Comment)), "timeout")
|
||||
if timedOut {
|
||||
rejectMsg = "审批超时,安全起见已自动拒绝,模型将基于反馈继续迭代"
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_rejected", rejectMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"comment": d.Comment,
|
||||
})
|
||||
status := "decided"
|
||||
decidedBy := "human"
|
||||
if timedOut {
|
||||
status = "timeout"
|
||||
decidedBy = "system"
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
if sendEventFunc != nil {
|
||||
sendEventFunc("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
|
||||
emitHITL("hitl_rejected", rejectMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"status": status,
|
||||
"decision": "reject",
|
||||
"comment": d.Comment,
|
||||
"editedArgs": d.EditedArguments,
|
||||
"decidedBy": decidedBy,
|
||||
"reviewer": "human",
|
||||
})
|
||||
return &d, nil
|
||||
}
|
||||
emitHITL("hitl_resumed", "人工确认通过,继续执行", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"interruptId": p.InterruptID,
|
||||
"toolName": toolName,
|
||||
"toolCallId": toolCallID,
|
||||
"mode": cfg.Mode,
|
||||
"decision": "approve",
|
||||
"comment": d.Comment,
|
||||
"editedArgs": d.EditedArguments,
|
||||
"reviewer": "human",
|
||||
})
|
||||
h.hitlManager.TrackApprovedHitlExecution(p.InterruptID, conversationID, toolName, toolCallID)
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
@@ -39,11 +39,14 @@ func normalizeHitlDecidedBy(v string) string {
|
||||
|
||||
func (m *HITLManager) migrateHitlSchemaColumns() {
|
||||
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN decided_by TEXT NOT NULL DEFAULT 'human'`)
|
||||
_, _ = m.db.Exec(`ALTER TABLE hitl_interrupts ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET reviewer='audit_agent'
|
||||
WHERE COALESCE(decided_by, '') IN ('audit_agent', 'agent', 'ai')`)
|
||||
_, _ = m.db.Exec(`ALTER TABLE hitl_conversation_configs ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'`)
|
||||
}
|
||||
|
||||
func hitlInterruptRowToMap(
|
||||
id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string,
|
||||
id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string,
|
||||
messageID sql.NullString,
|
||||
decision, comment sql.NullString,
|
||||
createdAt time.Time,
|
||||
@@ -62,6 +65,7 @@ func hitlInterruptRowToMap(
|
||||
"toolCallId": toolCallID,
|
||||
"payload": payload,
|
||||
"status": rowStatus,
|
||||
"reviewer": reviewer,
|
||||
"decision": decision.String,
|
||||
"comment": comment.String,
|
||||
"decidedBy": decidedBy,
|
||||
@@ -77,7 +81,7 @@ func hitlInterruptRowToMap(
|
||||
|
||||
func (h *AgentHandler) buildHitlListQuery(logs bool) (string, []interface{}) {
|
||||
where, args := h.buildHitlLogsWhere(logs)
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, COALESCE(reviewer,'human'), decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts` + where
|
||||
return q, args
|
||||
}
|
||||
|
||||
@@ -87,7 +91,9 @@ func (h *AgentHandler) buildHitlLogsWhere(logs bool) (string, []interface{}) {
|
||||
if logs {
|
||||
q += " AND status != 'pending'"
|
||||
} else {
|
||||
q += " AND status = 'pending'"
|
||||
// 该接口只返回真正等待用户操作的人工审批。Agent 审查即使正在运行,
|
||||
// 也不应触发弹窗、倒计时或项目待审批计数。
|
||||
q += " AND status = 'pending' AND COALESCE(reviewer,'human') = 'human'"
|
||||
}
|
||||
return q, args
|
||||
}
|
||||
@@ -131,15 +137,15 @@ func (h *AgentHandler) appendHitlListFilters(q string, args []interface{}, c *gi
|
||||
func (h *AgentHandler) scanHitlInterruptRows(rows *sql.Rows) ([]map[string]interface{}, error) {
|
||||
items := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
||||
var id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string
|
||||
var messageID sql.NullString
|
||||
var decision, comment sql.NullString
|
||||
var createdAt time.Time
|
||||
var decidedAt sql.NullTime
|
||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
|
||||
if err := rows.Scan(&id, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &reviewer, &decision, &comment, &decidedBy, &createdAt, &decidedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
items = append(items, hitlInterruptRowToMap(id, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -252,13 +258,13 @@ func (h *AgentHandler) GetHITLLog(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
|
||||
return
|
||||
}
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
|
||||
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy string
|
||||
q := `SELECT id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, COALESCE(reviewer,'human'), decision, decision_comment, COALESCE(decided_by,'human'), created_at, decided_at FROM hitl_interrupts WHERE id = ?`
|
||||
var rowID, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy string
|
||||
var messageID sql.NullString
|
||||
var decision, comment sql.NullString
|
||||
var createdAt time.Time
|
||||
var decidedAt sql.NullTime
|
||||
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
|
||||
err := h.db.QueryRow(q, id).Scan(&rowID, &cid, &messageID, &mode, &toolName, &toolCallID, &payload, &rowStatus, &reviewer, &decision, &comment, &decidedBy, &createdAt, &decidedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
@@ -271,7 +277,7 @@ func (h *AgentHandler) GetHITLLog(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
c.JSON(http.StatusOK, hitlInterruptRowToMap(rowID, cid, mode, toolName, toolCallID, payload, rowStatus, reviewer, decidedBy, messageID, decision, comment, createdAt, decidedAt))
|
||||
}
|
||||
|
||||
func (h *AgentHandler) filterAllowedHitlInterruptIDs(c *gin.Context, ids []string) ([]string, error) {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestEnsureSchemaCancelsPendingInterruptsAfterRestart(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-restart.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
manager := NewHITLManager(db, zap.NewNop())
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("restart interrupted", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
message, err := db.AddMessage(conversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create assistant placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, tool_call_id, payload, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)`,
|
||||
"restart-pending", conversation.ID, message.ID, "approval", "browser", "tool-call-1", `{}`); err != nil {
|
||||
t.Fatalf("insert pending interrupt: %v", err)
|
||||
}
|
||||
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("reconcile restart: %v", err)
|
||||
}
|
||||
|
||||
var status, decision, comment, decidedBy string
|
||||
var decidedAt sql.NullTime
|
||||
if err := db.QueryRow(`SELECT status, decision, decision_comment, decided_by, decided_at
|
||||
FROM hitl_interrupts WHERE id = ?`, "restart-pending").
|
||||
Scan(&status, &decision, &comment, &decidedBy, &decidedAt); err != nil {
|
||||
t.Fatalf("query reconciled interrupt: %v", err)
|
||||
}
|
||||
if status != "cancelled" || decision != "reject" || comment != "process restarted" {
|
||||
t.Fatalf("unexpected restart decision: status=%q decision=%q comment=%q", status, decision, comment)
|
||||
}
|
||||
if decidedBy != "system" {
|
||||
t.Fatalf("decided_by=%q, want system", decidedBy)
|
||||
}
|
||||
if !decidedAt.Valid {
|
||||
t.Fatal("decided_at should be set after restart reconciliation")
|
||||
}
|
||||
|
||||
var content string
|
||||
var updatedAt sql.NullTime
|
||||
if err := db.QueryRow(`SELECT content, updated_at FROM messages WHERE id = ?`, message.ID).
|
||||
Scan(&content, &updatedAt); err != nil {
|
||||
t.Fatalf("query reconciled assistant message: %v", err)
|
||||
}
|
||||
if content != "任务因服务重启已中断,审批已取消。" {
|
||||
t.Fatalf("assistant content=%q, want restart interruption notice", content)
|
||||
}
|
||||
if !updatedAt.Valid {
|
||||
t.Fatal("assistant updated_at should be set to the interruption time")
|
||||
}
|
||||
var eventType, eventMessage string
|
||||
if err := db.QueryRow(`SELECT event_type, message FROM process_details WHERE message_id = ?`, message.ID).
|
||||
Scan(&eventType, &eventMessage); err != nil {
|
||||
t.Fatalf("query restart cancellation process detail: %v", err)
|
||||
}
|
||||
if eventType != "cancelled" || eventMessage != content {
|
||||
t.Fatalf("unexpected terminal detail: type=%q message=%q", eventType, eventMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSchemaFinalizesOnlyHistoricalPlaceholdersWithTerminalEvidence(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-history.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
manager := NewHITLManager(db, zap.NewNop())
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
|
||||
supersededConversation, err := db.CreateConversation("superseded placeholder", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create superseded conversation: %v", err)
|
||||
}
|
||||
superseded, err := db.AddMessage(supersededConversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create superseded placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.AddMessage(supersededConversation.ID, "user", "继续", nil); err != nil {
|
||||
t.Fatalf("create later message: %v", err)
|
||||
}
|
||||
|
||||
timeoutConversation, err := db.CreateConversation("timeout placeholder", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create timeout conversation: %v", err)
|
||||
}
|
||||
timedOut, err := db.AddMessage(timeoutConversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create timeout placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
|
||||
VALUES (?, ?, ?, 'approval', 'browser', 'timeout', 'reject', 'HITL timeout auto-reject for safety', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
"timeout-interrupt", timeoutConversation.ID, timedOut.ID); err != nil {
|
||||
t.Fatalf("insert timeout interrupt: %v", err)
|
||||
}
|
||||
|
||||
rejectedConversation, err := db.CreateConversation("rejected placeholder", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create rejected conversation: %v", err)
|
||||
}
|
||||
rejected, err := db.AddMessage(rejectedConversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create rejected placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, message_id, mode, tool_name, status, decision, decision_comment, created_at, decided_at)
|
||||
VALUES (?, ?, ?, 'approval', 'exec', 'decided', 'reject', 'user rejected', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
|
||||
"rejected-interrupt", rejectedConversation.ID, rejected.ID); err != nil {
|
||||
t.Fatalf("insert rejected interrupt: %v", err)
|
||||
}
|
||||
|
||||
activeConversation, err := db.CreateConversation("potentially active placeholder", database.ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("create active conversation: %v", err)
|
||||
}
|
||||
potentiallyActive, err := db.AddMessage(activeConversation.ID, "assistant", "处理中...", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create potentially active placeholder: %v", err)
|
||||
}
|
||||
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("reconcile historical placeholders: %v", err)
|
||||
}
|
||||
|
||||
assertTerminal := func(messageID, wantContent, wantEvent string) {
|
||||
t.Helper()
|
||||
var content, eventType string
|
||||
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, messageID).Scan(&content); err != nil {
|
||||
t.Fatalf("query message %s: %v", messageID, err)
|
||||
}
|
||||
if content != wantContent {
|
||||
t.Fatalf("message %s content=%q, want %q", messageID, content, wantContent)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT event_type FROM process_details WHERE message_id = ?
|
||||
AND event_type IN ('cancelled', 'timeout', 'error')`, messageID).Scan(&eventType); err != nil {
|
||||
t.Fatalf("query terminal detail %s: %v", messageID, err)
|
||||
}
|
||||
if eventType != wantEvent {
|
||||
t.Fatalf("message %s event=%q, want %q", messageID, eventType, wantEvent)
|
||||
}
|
||||
}
|
||||
assertTerminal(superseded.ID, "任务因服务重启已中断。", "cancelled")
|
||||
assertTerminal(timedOut.ID, "任务等待审批超时,已自动拒绝。", "timeout")
|
||||
assertTerminal(rejected.ID, "任务审批已拒绝,执行已停止。", "cancelled")
|
||||
|
||||
var activeContent string
|
||||
if err := db.QueryRow(`SELECT content FROM messages WHERE id = ?`, potentiallyActive.ID).Scan(&activeContent); err != nil {
|
||||
t.Fatalf("query potentially active message: %v", err)
|
||||
}
|
||||
if activeContent != "处理中..." {
|
||||
t.Fatalf("potentially active message was rewritten to %q", activeContent)
|
||||
}
|
||||
var terminalCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM process_details WHERE message_id = ?
|
||||
AND event_type IN ('cancelled', 'timeout', 'error')`, potentiallyActive.ID).Scan(&terminalCount); err != nil {
|
||||
t.Fatalf("count active terminal details: %v", err)
|
||||
}
|
||||
if terminalCount != 0 {
|
||||
t.Fatalf("potentially active message got %d terminal details", terminalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditAgentInterruptIsNotHumanPendingWork(t *testing.T) {
|
||||
db, err := database.NewDB(filepath.Join(t.TempDir(), "hitl-reviewer.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
manager := NewHITLManager(db, zap.NewNop())
|
||||
if err := manager.EnsureSchema(); err != nil {
|
||||
t.Fatalf("ensure schema: %v", err)
|
||||
}
|
||||
|
||||
audit, err := manager.CreatePendingInterrupt("conversation-audit", "message-audit", "review_edit", "exec", "call-audit", `{}`, "audit_agent")
|
||||
if err != nil {
|
||||
t.Fatalf("create audit interrupt: %v", err)
|
||||
}
|
||||
human, err := manager.CreatePendingInterrupt("conversation-human", "message-human", "approval", "exec", "call-human", `{}`, "human")
|
||||
if err != nil {
|
||||
t.Fatalf("create human interrupt: %v", err)
|
||||
}
|
||||
|
||||
manager.mu.RLock()
|
||||
_, auditWaitsForHuman := manager.pending[audit.InterruptID]
|
||||
_, humanWaitsForHuman := manager.pending[human.InterruptID]
|
||||
manager.mu.RUnlock()
|
||||
if auditWaitsForHuman {
|
||||
t.Fatal("audit-agent interrupt must not enter the human pending queue")
|
||||
}
|
||||
if !humanWaitsForHuman {
|
||||
t.Fatal("human interrupt should enter the human pending queue")
|
||||
}
|
||||
|
||||
query, args := (&AgentHandler{}).buildHitlListQuery(false)
|
||||
if len(args) != 0 {
|
||||
t.Fatalf("unexpected pending query args: %v", args)
|
||||
}
|
||||
if !strings.Contains(query, "COALESCE(reviewer,'human') = 'human'") {
|
||||
t.Fatalf("pending query must filter out audit-agent work: %s", query)
|
||||
}
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
t.Fatalf("query human pending interrupts: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items, err := (&AgentHandler{}).scanHitlInterruptRows(rows)
|
||||
if err != nil {
|
||||
t.Fatalf("scan human pending interrupts: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0]["id"] != human.InterruptID || items[0]["reviewer"] != "human" {
|
||||
t.Fatalf("unexpected human pending result: %#v", items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package handler
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHITLBuiltInWhitelistExemptsWriteFile(t *testing.T) {
|
||||
h := &AgentHandler{}
|
||||
req := h.hitlRequestWithMergedConfigWhitelist(&HITLRequest{
|
||||
Enabled: true,
|
||||
Mode: "approval",
|
||||
})
|
||||
|
||||
manager := NewHITLManager(nil, nil)
|
||||
manager.ActivateConversation("conversation-1", req)
|
||||
|
||||
if manager.NeedsToolApproval("conversation-1", "write_file") {
|
||||
t.Fatal("write_file should use the built-in HITL exemption")
|
||||
}
|
||||
if !manager.NeedsToolApproval("conversation-1", "exec") {
|
||||
t.Fatal("non-exempt tools should still require approval")
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
|
||||
h.agentHandler = ah
|
||||
}
|
||||
|
||||
const monitorPageTopTools = 3
|
||||
const monitorPageTopTools = 6
|
||||
|
||||
// MonitorStatsSummary 工具调用汇总
|
||||
type MonitorStatsSummary struct {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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。",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
@@ -19,8 +18,8 @@ import (
|
||||
|
||||
// PlanExecuteRootArgs 构建 Eino adk/prebuilt/planexecute 根 Agent 所需参数。
|
||||
type PlanExecuteRootArgs struct {
|
||||
MainToolCallingModel *openai.ChatModel
|
||||
ExecModel *openai.ChatModel
|
||||
MainToolCallingModel model.ToolCallingChatModel
|
||||
ExecModel model.ToolCallingChatModel
|
||||
OrchInstruction string
|
||||
ToolsCfg adk.ToolsConfig
|
||||
ExecMaxIter int
|
||||
|
||||
@@ -121,10 +121,11 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
reasoning.ApplyToEinoChatModelConfig(baseModelCfg, &appCfg.OpenAI, reasoningClient)
|
||||
|
||||
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
baseMainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eino single 模型: %w", err)
|
||||
}
|
||||
mainModel := newStreamToolCallIndexRepairModel(baseMainModel)
|
||||
|
||||
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// streamToolCallIndexRepairModel isolates an OpenAI-compatible streaming
|
||||
// protocol defect before Eino concatenates response chunks. Some providers
|
||||
// reuse a tool-call index for different non-empty tool-call IDs in one stream.
|
||||
// Eino correctly rejects that shape because one index represents one call.
|
||||
//
|
||||
// The wrapper keeps valid streams untouched. When it sees the conflicting
|
||||
// shape, it assigns each distinct ID a stable, stream-local index so Eino can
|
||||
// retain all calls instead of aborting the agent run.
|
||||
type streamToolCallIndexRepairModel struct {
|
||||
base model.ToolCallingChatModel
|
||||
}
|
||||
|
||||
func newStreamToolCallIndexRepairModel(base model.ToolCallingChatModel) model.ToolCallingChatModel {
|
||||
if base == nil {
|
||||
return nil
|
||||
}
|
||||
return &streamToolCallIndexRepairModel{base: base}
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexRepairModel) Generate(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.Message, error) {
|
||||
return m.base.Generate(ctx, input, opts...)
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexRepairModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
stream, err := m.base.Stream(ctx, input, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := newStreamToolCallIndexRepairState()
|
||||
return schema.StreamReaderWithConvert(stream, state.repairMessage), nil
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexRepairModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
||||
withTools, err := m.base.WithTools(tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newStreamToolCallIndexRepairModel(withTools), nil
|
||||
}
|
||||
|
||||
type streamToolCallIndexRepairState struct {
|
||||
indexByID map[string]int
|
||||
idByIndex map[int]string
|
||||
nextFreeIndex int
|
||||
}
|
||||
|
||||
func newStreamToolCallIndexRepairState() *streamToolCallIndexRepairState {
|
||||
return &streamToolCallIndexRepairState{
|
||||
indexByID: make(map[string]int),
|
||||
idByIndex: make(map[int]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *streamToolCallIndexRepairState) repairMessage(msg *schema.Message) (*schema.Message, error) {
|
||||
if msg == nil || len(msg.ToolCalls) == 0 {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
var calls []schema.ToolCall
|
||||
changed := false
|
||||
for i := range msg.ToolCalls {
|
||||
call := msg.ToolCalls[i]
|
||||
if call.Index == nil || call.ID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
sourceIndex := *call.Index
|
||||
if sourceIndex >= s.nextFreeIndex {
|
||||
s.nextFreeIndex = sourceIndex + 1
|
||||
}
|
||||
|
||||
assigned, known := s.indexByID[call.ID]
|
||||
if !known {
|
||||
assigned = sourceIndex
|
||||
if owner, occupied := s.idByIndex[assigned]; occupied && owner != call.ID {
|
||||
assigned = s.takeFreeIndex()
|
||||
}
|
||||
s.indexByID[call.ID] = assigned
|
||||
s.idByIndex[assigned] = call.ID
|
||||
}
|
||||
if assigned == sourceIndex {
|
||||
continue
|
||||
}
|
||||
if calls == nil {
|
||||
calls = append([]schema.ToolCall(nil), msg.ToolCalls...)
|
||||
}
|
||||
index := assigned
|
||||
calls[i].Index = &index
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return msg, nil
|
||||
}
|
||||
out := *msg
|
||||
out.ToolCalls = calls
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *streamToolCallIndexRepairState) takeFreeIndex() int {
|
||||
for {
|
||||
candidate := s.nextFreeIndex
|
||||
s.nextFreeIndex++
|
||||
if _, occupied := s.idByIndex[candidate]; !occupied {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
type streamToolCallIndexFakeModel struct {
|
||||
chunks []*schema.Message
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexFakeModel) Generate(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexFakeModel) Stream(_ context.Context, _ []*schema.Message, _ ...model.Option) (*schema.StreamReader[*schema.Message], error) {
|
||||
return schema.StreamReaderFromArray(m.chunks), nil
|
||||
}
|
||||
|
||||
func (m *streamToolCallIndexFakeModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func TestStreamToolCallIndexRepairSeparatesConflictingIDs(t *testing.T) {
|
||||
index := 0
|
||||
wrapped := newStreamToolCallIndexRepairModel(&streamToolCallIndexFakeModel{chunks: []*schema.Message{
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
Index: &index, ID: "fc_call_0", Type: "function",
|
||||
Function: schema.FunctionCall{Name: "search", Arguments: `{"query":"one"}`},
|
||||
}}),
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
Index: &index, ID: "fc_call_1", Type: "function",
|
||||
Function: schema.FunctionCall{Name: "task", Arguments: `{"query":"two"}`},
|
||||
}}),
|
||||
}})
|
||||
|
||||
got := readStreamToolCallChunks(t, wrapped)
|
||||
merged, err := schema.ConcatMessages(got)
|
||||
if err != nil {
|
||||
t.Fatalf("ConcatMessages() error = %v", err)
|
||||
}
|
||||
if len(merged.ToolCalls) != 2 {
|
||||
t.Fatalf("tool call count = %d, want 2", len(merged.ToolCalls))
|
||||
}
|
||||
if merged.ToolCalls[0].ID != "fc_call_0" || merged.ToolCalls[1].ID != "fc_call_1" {
|
||||
t.Fatalf("tool call IDs = %#v", merged.ToolCalls)
|
||||
}
|
||||
if merged.ToolCalls[0].Index == nil || *merged.ToolCalls[0].Index != 0 || merged.ToolCalls[1].Index == nil || *merged.ToolCalls[1].Index != 1 {
|
||||
t.Fatalf("tool call indexes = %#v", merged.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToolCallIndexRepairPreservesFragmentsForOneID(t *testing.T) {
|
||||
index := 0
|
||||
wrapped := newStreamToolCallIndexRepairModel(&streamToolCallIndexFakeModel{chunks: []*schema.Message{
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
Index: &index, ID: "call_0", Type: "function",
|
||||
Function: schema.FunctionCall{Name: "search", Arguments: `{"query":"`},
|
||||
}}),
|
||||
schema.AssistantMessage("", []schema.ToolCall{{
|
||||
Index: &index, ID: "call_0", Type: "function",
|
||||
Function: schema.FunctionCall{Arguments: `one"}`},
|
||||
}}),
|
||||
}})
|
||||
|
||||
got := readStreamToolCallChunks(t, wrapped)
|
||||
merged, err := schema.ConcatMessages(got)
|
||||
if err != nil {
|
||||
t.Fatalf("ConcatMessages() error = %v", err)
|
||||
}
|
||||
if len(merged.ToolCalls) != 1 || merged.ToolCalls[0].Function.Arguments != `{"query":"one"}` {
|
||||
t.Fatalf("tool calls = %#v", merged.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamToolCallIndexRepairLeavesValidParallelIndexesUntouched(t *testing.T) {
|
||||
first, second := 0, 1
|
||||
wrapped := newStreamToolCallIndexRepairModel(&streamToolCallIndexFakeModel{chunks: []*schema.Message{
|
||||
schema.AssistantMessage("", []schema.ToolCall{
|
||||
{Index: &first, ID: "call_0", Type: "function", Function: schema.FunctionCall{Name: "search", Arguments: `{}`}},
|
||||
{Index: &second, ID: "call_1", Type: "function", Function: schema.FunctionCall{Name: "task", Arguments: `{}`}},
|
||||
}),
|
||||
}})
|
||||
|
||||
got := readStreamToolCallChunks(t, wrapped)
|
||||
if len(got) != 1 || len(got[0].ToolCalls) != 2 {
|
||||
t.Fatalf("chunks = %#v", got)
|
||||
}
|
||||
if *got[0].ToolCalls[0].Index != 0 || *got[0].ToolCalls[1].Index != 1 {
|
||||
t.Fatalf("tool call indexes changed: %#v", got[0].ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func readStreamToolCallChunks(t *testing.T, chatModel model.ToolCallingChatModel) []*schema.Message {
|
||||
t.Helper()
|
||||
stream, err := chatModel.Stream(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Stream() error = %v", err)
|
||||
}
|
||||
defer stream.Close()
|
||||
|
||||
var chunks []*schema.Message
|
||||
for {
|
||||
chunk, recvErr := stream.Recv()
|
||||
if recvErr == io.EOF {
|
||||
return chunks
|
||||
}
|
||||
if recvErr != nil {
|
||||
t.Fatalf("Recv() error = %v", recvErr)
|
||||
}
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
}
|
||||
@@ -8,19 +8,23 @@ import (
|
||||
|
||||
const toolSearchToolName = "tool_search"
|
||||
|
||||
// HitlExemptMetaTools 为编排/元工具:不直接执行攻击动作,但会阻塞 agent 控制流。
|
||||
// tool_search 必须免审批,否则其 HITL 拒绝结果与 Eino toolsearch 中间件不兼容(会硬崩 ChatModel)。
|
||||
// HitlExemptMetaTools 为 HITL 内置免审批工具:包括编排/元工具,以及模型输出修复链路依赖的 write_file。
|
||||
// tool_search 必须免审批,否则其 HITL 拒绝结果与 Eino toolsearch 中间件不兼容(会硬崩 ChatModel);
|
||||
// write_file 必须免审批,否则长脚本或请求体无法先安全落盘,模型输出修复链路会被再次阻塞。
|
||||
var HitlExemptMetaTools = []string{
|
||||
toolSearchToolName,
|
||||
"skill",
|
||||
"task",
|
||||
"write_todos",
|
||||
"write_file",
|
||||
"transfer_to_agent",
|
||||
"exit",
|
||||
"TaskCreate",
|
||||
"TaskGet",
|
||||
"TaskUpdate",
|
||||
"TaskList",
|
||||
"upsert_project_fact",
|
||||
"get_project_fact",
|
||||
}
|
||||
|
||||
// IsToolSearchTool reports whether name is the Eino dynamictool tool_search meta-tool.
|
||||
|
||||
@@ -33,16 +33,32 @@ func TestHitlRejectToolResult_otherToolKeepsLegacyText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHitlExemptMetaTools_includesToolSearch(t *testing.T) {
|
||||
func TestMergeHitlExemptMetaTools_includesBuiltInExemptTools(t *testing.T) {
|
||||
merged := MergeHitlExemptMetaTools([]string{"read_file"})
|
||||
found := false
|
||||
foundToolSearch := false
|
||||
for _, name := range merged {
|
||||
if IsToolSearchTool(name) {
|
||||
found = true
|
||||
foundToolSearch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if !foundToolSearch {
|
||||
t.Fatalf("tool_search missing from %v", merged)
|
||||
}
|
||||
foundBuiltInTools := map[string]bool{
|
||||
"write_file": false,
|
||||
"upsert_project_fact": false,
|
||||
"get_project_fact": false,
|
||||
}
|
||||
for _, name := range merged {
|
||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||
if _, ok := foundBuiltInTools[normalized]; ok {
|
||||
foundBuiltInTools[normalized] = true
|
||||
}
|
||||
}
|
||||
for name, found := range foundBuiltInTools {
|
||||
if !found {
|
||||
t.Fatalf("%s missing from %v", name, merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -211,10 +218,11 @@ func RunDeepAgent(
|
||||
}
|
||||
}
|
||||
|
||||
subModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
baseSubModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("子代理 %q ChatModel: %w", id, err)
|
||||
}
|
||||
subModel := newStreamToolCallIndexRepairModel(baseSubModel)
|
||||
|
||||
subDefs := ag.ToolsForRole(roleTools)
|
||||
subTools, err := einomcp.ToolsFromDefinitions(ag, holder, subDefs, recorder, nil, toolInvokeNotify, id)
|
||||
@@ -301,10 +309,11 @@ func RunDeepAgent(
|
||||
}
|
||||
}
|
||||
|
||||
mainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
baseMainModel, err := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("多代理主模型: %w", err)
|
||||
}
|
||||
mainModel := newStreamToolCallIndexRepairModel(baseMainModel)
|
||||
|
||||
mainSumMw, err := newEinoSummarizationMiddleware(ctx, mainModel, appCfg, &ma.EinoMiddleware, conversationID, db, projectID, logger)
|
||||
if err != nil {
|
||||
@@ -474,19 +483,21 @@ func RunDeepAgent(
|
||||
MaxCompletionTokens: &maxCompletionTokens,
|
||||
}
|
||||
reasoning.ApplyPlanExecutePlannerModelConfig(plannerModelCfg, &appCfg.OpenAI)
|
||||
peMainModel, perr := einoopenai.NewChatModel(ctx, plannerModelCfg)
|
||||
basePEMainModel, perr := einoopenai.NewChatModel(ctx, plannerModelCfg)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("plan_execute 规划模型: %w", perr)
|
||||
}
|
||||
peMainModel := newStreamToolCallIndexRepairModel(basePEMainModel)
|
||||
if logger != nil {
|
||||
logger.Info("plan_execute: planner/replanner 使用无 reasoning 的独立 ChatModel(ToolChoiceForced 兼容)",
|
||||
zap.String("model", appCfg.OpenAI.Model),
|
||||
)
|
||||
}
|
||||
execModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
baseExecModel, perr := einoopenai.NewChatModel(ctx, baseModelCfg)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("plan_execute 执行器模型: %w", perr)
|
||||
}
|
||||
execModel := newStreamToolCallIndexRepairModel(baseExecModel)
|
||||
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
||||
var peFsMw adk.ChatModelAgentMiddleware
|
||||
if einoSkillMW != nil && einoFSTools && einoLoc != nil {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 个 output;output/end 不能有出边。
|
||||
- 节点 type 只能从这些字符串中选择:start、tool、agent、condition、hitl、output、end。
|
||||
- 每个 agent、tool、output 节点都必须配置唯一的 output_key;output 节点默认使用 result。
|
||||
- agent 节点必须配置 instruction 或 input_binding;默认 input_binding 为 {"from":"previous","field":"output"}。
|
||||
- output 节点必须配置 source_binding 或 static_value;默认 source_binding 为 {"from":"previous","field":"output"}。
|
||||
- tool 节点必须配置 tool_name、arguments、timeout_seconds;arguments 必须是合法 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())
|
||||
}
|
||||
@@ -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,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,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,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,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,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,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,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,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,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, 红队]
|
||||
---
|
||||
|
||||
## 初始访问 / 钓鱼 / 社工
|
||||
|
||||
@@ -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操作系统
|
||||
|
||||
@@ -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, 红队, 项目黑板]
|
||||
---
|
||||
|
||||
# 项目黑板(与本产品对齐)
|
||||
|
||||
@@ -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, 红队]
|
||||
---
|
||||
|
||||
## 输出规范
|
||||
|
||||
@@ -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,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,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,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,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,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,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,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,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,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 自主发现引擎(全网搜不到漏洞时自己挖)
|
||||
|
||||
+25
-15
@@ -2001,7 +2001,7 @@ html[data-theme="dark"] .c2-file-upload-hint {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--c2-text-dim) 70%, transparent) transparent;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.c2-tasks-table tr {
|
||||
@@ -2010,24 +2010,29 @@ html[data-theme="dark"] .c2-file-upload-hint {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody:hover {
|
||||
scrollbar-color: color-mix(in srgb, var(--c2-text-dim) 70%, transparent) transparent;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody::-webkit-scrollbar-track {
|
||||
background: var(--c2-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--c2-text-dim) 70%, transparent);
|
||||
background-clip: content-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody::-webkit-scrollbar-thumb:hover {
|
||||
.c2-tasks-table tbody:hover::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--c2-text-dim) 70%, transparent);
|
||||
}
|
||||
|
||||
.c2-tasks-table tbody:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--c2-text-muted) 78%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.c2-tasks-table thead th {
|
||||
@@ -2861,6 +2866,10 @@ html[data-theme="dark"] .c2-file-upload-hint {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.c2-events-table tbody:hover {
|
||||
scrollbar-color: color-mix(in srgb, var(--c2-text-dim) 70%, transparent) transparent;
|
||||
}
|
||||
|
||||
@@ -2871,23 +2880,24 @@ html[data-theme="dark"] .c2-file-upload-hint {
|
||||
}
|
||||
|
||||
.c2-events-table tbody::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.c2-events-table tbody::-webkit-scrollbar-track {
|
||||
background: var(--c2-surface);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c2-events-table tbody::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--c2-text-dim) 70%, transparent);
|
||||
background-clip: content-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.c2-events-table tbody::-webkit-scrollbar-thumb:hover {
|
||||
.c2-events-table tbody:hover::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--c2-text-dim) 70%, transparent);
|
||||
}
|
||||
|
||||
.c2-events-table tbody:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--c2-text-muted) 78%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.c2-events-table thead th {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/* Agent-maintained plan: compact progress chip, expanded on hover/focus. */
|
||||
.agent-plan-progress {
|
||||
--agent-plan-surface: var(--card-bg);
|
||||
--agent-plan-surface-hover: var(--bg-tertiary);
|
||||
--agent-plan-text: var(--text-primary);
|
||||
--agent-plan-text-secondary: var(--text-secondary);
|
||||
--agent-plan-border: var(--border-color);
|
||||
--agent-plan-spinner-track: color-mix(in srgb, var(--accent-color) 30%, transparent);
|
||||
--agent-plan-trigger-bottom: 14px;
|
||||
--agent-plan-panel-bottom: 70px;
|
||||
position: relative;
|
||||
z-index: 42;
|
||||
flex: 0 0 0;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.agent-plan-progress[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-plan-progress-trigger {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
bottom: var(--agent-plan-trigger-bottom);
|
||||
transform: translateX(50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border: 1px solid var(--agent-plan-border);
|
||||
border-radius: 18px;
|
||||
color: var(--agent-plan-text);
|
||||
background: var(--agent-plan-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: background-color 150ms ease, border-color 150ms ease, transform 150ms ease;
|
||||
}
|
||||
|
||||
.agent-plan-progress-trigger:hover,
|
||||
.agent-plan-progress-trigger:focus-visible {
|
||||
color: var(--agent-plan-text);
|
||||
background: var(--agent-plan-surface-hover);
|
||||
border-color: var(--accent-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.agent-plan-progress-panel {
|
||||
position: absolute;
|
||||
right: 50%;
|
||||
bottom: var(--agent-plan-panel-bottom);
|
||||
width: max-content;
|
||||
min-width: 360px;
|
||||
max-width: min(560px, calc(100vw - 40px));
|
||||
max-height: min(54vh, 440px);
|
||||
padding: 14px 18px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--agent-plan-border);
|
||||
border-radius: 18px;
|
||||
color: var(--agent-plan-text);
|
||||
background: var(--agent-plan-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
transform: translate(50%, 8px) scale(0.985);
|
||||
transform-origin: bottom center;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
|
||||
}
|
||||
|
||||
/*
|
||||
* “回到最新消息”只在用户离开底部时出现。它与任务进度同为居中浮层,
|
||||
* 两者同时可见时让任务进度上移,保留回到底部按钮靠近输入框的位置。
|
||||
*/
|
||||
.chat-return-latest:not([hidden]) + .agent-plan-progress:not([hidden]) {
|
||||
--agent-plan-trigger-bottom: 64px;
|
||||
--agent-plan-panel-bottom: 120px;
|
||||
}
|
||||
|
||||
.agent-plan-progress.is-hover-active .agent-plan-progress-panel,
|
||||
.agent-plan-progress:focus-within .agent-plan-progress-panel,
|
||||
.agent-plan-progress.is-open .agent-plan-progress-panel {
|
||||
opacity: 1;
|
||||
transform: translate(50%, 0) scale(1);
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.agent-plan-task {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
min-height: 34px;
|
||||
padding: 4px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 570;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.agent-plan-task-label {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.agent-plan-task--completed {
|
||||
color: var(--agent-plan-text-secondary);
|
||||
}
|
||||
|
||||
.agent-plan-task--in_progress {
|
||||
color: var(--agent-plan-text);
|
||||
}
|
||||
|
||||
.agent-plan-task--pending {
|
||||
color: var(--agent-plan-text-secondary);
|
||||
}
|
||||
|
||||
.agent-plan-task-status {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 2px;
|
||||
border: 2px solid var(--agent-plan-text-secondary);
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.agent-plan-task-status--completed {
|
||||
border-color: var(--agent-plan-text-secondary);
|
||||
color: var(--agent-plan-text-secondary);
|
||||
}
|
||||
|
||||
.agent-plan-task-check {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2.3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.agent-plan-task-status--in_progress {
|
||||
border-color: var(--agent-plan-spinner-track);
|
||||
border-top-color: var(--accent-color);
|
||||
animation: agent-plan-spin 820ms linear infinite;
|
||||
}
|
||||
|
||||
.agent-plan-progress-trigger .agent-plan-task-status {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@keyframes agent-plan-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.agent-plan-progress {
|
||||
--agent-plan-trigger-bottom: 10px;
|
||||
--agent-plan-panel-bottom: 66px;
|
||||
}
|
||||
|
||||
.chat-return-latest:not([hidden]) + .agent-plan-progress:not([hidden]) {
|
||||
--agent-plan-trigger-bottom: 60px;
|
||||
--agent-plan-panel-bottom: 116px;
|
||||
}
|
||||
|
||||
.agent-plan-progress-panel {
|
||||
min-width: min(360px, calc(100vw - 28px));
|
||||
max-width: calc(100vw - 28px);
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.agent-plan-progress-trigger {
|
||||
min-height: 42px;
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-plan-progress-trigger,
|
||||
.agent-plan-progress-panel {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.agent-plan-task-status--in_progress {
|
||||
animation-duration: 1.8s;
|
||||
}
|
||||
}
|
||||
+3526
-192
File diff suppressed because it is too large
Load Diff
+201
-6
@@ -508,6 +508,8 @@
|
||||
"settingsIntroTitle": "Project settings",
|
||||
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
|
||||
"pinProject": "Pin project (show first in list)",
|
||||
"pinProjectAction": "Pin project",
|
||||
"unpinProjectAction": "Unpin project",
|
||||
"pinFact": "Pin fact (prioritize in list and blackboard index)",
|
||||
"editDescriptionPlaceholder": "Client/task notes, contacts, collaboration…",
|
||||
"scopeTitle": "Test scope",
|
||||
@@ -521,6 +523,7 @@
|
||||
"archiveRestore": "Archive / Restore",
|
||||
"archiveProject": "Archive",
|
||||
"editProject": "Edit",
|
||||
"renameProject": "Rename",
|
||||
"restoreProjectActive": "Restore to active",
|
||||
"projectActions": "Project actions",
|
||||
"deleteProject": "Delete project",
|
||||
@@ -538,11 +541,44 @@
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "New chat",
|
||||
"newTask": "New task",
|
||||
"toggleConversationPanel": "Collapse/expand conversation list",
|
||||
"searchHistory": "Search history...",
|
||||
"projectFolders": "Projects",
|
||||
"projectFoldersLoadMoreRemaining": "Load more, {{count}} projects remaining",
|
||||
"projectPreviewLabel": "Project information",
|
||||
"projectPreviewStats": "{{total}} tasks · {{active}} open",
|
||||
"projectPreviewNoDescription": "No project description",
|
||||
"projectPreviewScope": "Test scope: {{scope}}",
|
||||
"projectPreviewEdit": "Edit project",
|
||||
"conversationPreviewJustNow": "Now",
|
||||
"conversationPreviewMinutes": "{{count}} min",
|
||||
"conversationPreviewHours": "{{count}}h",
|
||||
"conversationPreviewDays": "{{count}}d",
|
||||
"conversationPreviewDateTime": "{{year}}-{{month}}-{{day}} {{hour}}:{{minute}}",
|
||||
"conversationPreviewNoProject": "No project",
|
||||
"conversationPreviewDefaultMode": "Default",
|
||||
"conversationPreviewUnread": "Unread update",
|
||||
"conversationPreviewViewed": "Viewed",
|
||||
"conversationPreviewConversation": "Conversation",
|
||||
"returnToLatest": "Jump to latest message",
|
||||
"taskProgressStep": "Step {{current}} of {{total}}",
|
||||
"taskProgressOpen": "View task progress",
|
||||
"taskProgressDetails": "Task progress details",
|
||||
"taskProgressUnnamed": "Untitled task",
|
||||
"completedUnread": "Completed, not viewed",
|
||||
"newConversationInProject": "Start a new conversation in this project",
|
||||
"newUnassignedConversation": "Start a new conversation without a project",
|
||||
"conversationActions": "Conversation actions",
|
||||
"renameConversationPrompt": "Enter a new title:",
|
||||
"renameConversationTitle": "Rename conversation",
|
||||
"renameConversationSubtitle": "The name will update in project folders and recent conversations",
|
||||
"conversationTitleLabel": "Conversation name",
|
||||
"conversationTitlePlaceholder": "Enter a conversation name",
|
||||
"conversationGroups": "Conversation groups",
|
||||
"addGroup": "New group",
|
||||
"recentConversations": "Recent conversations",
|
||||
"toggleRecentConversations": "Expand/collapse recent conversations",
|
||||
"filterByProject": "Filter by project",
|
||||
"filterAllProjects": "All projects",
|
||||
"filterUnboundProjects": "Unbound",
|
||||
@@ -571,7 +607,7 @@
|
||||
"viewAttackChain": "View attack chain",
|
||||
"selectRole": "Select role",
|
||||
"defaultRole": "Default",
|
||||
"inputPlaceholder": "Enter target or command... (type @ to select tools | Shift+Enter newline, Enter send)",
|
||||
"inputPlaceholder": "Enter a target or command… @ select tools",
|
||||
"selectFile": "Select file",
|
||||
"uploadFile": "Upload file (multi-select or drag & drop)",
|
||||
"readingAttachmentsDetail": "Reading attachment {{current}}/{{total}} · {{name}} · {{percent}}%",
|
||||
@@ -587,10 +623,20 @@
|
||||
"noMatchTools": "No matching tools",
|
||||
"penetrationTestDetail": "Task execution details",
|
||||
"expandDetail": "Expand details",
|
||||
"turnElapsedRunning": "Processed for {{duration}}",
|
||||
"turnElapsedComplete": "Took {{duration}}",
|
||||
"turnElapsedCancelled": "Interrupted · Took {{duration}}",
|
||||
"turnElapsedTimeout": "Timed out · Took {{duration}}",
|
||||
"turnElapsedFailed": "Failed · Took {{duration}}",
|
||||
"turnDurationSeconds": "{{seconds}} sec",
|
||||
"turnDurationMinutes": "{{minutes}} min {{seconds}} sec",
|
||||
"turnDurationHours": "{{hours}} hr {{minutes}} min",
|
||||
"turnProcessAria": "{{state}}; expand or collapse execution details",
|
||||
"turnNumber": "Turn {{number}}",
|
||||
"turnPending": "Processing…",
|
||||
"expandDetailLazyHint": "Expand details (loads iteration details on click)",
|
||||
"loadingEarlierDetails": "Loading earlier entries…",
|
||||
"loadingLaterDetails": "Loading newer entries…",
|
||||
"backToLatestProgress": "↓ Back to latest",
|
||||
"viewToolDetail": "View details",
|
||||
"collapseToolDetail": "Collapse",
|
||||
"liveTimelinePruned": "Collapsed the first {{count}} live process details. View the full record page by page after the task completes.",
|
||||
@@ -617,6 +663,12 @@
|
||||
"executeFailed": "Execution failed",
|
||||
"callOpenAIFailed": "Call OpenAI failed",
|
||||
"systemReadyMessage": "System is ready. Please enter your test requirements, and the system will automatically perform the corresponding security tests.",
|
||||
"projectWelcomeMessage": "Current project: {{project}}. Enter your test requirements and the system will run the corresponding security tests.",
|
||||
"noProjectWelcomeMessage": "No project is currently selected. Enter your test requirements and the system will run the corresponding security tests.",
|
||||
"projectWelcomeTitlePrefix": "What should be tested in ",
|
||||
"projectWelcomeTitleSuffix": "?",
|
||||
"noProjectWelcomeTitle": "What should be tested?",
|
||||
"welcomeSubtitle": "Enter your test requirements and the system will automatically run the corresponding security tests.",
|
||||
"addNewGroup": "+ New group",
|
||||
"callNumber": "Call #{{n}}",
|
||||
"iterationRound": "Iteration {{n}}",
|
||||
@@ -674,15 +726,16 @@
|
||||
"loadFailedRetry": "Load failed, please retry",
|
||||
"dataFormatError": "Data format error",
|
||||
"progressInProgress": "Penetration test in progress...",
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"scrollToBottomHasNew": "↓ New content below",
|
||||
"scrollToBottomNew": "↓ {{count}} new update(s)",
|
||||
"executionFailed": "Execution failed",
|
||||
"penetrationTestComplete": "Penetration test complete",
|
||||
"yesterday": "Yesterday",
|
||||
"historyGroupToday": "Today",
|
||||
"historyGroupLast7Days": "Past 7 days",
|
||||
"historyGroupEarlier": "Older",
|
||||
"conversationPreviewJustNow": "Just now",
|
||||
"conversationPreviewMinutes": "{{count}} min",
|
||||
"conversationPreviewHours": "{{count}} hr",
|
||||
"conversationPreviewDays": "{{count}} days",
|
||||
"agentModeSelectAria": "Choose conversation execution mode",
|
||||
"agentModePanelTitle": "Conversation mode",
|
||||
"agentModeEinoSingle": "Eino single (ADK)",
|
||||
@@ -711,6 +764,21 @@
|
||||
"sessionSettingsTitle": "Session settings",
|
||||
"sessionSettingsAria": "Open session settings",
|
||||
"sessionSettingsHint": "AI channel, reasoning, and HITL settings only affect future messages.",
|
||||
"sessionShortcutAuditAgent": "Agent review",
|
||||
"modelSettingsAria": "Choose model and reasoning effort",
|
||||
"systemModelPickerTitle": "Choose system model",
|
||||
"systemModelField": "Model",
|
||||
"systemModelLoading": "Fetching model list…",
|
||||
"systemModelLoaded": "Loaded {count} models",
|
||||
"systemModelCurrent": "Current",
|
||||
"systemModelSaving": "Saving…",
|
||||
"systemModelSaved": "Saved automatically",
|
||||
"systemModelLoadFailed": "Failed to fetch models",
|
||||
"systemModelSaveFailed": "Failed to save model",
|
||||
"systemModelApplyFailed": "Failed to apply model",
|
||||
"systemModelNeedApiKey": "Configure an API key in System Settings first",
|
||||
"systemModelRetry": "Try again",
|
||||
"sessionShortcutHuman": "Human approval",
|
||||
"aiChannelLabel": "AI channel",
|
||||
"aiChannelDefault": "Use default channel",
|
||||
"aiChannelDefaultShort": "Default channel",
|
||||
@@ -738,6 +806,12 @@
|
||||
"hitlApplyOkWhitelistYaml": "Tool whitelist merged into config.yaml and active. Session settings are saved automatically.",
|
||||
"hitlApplyOkLocal": "Saved in this browser.",
|
||||
"hitlApplyFail": "Failed to sync to server",
|
||||
"hitlTimeoutLabel": "Approval wait limit",
|
||||
"hitlTimeoutOneMinute": "1 minute",
|
||||
"hitlTimeoutFiveMinutes": "5 minutes",
|
||||
"hitlTimeoutTenMinutes": "10 minutes",
|
||||
"hitlTimeoutUnlimited": "No limit",
|
||||
"hitlTimeoutHint": "Unanswered requests are rejected automatically when time expires; approval cards show the countdown.",
|
||||
"hitlStatusOff": "Human-in-the-loop: Off"
|
||||
},
|
||||
"hitl": {
|
||||
@@ -763,6 +837,52 @@
|
||||
"tabStrategy": "Audit strategy",
|
||||
"tabWhitelist": "Tool whitelist",
|
||||
"pendingTitle": "Pending approvals",
|
||||
"auditReviewing": "Automatic review in progress",
|
||||
"auditReviewEditing": "Automatically reviewing and correcting",
|
||||
"auditReviewExplanation": "A carefully prompted review agent is reviewing this request. It will run only after approval.",
|
||||
"auditApproved": "Audit Agent approved",
|
||||
"auditEditedApproved": "Audit Agent edited parameters and approved",
|
||||
"auditRejected": "Audit Agent rejected",
|
||||
"waitingHumanApproval": "Waiting for human approval",
|
||||
"waitingHumanReview": "Waiting for human review",
|
||||
"humanApprovalExplanation": "This tool call needs your confirmation before it can run.",
|
||||
"humanReviewExplanation": "Review and optionally edit the parameters before allowing execution.",
|
||||
"humanApproved": "Allowed once",
|
||||
"humanEditedApproved": "Edited parameters and allowed",
|
||||
"humanRejected": "Human approval rejected",
|
||||
"viewEditedArgs": "View edited parameters",
|
||||
"reviewArgs": "Review parameters (JSON)",
|
||||
"commentOptional": "Comment (optional)",
|
||||
"commentPlaceholder": "For example: read-only operations only",
|
||||
"reject": "Reject",
|
||||
"allowOnce": "Allow once",
|
||||
"saveEditedAndAllow": "Save edits and allow",
|
||||
"waitingApprovalShort": "Waiting for approval",
|
||||
"waitingApprovalCount": "Waiting approval {{count}}",
|
||||
"approvalUrgencyUnlimited": "Approval has no time limit",
|
||||
"approvalUrgencyMoreThanThree": "Earliest approval expires in more than 3 minutes",
|
||||
"approvalUrgencyMoreThanFive": "Earliest approval expires in more than 5 minutes",
|
||||
"approvalUrgencyThreeToFive": "Earliest approval expires in 3–5 minutes",
|
||||
"approvalUrgencyOneToThree": "Earliest approval expires in 1–3 minutes",
|
||||
"approvalUrgencyWithinOne": "Earliest approval expires within 1 minute",
|
||||
"requestGeneric": "Allow CyberStrikeAI to call {{tool}}?",
|
||||
"requestVisitUrl": "Allow CyberStrikeAI to visit {{url}}?",
|
||||
"requestBrowser": "Allow CyberStrikeAI to use the browser?",
|
||||
"requestCommand": "Allow CyberStrikeAI to run this command?",
|
||||
"requestFile": "Allow CyberStrikeAI to modify {{path}}?",
|
||||
"requestFiles": "Allow CyberStrikeAI to modify files?",
|
||||
"toolTerminal": "Terminal",
|
||||
"toolFiles": "Files",
|
||||
"viewRequestDetails": "View request details",
|
||||
"editRequestDetails": "View or edit request parameters",
|
||||
"addApprovalComment": "Add approval comment (optional)",
|
||||
"timeoutAutoReject": "Automatically rejects at expiry",
|
||||
"timeoutUnlimited": "No time limit",
|
||||
"expiredAutoRejected": "Approval timed out; rejecting automatically…",
|
||||
"taskClosedApprovalUnavailable": "Task ended; approval is unavailable",
|
||||
"taskInterrupted": "Task interrupted",
|
||||
"interruptedApprovalCancelled": "Task interrupted; approval cancelled",
|
||||
"expiredRejected": "Approval timed out and was rejected",
|
||||
"searchLabel": "Search",
|
||||
"searchPlaceholder": "Tool, conversation, payload, comment…",
|
||||
"searchApply": "Search",
|
||||
@@ -3532,6 +3652,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 +3836,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",
|
||||
|
||||
+201
-6
@@ -496,6 +496,8 @@
|
||||
"settingsIntroTitle": "项目设置",
|
||||
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
|
||||
"pinProject": "置顶项目(列表优先显示)",
|
||||
"pinProjectAction": "置顶项目",
|
||||
"unpinProjectAction": "取消置顶",
|
||||
"pinFact": "置顶事实(列表与黑板索引优先)",
|
||||
"editDescriptionPlaceholder": "客户/任务备注、协作说明、联系人…",
|
||||
"scopeTitle": "测试范围",
|
||||
@@ -509,6 +511,7 @@
|
||||
"archiveRestore": "归档 / 恢复",
|
||||
"archiveProject": "归档",
|
||||
"editProject": "编辑",
|
||||
"renameProject": "重命名",
|
||||
"restoreProjectActive": "恢复为进行中",
|
||||
"projectActions": "项目操作",
|
||||
"deleteProject": "删除项目",
|
||||
@@ -526,11 +529,44 @@
|
||||
},
|
||||
"chat": {
|
||||
"newChat": "新对话",
|
||||
"newTask": "新任务",
|
||||
"toggleConversationPanel": "折叠/展开对话列表",
|
||||
"searchHistory": "搜索历史记录...",
|
||||
"projectFolders": "项目",
|
||||
"projectFoldersLoadMoreRemaining": "加载更多,剩余 {{count}} 个项目",
|
||||
"projectPreviewLabel": "项目信息",
|
||||
"projectPreviewStats": "{{total}} 个任务 · {{active}} 个已开启",
|
||||
"projectPreviewNoDescription": "暂无项目说明",
|
||||
"projectPreviewScope": "测试范围:{{scope}}",
|
||||
"projectPreviewEdit": "编辑项目",
|
||||
"conversationPreviewJustNow": "刚刚",
|
||||
"conversationPreviewMinutes": "{{count}} 分钟",
|
||||
"conversationPreviewHours": "{{count}} 小时",
|
||||
"conversationPreviewDays": "{{count}} 天",
|
||||
"conversationPreviewDateTime": "{{year}}年{{month}}月{{day}}日 {{hour}}:{{minute}}",
|
||||
"conversationPreviewNoProject": "未绑定项目",
|
||||
"conversationPreviewDefaultMode": "默认",
|
||||
"conversationPreviewUnread": "有未读更新",
|
||||
"conversationPreviewViewed": "已查看",
|
||||
"conversationPreviewConversation": "对话",
|
||||
"returnToLatest": "回到最新消息",
|
||||
"taskProgressStep": "第 {{current}} / {{total}} 步",
|
||||
"taskProgressOpen": "查看任务进度",
|
||||
"taskProgressDetails": "任务进度详情",
|
||||
"taskProgressUnnamed": "未命名任务",
|
||||
"completedUnread": "已完成,尚未查看",
|
||||
"newConversationInProject": "在此项目中新建对话",
|
||||
"newUnassignedConversation": "新建无项目对话",
|
||||
"conversationActions": "对话操作",
|
||||
"renameConversationPrompt": "请输入新标题:",
|
||||
"renameConversationTitle": "重命名对话",
|
||||
"renameConversationSubtitle": "修改后会同步更新项目文件夹和最近对话中的名称",
|
||||
"conversationTitleLabel": "对话名称",
|
||||
"conversationTitlePlaceholder": "请输入对话名称",
|
||||
"conversationGroups": "对话分组",
|
||||
"addGroup": "新建分组",
|
||||
"recentConversations": "最近对话",
|
||||
"toggleRecentConversations": "展开/折叠最近对话",
|
||||
"filterByProject": "按项目筛选",
|
||||
"filterAllProjects": "全部项目",
|
||||
"filterUnboundProjects": "未绑定项目",
|
||||
@@ -559,7 +595,7 @@
|
||||
"viewAttackChain": "查看攻击链",
|
||||
"selectRole": "选择角色",
|
||||
"defaultRole": "默认",
|
||||
"inputPlaceholder": "输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)",
|
||||
"inputPlaceholder": "输入测试目标或命令… @ 选择工具",
|
||||
"selectFile": "选择文件",
|
||||
"uploadFile": "上传文件(可多选或拖拽到此处)",
|
||||
"readingAttachmentsDetail": "读取附件 {{current}}/{{total}} · {{name}} · {{percent}}%",
|
||||
@@ -575,10 +611,20 @@
|
||||
"noMatchTools": "没有匹配的工具",
|
||||
"penetrationTestDetail": "任务执行详情",
|
||||
"expandDetail": "展开详情",
|
||||
"turnElapsedRunning": "已处理 {{duration}}",
|
||||
"turnElapsedComplete": "耗时 {{duration}}",
|
||||
"turnElapsedCancelled": "已中断 · 耗时 {{duration}}",
|
||||
"turnElapsedTimeout": "已超时 · 耗时 {{duration}}",
|
||||
"turnElapsedFailed": "执行失败 · 耗时 {{duration}}",
|
||||
"turnDurationSeconds": "{{seconds}} 秒",
|
||||
"turnDurationMinutes": "{{minutes}} 分钟 {{seconds}} 秒",
|
||||
"turnDurationHours": "{{hours}} 小时 {{minutes}} 分钟",
|
||||
"turnProcessAria": "{{state}},展开或收起执行过程",
|
||||
"turnNumber": "第 {{number}} 轮",
|
||||
"turnPending": "正在处理…",
|
||||
"expandDetailLazyHint": "展开详情(点击后加载迭代详情)",
|
||||
"loadingEarlierDetails": "正在加载更早记录…",
|
||||
"loadingLaterDetails": "正在加载更新记录…",
|
||||
"backToLatestProgress": "↓ 回到最新进度",
|
||||
"viewToolDetail": "查看详情",
|
||||
"collapseToolDetail": "收起",
|
||||
"liveTimelinePruned": "已收起前 {{count}} 条实时过程详情,任务完成后可按页查看完整记录",
|
||||
@@ -605,6 +651,12 @@
|
||||
"executeFailed": "执行失败",
|
||||
"callOpenAIFailed": "调用OpenAI失败",
|
||||
"systemReadyMessage": "系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||
"projectWelcomeMessage": "当前{{project}}项目,请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||
"noProjectWelcomeMessage": "当前无项目,请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||
"projectWelcomeTitlePrefix": "要在 ",
|
||||
"projectWelcomeTitleSuffix": " 项目中测试什么?",
|
||||
"noProjectWelcomeTitle": "要测试什么?",
|
||||
"welcomeSubtitle": "请输入您的测试需求,系统将自动执行相应的安全测试。",
|
||||
"addNewGroup": "+ 新增分组",
|
||||
"callNumber": "调用 #{{n}}",
|
||||
"iterationRound": "第 {{n}} 轮迭代",
|
||||
@@ -662,15 +714,16 @@
|
||||
"loadFailedRetry": "加载失败,请重试",
|
||||
"dataFormatError": "数据格式错误",
|
||||
"progressInProgress": "渗透测试进行中...",
|
||||
"scrollToBottom": "回到底部",
|
||||
"scrollToBottomHasNew": "↓ 有新内容",
|
||||
"scrollToBottomNew": "↓ {{count}} 条新内容",
|
||||
"executionFailed": "执行失败",
|
||||
"penetrationTestComplete": "渗透测试完成",
|
||||
"yesterday": "昨天",
|
||||
"historyGroupToday": "今天",
|
||||
"historyGroupLast7Days": "过去七天",
|
||||
"historyGroupEarlier": "更早",
|
||||
"conversationPreviewJustNow": "刚刚",
|
||||
"conversationPreviewMinutes": "{{count}} 分钟",
|
||||
"conversationPreviewHours": "{{count}} 小时",
|
||||
"conversationPreviewDays": "{{count}} 天",
|
||||
"agentModeSelectAria": "选择对话执行模式",
|
||||
"agentModePanelTitle": "对话模式",
|
||||
"agentModeEinoSingle": "Eino 单代理(ADK)",
|
||||
@@ -699,6 +752,21 @@
|
||||
"sessionSettingsTitle": "会话设置",
|
||||
"sessionSettingsAria": "打开会话设置",
|
||||
"sessionSettingsHint": "AI 通道、推理设置与人机协同只影响后续消息。",
|
||||
"sessionShortcutAuditAgent": "Agent 审查",
|
||||
"modelSettingsAria": "选择模型与推理强度",
|
||||
"systemModelPickerTitle": "选择系统模型",
|
||||
"systemModelField": "模型",
|
||||
"systemModelLoading": "正在获取模型列表…",
|
||||
"systemModelLoaded": "已获取 {count} 个模型",
|
||||
"systemModelCurrent": "当前",
|
||||
"systemModelSaving": "正在保存…",
|
||||
"systemModelSaved": "已自动保存",
|
||||
"systemModelLoadFailed": "获取模型失败",
|
||||
"systemModelSaveFailed": "保存模型失败",
|
||||
"systemModelApplyFailed": "应用模型失败",
|
||||
"systemModelNeedApiKey": "请先在系统设置中配置 API Key",
|
||||
"systemModelRetry": "重新获取",
|
||||
"sessionShortcutHuman": "人工审批",
|
||||
"aiChannelLabel": "AI 通道",
|
||||
"aiChannelDefault": "跟随默认通道",
|
||||
"aiChannelDefaultShort": "默认通道",
|
||||
@@ -726,6 +794,12 @@
|
||||
"hitlApplyOkWhitelistYaml": "免审批工具已合并进 config.yaml 并生效。会话配置会自动保存。",
|
||||
"hitlApplyOkLocal": "已保存到本浏览器。",
|
||||
"hitlApplyFail": "同步到服务器失败",
|
||||
"hitlTimeoutLabel": "审批等待时限",
|
||||
"hitlTimeoutOneMinute": "1 分钟",
|
||||
"hitlTimeoutFiveMinutes": "5 分钟",
|
||||
"hitlTimeoutTenMinutes": "10 分钟",
|
||||
"hitlTimeoutUnlimited": "不限制",
|
||||
"hitlTimeoutHint": "到期未处理将自动拒绝;审批卡片会显示倒计时。",
|
||||
"hitlStatusOff": "人机协同:关闭"
|
||||
},
|
||||
"hitl": {
|
||||
@@ -751,6 +825,52 @@
|
||||
"tabStrategy": "审计策略",
|
||||
"tabWhitelist": "工具白名单",
|
||||
"pendingTitle": "待处理审批",
|
||||
"auditReviewing": "自动审核中",
|
||||
"auditReviewEditing": "自动审查并校正中",
|
||||
"auditReviewExplanation": "经过谨慎提示的审查智能体正在审查此请求,通过后才会执行。",
|
||||
"auditApproved": "审计 Agent 已批准",
|
||||
"auditEditedApproved": "审计 Agent 已修改参数并批准",
|
||||
"auditRejected": "审计 Agent 已拒绝",
|
||||
"waitingHumanApproval": "等待人工审批",
|
||||
"waitingHumanReview": "等待人工审查",
|
||||
"humanApprovalExplanation": "此工具调用需要你的确认,通过后才会执行。",
|
||||
"humanReviewExplanation": "请审查并可修改参数,保存后才会执行。",
|
||||
"humanApproved": "已允许一次",
|
||||
"humanEditedApproved": "已修改参数并允许",
|
||||
"humanRejected": "人工审批已拒绝",
|
||||
"viewEditedArgs": "查看修改后的参数",
|
||||
"reviewArgs": "审查参数(JSON)",
|
||||
"commentOptional": "备注(可选)",
|
||||
"commentPlaceholder": "例如:仅允许只读操作",
|
||||
"reject": "拒绝",
|
||||
"allowOnce": "允许一次",
|
||||
"saveEditedAndAllow": "保存修改并允许",
|
||||
"waitingApprovalShort": "等待批准",
|
||||
"waitingApprovalCount": "等待批准 {{count}}",
|
||||
"approvalUrgencyUnlimited": "审批不限时",
|
||||
"approvalUrgencyMoreThanThree": "最早审批将在 3 分钟后到期",
|
||||
"approvalUrgencyMoreThanFive": "最早审批将在 5 分钟后到期",
|
||||
"approvalUrgencyThreeToFive": "最早审批将在 3–5 分钟内到期",
|
||||
"approvalUrgencyOneToThree": "最早审批将在 1–3 分钟内到期",
|
||||
"approvalUrgencyWithinOne": "最早审批将在 1 分钟内到期",
|
||||
"requestGeneric": "允许 CyberStrikeAI 调用 {{tool}}?",
|
||||
"requestVisitUrl": "允许 CyberStrikeAI 访问 {{url}}?",
|
||||
"requestBrowser": "允许 CyberStrikeAI 使用浏览器?",
|
||||
"requestCommand": "允许 CyberStrikeAI 执行这条命令?",
|
||||
"requestFile": "允许 CyberStrikeAI 修改 {{path}}?",
|
||||
"requestFiles": "允许 CyberStrikeAI 修改文件?",
|
||||
"toolTerminal": "终端",
|
||||
"toolFiles": "文件",
|
||||
"viewRequestDetails": "查看请求详情",
|
||||
"editRequestDetails": "查看或修改请求参数",
|
||||
"addApprovalComment": "添加审批备注(可选)",
|
||||
"timeoutAutoReject": "到期自动拒绝",
|
||||
"timeoutUnlimited": "不限时等待",
|
||||
"expiredAutoRejected": "审批已超时,正在自动拒绝…",
|
||||
"taskClosedApprovalUnavailable": "任务已结束,审批不可用",
|
||||
"taskInterrupted": "任务已中断",
|
||||
"interruptedApprovalCancelled": "任务已中断,审批已取消",
|
||||
"expiredRejected": "审批超时,已自动拒绝",
|
||||
"searchLabel": "搜索",
|
||||
"searchPlaceholder": "工具名、会话 ID、载荷、备注…",
|
||||
"searchApply": "搜索",
|
||||
@@ -3520,6 +3640,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 +3824,7 @@
|
||||
"deleteSelected": "删除选中",
|
||||
"autoLayout": "自动布局",
|
||||
"dryRun": "试运行",
|
||||
"canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加",
|
||||
"canvasEmpty": "从左侧拖拽节点到画布,或用自然语言生成工作流",
|
||||
"properties": "属性",
|
||||
"nodeProperties": "节点属性",
|
||||
"edgeProperties": "连线属性",
|
||||
|
||||
+23
-11
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeJsStringAttr(text) {
|
||||
return escapeAttr(escapeJsString(text));
|
||||
}
|
||||
|
||||
// ID转义(用于HTML ID属性)
|
||||
function escapeId(text) {
|
||||
return text.replace(/[{}]/g, '').replace(/\//g, '-');
|
||||
|
||||
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+22
-1
@@ -333,6 +333,15 @@ async function refreshAppData(showTaskErrors = false) {
|
||||
loadConversations(),
|
||||
loadActiveTasks(showTaskErrors),
|
||||
]);
|
||||
// 未登录首屏的项目侧栏可能先收到 401 并显示失败;认证完成后必须主动重试。
|
||||
// 放在对话/任务刷新之后,确保最终渲染一定使用有效登录态且不会被早期失败覆盖。
|
||||
if (typeof window.refreshChatProjectSelector === 'function') {
|
||||
try {
|
||||
await window.refreshChatProjectSelector({ reloadFolders: true });
|
||||
} catch (error) {
|
||||
console.warn('刷新项目侧栏失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapApp() {
|
||||
@@ -632,6 +641,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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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 +884,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
@@ -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, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** 任务列表操作按钮(查看/取消/删除)— 事件委托 */
|
||||
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 下拉选项 HTML(value / 文本已转义) */
|
||||
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'))}">×</button>
|
||||
<button type="button" class="c2-modal-close" onclick="C2.closeModal()" aria-label="${escapeAttr(c2t('common.close'))}">×</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>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
|
||||
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const zh = JSON.parse(fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8'));
|
||||
const en = JSON.parse(fs.readFileSync('web/static/i18n/en-US.json', 'utf8'));
|
||||
|
||||
test('主对话时间线不再创建用户或助手头像', () => {
|
||||
assert.doesNotMatch(chat, /createMessageAvatar/);
|
||||
assert.doesNotMatch(monitor, /createMessageAvatar/);
|
||||
assert.doesNotMatch(chat, /message-avatar/);
|
||||
assert.doesNotMatch(styles, /\.message-avatar/);
|
||||
});
|
||||
|
||||
test('新对话使用无图标的项目欢迎空状态', () => {
|
||||
assert.match(chat, /function renderChatWelcomeEmptyState\(\)/);
|
||||
assert.match(chat, /chat-welcome-empty-state-title/);
|
||||
assert.match(chat, /chat-welcome-empty-state-subtitle/);
|
||||
assert.doesNotMatch(chat, /chat-welcome-empty-state-icon/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state\s*\{[\s\S]*?justify-content: center/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state-title/);
|
||||
assert.match(styles, /\.chat-welcome-empty-state-subtitle/);
|
||||
assert.match(styles, /\.chat-welcome-project-name\s*\{[\s\S]*?border-bottom: 1px dotted currentColor/);
|
||||
assert.match(chat, /projectName\.className = 'chat-welcome-project-name'/);
|
||||
assert.match(chat, /title\.replaceChildren\(/);
|
||||
});
|
||||
|
||||
test('欢迎语随项目和无项目状态更新', () => {
|
||||
assert.match(chat, /window\.t\('chat\.projectWelcomeMessage', \{ project \}\)/);
|
||||
assert.match(chat, /window\.t\('chat\.noProjectWelcomeMessage'\)/);
|
||||
assert.match(projects, /window\.refreshChatWelcomeEmptyState\(\)/);
|
||||
assert.equal(
|
||||
zh.chat.projectWelcomeMessage,
|
||||
'当前{{project}}项目,请输入您的测试需求,系统将自动执行相应的安全测试。'
|
||||
);
|
||||
assert.equal(zh.chat.projectWelcomeTitlePrefix, '要在 ');
|
||||
assert.equal(zh.chat.projectWelcomeTitleSuffix, ' 项目中测试什么?');
|
||||
assert.equal(zh.chat.welcomeSubtitle, '请输入您的测试需求,系统将自动执行相应的安全测试。');
|
||||
assert.equal(typeof en.chat.projectWelcomeMessage, 'string');
|
||||
});
|
||||
|
||||
test('会话设置打开时提升整个输入区层级并遮住轮次导航', () => {
|
||||
assert.match(chat, /function syncChatSessionSettingsLayerState\(\)/);
|
||||
assert.match(chat, /inputBar\.classList\.toggle\('is-session-settings-open', open\)/);
|
||||
assert.match(styles, /\.chat-input-container\.is-session-settings-open\s*\{[\s\S]*?z-index:\s*121/);
|
||||
assert.match(styles, /\.chat-turn-rail\s*\{[\s\S]*?z-index:\s*20/);
|
||||
});
|
||||
@@ -13,6 +13,10 @@ let chatFilesPage = 1;
|
||||
let chatFilesPageSize = 20;
|
||||
let chatFilesSearchDebounceTimer = null;
|
||||
|
||||
function chatFilesEscapeAttr(text) {
|
||||
return escapeHtml(text).replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createKeydownHarness() {
|
||||
const context = {
|
||||
isComposing: false,
|
||||
mentionState: { active: false },
|
||||
mentionSuggestionsEl: null,
|
||||
sendCount: 0,
|
||||
sendMessage() {
|
||||
context.sendCount += 1;
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(
|
||||
`${functionSource(chat, 'handleChatInputKeydown', 'updateMentionStateFromInput')}; this.handleChatInputKeydown = handleChatInputKeydown;`,
|
||||
context
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
test('聊天输入框按 Enter 发送并阻止原生换行', () => {
|
||||
const context = createKeydownHarness();
|
||||
let prevented = false;
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: false,
|
||||
isComposing: false,
|
||||
keyCode: 13,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(context.sendCount, 1);
|
||||
});
|
||||
|
||||
test('聊天输入框按 Shift+Enter 只换行且不发送', () => {
|
||||
const context = createKeydownHarness();
|
||||
let prevented = false;
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: true,
|
||||
isComposing: false,
|
||||
keyCode: 13,
|
||||
preventDefault() {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(context.sendCount, 0);
|
||||
});
|
||||
|
||||
test('输入法确认候选词时按 Enter 不会发送', () => {
|
||||
const context = createKeydownHarness();
|
||||
|
||||
context.handleChatInputKeydown({
|
||||
key: 'Enter',
|
||||
shiftKey: false,
|
||||
isComposing: true,
|
||||
keyCode: 229,
|
||||
preventDefault() {
|
||||
throw new Error('IME Enter should not be prevented');
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(context.sendCount, 0);
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const PLAN_TOOL_NAMES = new Set(['taskcreate', 'taskupdate', 'tasklist', 'taskget']);
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const FINAL_STATE_HOLD_MS = 2400;
|
||||
|
||||
function normalizeTask(raw, index) {
|
||||
const task = raw && typeof raw === 'object' ? raw : {};
|
||||
const status = String(task.status || 'pending').trim().toLowerCase();
|
||||
return {
|
||||
id: String(task.id || (index + 1)),
|
||||
subject: String(task.subject || '').trim(),
|
||||
description: String(task.description || '').trim(),
|
||||
activeForm: String(task.activeForm || '').trim(),
|
||||
status: ['pending', 'in_progress', 'completed', 'deleted'].includes(status) ? status : 'pending'
|
||||
};
|
||||
}
|
||||
|
||||
function deriveProgress(rawTasks) {
|
||||
const tasks = (Array.isArray(rawTasks) ? rawTasks : [])
|
||||
.map(normalizeTask)
|
||||
.filter((task) => task.status !== 'deleted');
|
||||
let activeIndex = tasks.findIndex((task) => task.status === 'in_progress');
|
||||
if (activeIndex < 0) activeIndex = tasks.findIndex((task) => task.status !== 'completed');
|
||||
if (activeIndex < 0 && tasks.length) activeIndex = tasks.length - 1;
|
||||
return {
|
||||
tasks,
|
||||
total: tasks.length,
|
||||
completed: tasks.filter((task) => task.status === 'completed').length,
|
||||
activeStep: activeIndex >= 0 ? activeIndex + 1 : 0,
|
||||
allCompleted: tasks.length > 0 && tasks.every((task) => task.status === 'completed')
|
||||
};
|
||||
}
|
||||
|
||||
function applyTaskUpdate(rawTasks, args) {
|
||||
const update = args && typeof args === 'object' ? args : {};
|
||||
const taskID = String(update.taskId || update.taskID || '').trim();
|
||||
if (!taskID) return deriveProgress(rawTasks).tasks;
|
||||
return deriveProgress(rawTasks).tasks
|
||||
.filter((task) => !(String(update.status || '').toLowerCase() === 'deleted' && task.id === taskID))
|
||||
.map((task) => {
|
||||
if (task.id !== taskID) return task;
|
||||
return Object.assign({}, task, {
|
||||
subject: String(update.subject || task.subject).trim(),
|
||||
description: String(update.description || task.description).trim(),
|
||||
activeForm: String(update.activeForm || task.activeForm).trim(),
|
||||
status: String(update.status || task.status).trim().toLowerCase()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { normalizeTask, deriveProgress, applyTaskUpdate };
|
||||
return;
|
||||
}
|
||||
|
||||
const state = {
|
||||
conversationId: '',
|
||||
tasks: [],
|
||||
signature: '',
|
||||
expanded: false,
|
||||
requestSequence: 0,
|
||||
abortController: null,
|
||||
pollTimer: null,
|
||||
refreshTimer: null,
|
||||
finalHoldUntil: 0,
|
||||
taskCalls: new Map()
|
||||
};
|
||||
|
||||
const host = root.document && root.document.getElementById('agent-plan-progress');
|
||||
if (!host) return;
|
||||
|
||||
let passiveHoverAnchor = null;
|
||||
|
||||
function clearPassiveHoverVisual() {
|
||||
host.classList.remove('is-hover-active');
|
||||
}
|
||||
|
||||
function resetPassiveHover() {
|
||||
clearPassiveHoverVisual();
|
||||
passiveHoverAnchor = null;
|
||||
}
|
||||
|
||||
function disarmPassiveHover(event) {
|
||||
clearPassiveHoverVisual();
|
||||
if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
|
||||
passiveHoverAnchor = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
function armHoverAfterPointerMove(event) {
|
||||
if (event && event.pointerType && event.pointerType !== 'mouse') return;
|
||||
if (passiveHoverAnchor) {
|
||||
if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) return;
|
||||
// Smooth scrolling and layout shifts may emit pointermove without the
|
||||
// physical pointer moving. Keep the panel locked until coordinates change.
|
||||
if (event.clientX === passiveHoverAnchor.x && event.clientY === passiveHoverAnchor.y) return;
|
||||
passiveHoverAnchor = null;
|
||||
}
|
||||
host.classList.add('is-hover-active');
|
||||
}
|
||||
|
||||
host.addEventListener('pointermove', armHoverAfterPointerMove);
|
||||
host.addEventListener('pointerleave', clearPassiveHoverVisual);
|
||||
const returnLatestButton = root.document.getElementById('chat-return-latest');
|
||||
if (returnLatestButton) {
|
||||
// Clicking the return-to-latest control moves this plan chip downward.
|
||||
// Clear the hover gate before that layout shift so a stationary pointer
|
||||
// cannot accidentally reveal the plan panel underneath it.
|
||||
returnLatestButton.addEventListener('pointerdown', disarmPassiveHover);
|
||||
returnLatestButton.addEventListener('click', disarmPassiveHover);
|
||||
}
|
||||
|
||||
function translate(key, fallback, params) {
|
||||
if (typeof root.t === 'function') {
|
||||
const value = root.t(key, params || {});
|
||||
if (value && value !== key) return value;
|
||||
}
|
||||
let value = fallback;
|
||||
Object.entries(params || {}).forEach(([name, replacement]) => {
|
||||
value = value.replaceAll('{{' + name + '}}', String(replacement));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
function createSVG(className, pathData) {
|
||||
const namespace = 'http://www.w3.org/2000/svg';
|
||||
const svg = root.document.createElementNS(namespace, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.classList.add(className);
|
||||
const path = root.document.createElementNS(namespace, 'path');
|
||||
path.setAttribute('d', pathData);
|
||||
svg.appendChild(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function statusIcon(status) {
|
||||
const icon = root.document.createElement('span');
|
||||
icon.className = 'agent-plan-task-status agent-plan-task-status--' + status;
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
if (status === 'completed') {
|
||||
icon.appendChild(createSVG('agent-plan-task-check', 'M7.5 12.5 10.5 15.5 16.8 8.8'));
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
function taskLabel(task) {
|
||||
if (task.status === 'in_progress' && task.activeForm) return task.activeForm;
|
||||
return task.subject || translate('chat.taskProgressUnnamed', '未命名任务');
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
const progress = deriveProgress(state.tasks);
|
||||
const signature = JSON.stringify(progress.tasks) + '|' + state.expanded;
|
||||
if (!force && signature === state.signature) return;
|
||||
state.signature = signature;
|
||||
host.replaceChildren();
|
||||
if (!progress.total) {
|
||||
host.hidden = true;
|
||||
resetPassiveHover();
|
||||
return;
|
||||
}
|
||||
host.hidden = false;
|
||||
host.classList.toggle('is-open', state.expanded);
|
||||
|
||||
const panel = root.document.createElement('div');
|
||||
panel.className = 'agent-plan-progress-panel';
|
||||
panel.id = 'agent-plan-progress-panel';
|
||||
panel.setAttribute('role', 'status');
|
||||
panel.setAttribute('aria-label', translate('chat.taskProgressDetails', '任务进度详情'));
|
||||
progress.tasks.forEach((task) => {
|
||||
const row = root.document.createElement('div');
|
||||
row.className = 'agent-plan-task agent-plan-task--' + task.status;
|
||||
row.appendChild(statusIcon(task.status));
|
||||
const label = root.document.createElement('span');
|
||||
label.className = 'agent-plan-task-label';
|
||||
label.textContent = taskLabel(task);
|
||||
if (task.description) label.title = task.description;
|
||||
row.appendChild(label);
|
||||
panel.appendChild(row);
|
||||
});
|
||||
|
||||
const trigger = root.document.createElement('button');
|
||||
trigger.type = 'button';
|
||||
trigger.className = 'agent-plan-progress-trigger';
|
||||
trigger.setAttribute('aria-controls', panel.id);
|
||||
trigger.setAttribute('aria-expanded', state.expanded ? 'true' : 'false');
|
||||
trigger.setAttribute('aria-label', translate('chat.taskProgressOpen', '查看任务进度'));
|
||||
trigger.appendChild(statusIcon(progress.allCompleted ? 'completed' : 'in_progress'));
|
||||
const count = root.document.createElement('span');
|
||||
count.className = 'agent-plan-progress-count';
|
||||
count.textContent = translate('chat.taskProgressStep', '第 {{current}} / {{total}} 步', {
|
||||
current: progress.activeStep,
|
||||
total: progress.total
|
||||
});
|
||||
trigger.appendChild(count);
|
||||
trigger.addEventListener('click', () => {
|
||||
state.expanded = !state.expanded;
|
||||
render(true);
|
||||
if (state.expanded) host.querySelector('.agent-plan-progress-trigger')?.focus();
|
||||
});
|
||||
|
||||
host.append(panel, trigger);
|
||||
}
|
||||
|
||||
function currentConversationId() {
|
||||
return String(root.currentConversationId || '').trim();
|
||||
}
|
||||
|
||||
function setConversation(conversationId) {
|
||||
const next = String(conversationId || '').trim();
|
||||
if (next === state.conversationId) return false;
|
||||
state.conversationId = next;
|
||||
state.tasks = [];
|
||||
state.signature = '';
|
||||
state.expanded = false;
|
||||
state.finalHoldUntil = 0;
|
||||
state.taskCalls.clear();
|
||||
resetPassiveHover();
|
||||
state.requestSequence += 1;
|
||||
if (state.abortController) state.abortController.abort();
|
||||
state.abortController = null;
|
||||
render(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchPlanTasks() {
|
||||
const conversationId = currentConversationId();
|
||||
setConversation(conversationId);
|
||||
if (!conversationId || root.document.hidden) return;
|
||||
const sequence = ++state.requestSequence;
|
||||
if (state.abortController) state.abortController.abort();
|
||||
const controller = new AbortController();
|
||||
state.abortController = controller;
|
||||
try {
|
||||
const fetcher = typeof root.apiFetch === 'function' ? root.apiFetch : root.fetch.bind(root);
|
||||
const response = await fetcher('/api/conversations/' + encodeURIComponent(conversationId) + '/plan-tasks', {
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
const payload = await response.json();
|
||||
if (sequence !== state.requestSequence || conversationId !== state.conversationId) return;
|
||||
if (payload && payload.running === false) {
|
||||
state.tasks = [];
|
||||
state.expanded = false;
|
||||
state.finalHoldUntil = 0;
|
||||
render(false);
|
||||
return;
|
||||
}
|
||||
const tasks = deriveProgress(payload && payload.tasks).tasks;
|
||||
if (!tasks.length && state.tasks.length && Date.now() < state.finalHoldUntil) return;
|
||||
state.tasks = tasks;
|
||||
render(false);
|
||||
} catch (error) {
|
||||
if (error && error.name === 'AbortError') return;
|
||||
// A task list is supplemental UI; a transient poll failure must not
|
||||
// interfere with the chat or erase the last known task state.
|
||||
} finally {
|
||||
if (sequence === state.requestSequence) state.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh(delay) {
|
||||
root.clearTimeout(state.refreshTimer);
|
||||
state.refreshTimer = root.setTimeout(fetchPlanTasks, Number(delay) || 0);
|
||||
}
|
||||
|
||||
function handlePlanToolEvent(event) {
|
||||
const detail = event && event.detail && typeof event.detail === 'object' ? event.detail : {};
|
||||
const conversationId = String(detail.conversationId || '').trim();
|
||||
if (!conversationId || conversationId !== currentConversationId()) return;
|
||||
const data = detail.data && typeof detail.data === 'object' ? detail.data : {};
|
||||
const toolName = String(data.toolName || '').trim().toLowerCase();
|
||||
if (!PLAN_TOOL_NAMES.has(toolName)) return;
|
||||
const callId = String(data.toolCallId || '').trim();
|
||||
if (detail.eventType === 'tool_call') {
|
||||
if (callId) state.taskCalls.set(callId, { toolName, args: data.argumentsObj || {} });
|
||||
scheduleRefresh(60);
|
||||
return;
|
||||
}
|
||||
if (detail.eventType !== 'tool_result') return;
|
||||
const call = callId ? state.taskCalls.get(callId) : null;
|
||||
if (callId) state.taskCalls.delete(callId);
|
||||
if (data.success !== false && call && call.toolName === 'taskupdate') {
|
||||
state.tasks = applyTaskUpdate(state.tasks, call.args);
|
||||
const progress = deriveProgress(state.tasks);
|
||||
if (progress.allCompleted) state.finalHoldUntil = Date.now() + FINAL_STATE_HOLD_MS;
|
||||
render(false);
|
||||
}
|
||||
scheduleRefresh(100);
|
||||
}
|
||||
|
||||
root.addEventListener('agent-plan-task-event', handlePlanToolEvent);
|
||||
root.addEventListener('conversation-changed', (event) => {
|
||||
setConversation(event && event.detail ? event.detail.conversationId : currentConversationId());
|
||||
scheduleRefresh(0);
|
||||
});
|
||||
root.document.addEventListener('visibilitychange', () => {
|
||||
if (!root.document.hidden) scheduleRefresh(0);
|
||||
});
|
||||
root.document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape' || !state.expanded) return;
|
||||
state.expanded = false;
|
||||
render(true);
|
||||
});
|
||||
root.document.addEventListener('pointerdown', (event) => {
|
||||
if (!state.expanded || host.contains(event.target)) return;
|
||||
state.expanded = false;
|
||||
render(true);
|
||||
});
|
||||
|
||||
setConversation(currentConversationId());
|
||||
fetchPlanTasks();
|
||||
state.pollTimer = root.setInterval(fetchPlanTasks, POLL_INTERVAL_MS);
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,74 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const { deriveProgress, applyTaskUpdate } = require('./chat-plan-progress.js');
|
||||
|
||||
test('任务进度优先定位进行中步骤并保留完成项', () => {
|
||||
const progress = deriveProgress([
|
||||
{ id: '1', subject: '梳理需求', status: 'completed' },
|
||||
{ id: '2', subject: '实现组件', activeForm: '正在实现组件', status: 'in_progress' },
|
||||
{ id: '3', subject: '浏览器验证', status: 'pending' }
|
||||
]);
|
||||
assert.equal(progress.activeStep, 2);
|
||||
assert.equal(progress.completed, 1);
|
||||
assert.equal(progress.total, 3);
|
||||
assert.equal(progress.allCompleted, false);
|
||||
});
|
||||
|
||||
test('TaskUpdate 成功后即时勾选,最终步骤显示全部完成', () => {
|
||||
const initial = [
|
||||
{ id: '1', subject: '接口', status: 'completed' },
|
||||
{ id: '2', subject: '界面', status: 'in_progress' }
|
||||
];
|
||||
const updated = applyTaskUpdate(initial, { taskId: '2', status: 'completed' });
|
||||
const progress = deriveProgress(updated);
|
||||
assert.equal(progress.activeStep, 2);
|
||||
assert.equal(progress.completed, 2);
|
||||
assert.equal(progress.allCompleted, true);
|
||||
});
|
||||
|
||||
test('删除任务不会出现在悬浮清单中', () => {
|
||||
const tasks = applyTaskUpdate([
|
||||
{ id: '1', subject: '保留', status: 'pending' },
|
||||
{ id: '2', subject: '删除', status: 'pending' }
|
||||
], { taskId: '2', status: 'deleted' });
|
||||
assert.deepEqual(tasks.map((task) => task.id), ['1']);
|
||||
});
|
||||
|
||||
test('任务进度样式跟随系统主题变量而非固定深色', () => {
|
||||
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
|
||||
assert.match(css, /--agent-plan-surface:\s*var\(--card-bg\)/);
|
||||
assert.match(css, /background:\s*var\(--agent-plan-surface\)/);
|
||||
assert.match(css, /color:\s*var\(--agent-plan-text\)/);
|
||||
assert.doesNotMatch(css, /background:\s*#(?:292929|2b2b2b|303030)/i);
|
||||
});
|
||||
|
||||
test('回到最新按钮与任务进度同时显示时采用上下避让布局', () => {
|
||||
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
|
||||
const template = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
assert.match(css, /\.chat-return-latest:not\(\[hidden\]\)\s*\+\s*\.agent-plan-progress:not\(\[hidden\]\)/);
|
||||
assert.match(css, /--agent-plan-trigger-bottom:\s*64px/);
|
||||
assert.match(css, /--agent-plan-panel-bottom:\s*120px/);
|
||||
assert.match(css, /bottom:\s*var\(--agent-plan-trigger-bottom\)/);
|
||||
assert.match(css, /bottom:\s*var\(--agent-plan-panel-bottom\)/);
|
||||
assert.match(template, /<button[^>]+id="chat-return-latest"[\s\S]*?<\/button>\s*<div id="agent-plan-progress"/);
|
||||
});
|
||||
|
||||
test('计划详情只在真实鼠标移动或主动操作后展开', () => {
|
||||
const css = fs.readFileSync('web/static/css/chat-plan-progress.css', 'utf8');
|
||||
const source = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8');
|
||||
assert.doesNotMatch(css, /\.agent-plan-progress:hover\s+\.agent-plan-progress-panel/);
|
||||
assert.match(css, /\.agent-plan-progress\.is-hover-active\s+\.agent-plan-progress-panel/);
|
||||
assert.match(source, /host\.addEventListener\('pointermove',\s*armHoverAfterPointerMove\)/);
|
||||
assert.match(source, /returnLatestButton\.addEventListener\('pointerdown',\s*disarmPassiveHover\)/);
|
||||
assert.match(source, /passiveHoverAnchor = \{ x: event\.clientX, y: event\.clientY \}/);
|
||||
assert.match(source, /event\.clientX === passiveHoverAnchor\.x && event\.clientY === passiveHoverAnchor\.y/);
|
||||
assert.match(source, /host\.classList\.remove\('is-hover-active'\)/);
|
||||
});
|
||||
|
||||
test('服务端判定任务停止后立即清空旧任务卡片', () => {
|
||||
const source = fs.readFileSync('web/static/js/chat-plan-progress.js', 'utf8');
|
||||
assert.match(source, /payload && payload\.running === false/);
|
||||
assert.match(source, /state\.tasks = \[\][\s\S]{0,160}state\.expanded = false/);
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const scroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
|
||||
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
|
||||
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
|
||||
const router = fs.readFileSync('web/static/js/router.js', 'utf8');
|
||||
const auth = fs.readFileSync('web/static/js/auth.js', 'utf8');
|
||||
const html = fs.readFileSync('web/templates/index.html', 'utf8');
|
||||
|
||||
function functionSource(source, name, nextName) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
const end = source.indexOf(`function ${nextName}(`, start);
|
||||
assert.notEqual(start, -1, `${name} should exist`);
|
||||
assert.notEqual(end, -1, `${nextName} should follow ${name}`);
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
function createScrollRuntime() {
|
||||
const listeners = new Map();
|
||||
const buttonListeners = new Map();
|
||||
const classList = { add() {}, remove() {}, toggle() {}, contains() { return false; } };
|
||||
const chatEl = {
|
||||
scrollTop: 500,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 500,
|
||||
children: [],
|
||||
classList,
|
||||
addEventListener(type, handler) { listeners.set(type, handler); },
|
||||
scrollTo(options) { this.scrollTop = Number(options && options.top) || 0; },
|
||||
getBoundingClientRect() { return { right: 1000 }; },
|
||||
};
|
||||
const returnLatest = {
|
||||
hidden: true,
|
||||
classList,
|
||||
addEventListener(type, handler) { buttonListeners.set(type, handler); },
|
||||
blur() {},
|
||||
};
|
||||
const rafQueue = new Map();
|
||||
let rafId = 0;
|
||||
const requestAnimationFrame = (handler) => {
|
||||
const id = ++rafId;
|
||||
rafQueue.set(id, handler);
|
||||
return id;
|
||||
};
|
||||
const cancelAnimationFrame = (id) => rafQueue.delete(id);
|
||||
const document = {
|
||||
readyState: 'complete',
|
||||
getElementById(id) {
|
||||
if (id === 'chat-messages') return chatEl;
|
||||
if (id === 'chat-return-latest') return returnLatest;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
addEventListener() {},
|
||||
};
|
||||
const window = {
|
||||
document,
|
||||
addEventListener() {},
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
innerWidth: 1440,
|
||||
innerHeight: 900,
|
||||
};
|
||||
const context = {
|
||||
window,
|
||||
document,
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
console,
|
||||
};
|
||||
vm.runInNewContext(scroll, context);
|
||||
return {
|
||||
api: window.CyberStrikeChatScroll,
|
||||
chatEl,
|
||||
listeners,
|
||||
flushAnimationFrames() {
|
||||
while (rafQueue.size) {
|
||||
const pending = Array.from(rafQueue.values());
|
||||
rafQueue.clear();
|
||||
pending.forEach((handler) => handler(Date.now()));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('向上滚动立即解除粘底,只有滚到真实底部才恢复', () => {
|
||||
const runtime = createScrollRuntime();
|
||||
runtime.flushAnimationFrames();
|
||||
|
||||
runtime.listeners.get('wheel')({ deltaY: -20 });
|
||||
runtime.chatEl.scrollTop = 480;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), false);
|
||||
|
||||
runtime.chatEl.scrollHeight = 1100;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 480, '新输出不能抢回用户的阅读位置');
|
||||
|
||||
runtime.chatEl.scrollTop = 597;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), false, '距底部 2px 以上仍保持脱离');
|
||||
|
||||
runtime.chatEl.scrollTop = 600;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), true, '用户滚到真实底部后立即恢复跟随');
|
||||
|
||||
runtime.chatEl.scrollHeight = 1200;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 1200, '恢复后新增输出继续请求滚到最底部');
|
||||
});
|
||||
|
||||
test('刷新重建详情引起的布局上移不会误判为用户上滑', () => {
|
||||
const runtime = createScrollRuntime();
|
||||
runtime.flushAnimationFrames();
|
||||
|
||||
runtime.chatEl.scrollTop = 460;
|
||||
runtime.listeners.get('scroll')();
|
||||
assert.equal(runtime.api.captureScrollPinState(), true, '没有用户输入的布局滚动仍应保持跟随');
|
||||
|
||||
runtime.chatEl.scrollHeight = 1100;
|
||||
runtime.api.scrollIfPinned(true);
|
||||
runtime.flushAnimationFrames();
|
||||
assert.equal(runtime.chatEl.scrollTop, 1100, '刷新恢复后的后续增量应继续粘底');
|
||||
});
|
||||
|
||||
test('登录成功后重新加载曾因未授权失败的项目侧栏', () => {
|
||||
const refreshSource = functionSource(auth, 'refreshAppData', 'bootstrapApp');
|
||||
const conversationsIndex = refreshSource.indexOf('loadConversations()');
|
||||
const projectRetryIndex = refreshSource.indexOf('window.refreshChatProjectSelector({ reloadFolders: true })');
|
||||
|
||||
assert.notEqual(conversationsIndex, -1);
|
||||
assert.ok(projectRetryIndex > conversationsIndex);
|
||||
assert.match(refreshSource, /typeof window\.refreshChatProjectSelector === 'function'/);
|
||||
assert.match(html, /\/static\/js\/auth\.js\?v=20260813-1/);
|
||||
});
|
||||
|
||||
test('用户真正滑到底部后恢复自动跟随且不会提前强制跳底', () => {
|
||||
const resumeSource = functionSource(scroll, 'resumeFollowingIfAtBottom', 'captureScrollPinState');
|
||||
const captureSource = functionSource(scroll, 'captureScrollPinState', 'setScrollFollowing');
|
||||
const autoSource = functionSource(scroll, 'canAutoScrollNow', 'scheduleChatScrollToBottomIfFollowing');
|
||||
const scrollSource = functionSource(scroll, 'onChatMessagesScroll', 'bindChatScrollListeners');
|
||||
|
||||
assert.match(resumeSource, /thresholdPx/);
|
||||
assert.match(scroll, /CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2/);
|
||||
assert.doesNotMatch(captureSource, /resumeFollowingIfAtBottom/);
|
||||
assert.doesNotMatch(autoSource, /resumeFollowingIfAtBottom/);
|
||||
assert.match(resumeSource, /if \(!userInitiated\) return false/);
|
||||
assert.match(scrollSource, /scrolledDown/);
|
||||
assert.match(scrollSource, /hasUserScrollIntent/);
|
||||
assert.match(scrollSource, /resumeFollowingIfAtBottom\(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true\)/);
|
||||
assert.doesNotMatch(scrollSource, /resumeFollowingIfAtBottom\(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX\)/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.doesNotMatch(scrollSource, /scheduleChatScrollToBottomIfFollowing\(true\)/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.match(scrollSource, /contentShrank/);
|
||||
assert.match(scrollSource, /sh < lastScrollHeight - 1/);
|
||||
assert.match(scrollSource, /if \(scrolledUp && \(scrollMode === 'detached' \|\| hasUserScrollIntent\)\) \{[\s\S]*?setScrollDetached\(\)/);
|
||||
assert.match(scrollSource, /if \(programmaticScroll\) \{[\s\S]*?st < lastScrollTop - 1 && \(scrollMode === 'detached' \|\| hasUserScrollIntent\)[\s\S]*?setScrollDetached\(\)/);
|
||||
});
|
||||
|
||||
test('切换对话模式引起的布局滚动不会重新开启粘底', () => {
|
||||
const scrollSource = functionSource(scroll, 'onChatMessagesScroll', 'bindChatScrollListeners');
|
||||
const bindSource = functionSource(scroll, 'bindChatScrollListeners', 'initChatScroll');
|
||||
const selectModeSource = functionSource(chat, 'selectAgentMode', 'initChatAgentModeFromConfig');
|
||||
|
||||
assert.match(scroll, /let userScrollIntentUntil = 0/);
|
||||
assert.match(scrollSource, /const hasUserScrollIntent = Date\.now\(\) <= userScrollIntentUntil/);
|
||||
assert.match(scrollSource, /scrolledDown &&[\s\S]*?hasUserScrollIntent &&[\s\S]*?resumeFollowingIfAtBottom/);
|
||||
assert.doesNotMatch(scrollSource, /else if \(resumeFollowingIfAtBottom\(\)\)/);
|
||||
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
|
||||
assert.match(bindSource, /userScrollIntentUntil = Date\.now\(\) \+ 1800/);
|
||||
assert.doesNotMatch(selectModeSource, /setScrollFollowing|forceScrollToBottom|scrollTop/);
|
||||
});
|
||||
|
||||
test('刷新运行中任务补齐最新详情后保持粘底但尊重用户上滑', () => {
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
const settleSource = functionSource(scroll, 'settleChatToBottomIfFollowing', 'scrollChatMessagesToBottomIfPinned');
|
||||
|
||||
assert.match(attachSource, /window\.captureScrollPinState\(\)/);
|
||||
assert.match(attachSource, /settleToBottomIfFollowing\(12\)/);
|
||||
assert.match(attachSource, /settleToBottomIfFollowing\(18\)/);
|
||||
assert.match(attachSource, /用户期间没有主动上滑/);
|
||||
assert.match(attachSource, /keepFollowingFinalRender/);
|
||||
assert.match(attachSource, /最终消息和详情重绘都会增高 DOM/);
|
||||
assert.match(settleSource, /scrollMode !== 'following'/);
|
||||
assert.match(settleSource, /Date\.now\(\) < detachLockUntil/);
|
||||
assert.match(settleSource, /settleFrame\(remaining - 1\)/);
|
||||
assert.match(settleSource, /scrollChatToBottomInstant\(\)/);
|
||||
assert.match(scroll, /function settleConversationRestoreToBottom\(frameCount\)/);
|
||||
assert.match(scroll, /CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000/);
|
||||
assert.match(scroll, /CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000/);
|
||||
assert.match(scroll, /const generation = \+\+conversationRestoreGeneration/);
|
||||
assert.match(scroll, /scrollMode !== 'following'/);
|
||||
assert.match(scroll, /stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES/);
|
||||
assert.match(scroll, /requestAnimationFrame\(settleRestoreFrame\)/);
|
||||
assert.match(chat, /settleConversationRestoreToBottom\(30\)/);
|
||||
});
|
||||
|
||||
test('刷新后迭代思考区独立跟随最新内容且允许用户上滑解除', () => {
|
||||
const startSource = functionSource(monitor, 'startProcessDetailsLatestFollow', 'loadProcessDetailsPaginated');
|
||||
const loadSource = functionSource(monitor, 'loadProcessDetailsPaginated', 'shouldInitiallyOpenProcessDetailsAtLatest');
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
|
||||
assert.match(startSource, /new MutationObserver\(scheduleFollowLatest\)/);
|
||||
assert.match(startSource, /characterData: true/);
|
||||
assert.match(startSource, /new ResizeObserver\(scheduleFollowLatest\)/);
|
||||
assert.match(startSource, /scrollProcessDetailsToLatest\(String\(assistantMessageId \|\| ''\), false\)/);
|
||||
assert.match(startSource, /event\.deltaY < -1/);
|
||||
assert.match(startSource, /state\.userScrollIntentUntil = Date\.now\(\) \+ 1200/);
|
||||
assert.match(startSource, /event\.clientX >= rect\.right - PROCESS_DETAILS_FOLLOW_SCROLLBAR_GUTTER_PX/);
|
||||
assert.match(startSource, /event\.key === 'ArrowUp'/);
|
||||
assert.match(startSource, /cancelAnimationFrame\(state\.rafId\)/);
|
||||
assert.match(startSource, /if \(scrolledUp && \(state\.detached \|\| Date\.now\(\) <= state\.userScrollIntentUntil\)\) \{[\s\S]*?detachForUserNavigation\(\)/);
|
||||
assert.match(startSource, /state\.detached &&[\s\S]*?scrolledDown &&[\s\S]*?Date\.now\(\) <= state\.userScrollIntentUntil/);
|
||||
assert.match(monitor, /PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX = 2/);
|
||||
assert.match(startSource, /distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX/);
|
||||
assert.match(startSource, /state\.detached = false/);
|
||||
assert.doesNotMatch(startSource, /if \(distance <= PROCESS_DETAILS_FOLLOW_RESUME_THRESHOLD_PX\) \{\s*state\.detached = false/);
|
||||
assert.match(loadSource, /startProcessDetailsLatestFollow\(assistantMessageId/);
|
||||
assert.match(attachSource, /startProcessDetailsLatestFollow\(asEl\.id, \{ persistent: true \}\)/);
|
||||
assert.match(attachSource, /stopProcessDetailsLatestFollow\(asEl\.id\)/);
|
||||
});
|
||||
|
||||
test('刷新后的工具调用恢复与实时一致的成功失败徽标', () => {
|
||||
const renderSource = functionSource(chat, 'renderProcessDetails', 'finishProcessDetailsRender');
|
||||
const presentationSource = functionSource(monitor, 'getToolCallStatusPresentation', 'applyToolCallStatus');
|
||||
const applySource = functionSource(monitor, 'applyToolCallStatus', 'updateToolCallStatus');
|
||||
const addSource = functionSource(monitor, 'addTimelineItem', 'loadActiveTasks');
|
||||
|
||||
assert.match(renderSource, /toolStatusByProcessDetailId/);
|
||||
assert.match(renderSource, /timelineOpts\.toolStatus = toolStatusByProcessDetailId\.get/);
|
||||
assert.match(presentationSource, /normalized === 'completed'/);
|
||||
assert.match(presentationSource, /normalized === 'failed'/);
|
||||
assert.match(applySource, /tool-status-badge/);
|
||||
assert.match(applySource, /item\.dataset\.toolDisplayStatus = presentation\.status/);
|
||||
assert.match(addSource, /initialToolStatus = item\.dataset\.toolDisplayStatus/);
|
||||
assert.match(addSource, /applyToolCallStatus\(item, initialToolStatus\)/);
|
||||
assert.match(monitor, /refreshProgressAndTimelineI18n\(\)[\s\S]*?applyToolCallStatus\(item, item\.dataset\.toolDisplayStatus\)/);
|
||||
});
|
||||
|
||||
test('首次实时输出与刷新恢复都保留独立迭代滚动并跟随最新内容', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
const addSource = functionSource(monitor, 'addProgressMessage', 'toggleProgressDetails');
|
||||
const liveSource = functionSource(monitor, 'startLiveProgressLatestFollow', 'stopLiveProgressLatestFollow');
|
||||
|
||||
assert.match(css, /\.progress-container\.is-streaming \.progress-timeline\.expanded,[\s\S]{0,360}max-height: min\(64vh, 720px\);[\s\S]{0,180}overflow-y: auto;/);
|
||||
assert.match(css, /\.message\.progress-message \.progress-timeline\.expanded \{[\s\S]{0,260}max-height: min\(64vh, 720px\);[\s\S]{0,160}overflow-y: auto;/);
|
||||
assert.doesNotMatch(css, /流式执行中[\s\S]{0,320}overflow-y: visible;/);
|
||||
assert.match(addSource, /startLiveProgressLatestFollow\(id\)/);
|
||||
assert.match(liveSource, /stateKey: liveProgressLatestFollowKey\(id\)/);
|
||||
assert.match(liveSource, /persistent: true/);
|
||||
assert.match(liveSource, /target\.scrollTop = Math\.max\(0, target\.scrollHeight - target\.clientHeight\)/);
|
||||
assert.match(monitor, /function finalizeProgressTask\(progressId, finalLabel\) \{[\s\S]{0,120}stopLiveProgressLatestFollow\(progressId\)/);
|
||||
});
|
||||
|
||||
test('同一会话的其他标签页自动补流且发送前阻止重复任务', () => {
|
||||
const syncSource = functionSource(monitor, 'syncVisibleConversationTaskReplay', 'getActiveTaskDisplayName');
|
||||
const sendSource = functionSource(chat, 'sendMessage', 'renderChatFileChips');
|
||||
|
||||
assert.match(monitor, /new BroadcastChannel\(CHAT_TASK_SYNC_CHANNEL_NAME\)/);
|
||||
assert.match(monitor, /payload\.type !== 'task-started'/);
|
||||
assert.match(monitor, /conversationExecutionTracker\.markRunning\(id\)/);
|
||||
assert.match(syncSource, /await window\.loadConversation\(conversationId\)/);
|
||||
assert.match(syncSource, /return attachRunningTaskEventStream\(conversationId\)/);
|
||||
assert.match(monitor, /syncVisibleConversationTaskReplay\(normalizedTasks\)/);
|
||||
assert.match(sendSource, /await loadActiveTasks\(\)/);
|
||||
assert.match(sendSource, /if \(isCurrentChatTaskActive\(\)\)/);
|
||||
assert.ok(sendSource.indexOf('if (isCurrentChatTaskActive())') < sendSource.indexOf("addMessage('user'"));
|
||||
assert.match(sendSource, /window\.notifyConversationTaskStarted\(streamConversationId\)/);
|
||||
});
|
||||
|
||||
test('刷新补流在订阅竞态或终态帧丢失时从数据库对账最终正文', () => {
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
const reconcileSource = functionSource(monitor, 'reconcileConversationAfterTaskReplay', 'cancelRunningTaskEventStream');
|
||||
|
||||
assert.match(attachSource, /const eventStreamResponsePromise = apiFetch\(url/);
|
||||
assert.ok(attachSource.indexOf('const eventStreamResponsePromise') < attachSource.indexOf('loadProcessDetailsPaginated'));
|
||||
assert.match(attachSource, /if \(!active\) \{[\s\S]*?assistantMessageNeedsTaskReplayReconcile\(staleAssistant\)[\s\S]*?reconcileConversationAfterTaskReplay\(conversationId, true\)/);
|
||||
assert.match(attachSource, /if \(!response\.ok\) \{[\s\S]*?reconcileConversationAfterTaskReplay\(conversationId, true\)/);
|
||||
assert.match(attachSource, /if \(!replaySawDone\) \{[\s\S]*?reconcileConversationAfterTaskReplay/);
|
||||
assert.match(reconcileSource, /updateAssistantBubbleContent\(assistantEl\.id, finalMessage\.content \|\| '', true\)/);
|
||||
assert.match(reconcileSource, /loadProcessDetailsPaginated\(assistantEl\.id, finalMessage\.id,[\s\S]*?initialLatest: true,[\s\S]*?autoLoadAll: false/);
|
||||
});
|
||||
|
||||
test('消息气泡内部流式增高时仅在跟随模式继续粘底', () => {
|
||||
const bindSource = functionSource(scroll, 'bindChatScrollListeners', 'initChatScroll');
|
||||
|
||||
assert.match(bindSource, /scrollMode === 'following'/);
|
||||
assert.match(bindSource, /scheduleChatScrollToBottomIfFollowing\(true\)/);
|
||||
assert.match(bindSource, /\{ childList: true, subtree: true, characterData: true \}/);
|
||||
assert.match(bindSource, /new ResizeObserver/);
|
||||
assert.match(bindSource, /chatMessagesResizeObserver\.observe\(el\)/);
|
||||
assert.match(bindSource, /改变消息区 clientHeight/);
|
||||
assert.match(bindSource, /Math\.abs\(e\.deltaY\) > 1/);
|
||||
assert.match(bindSource, /e\.deltaY < -1/);
|
||||
assert.match(bindSource, /e\.clientX >= rect\.right - 18/);
|
||||
assert.match(bindSource, /e\.key === 'ArrowUp'/);
|
||||
});
|
||||
|
||||
test('页面在任务补流脚本之前加载智能滚动控制器', () => {
|
||||
const scrollIndex = html.indexOf('/static/js/chat-scroll.js?v=20260813-6');
|
||||
const monitorIndex = html.indexOf('/static/js/monitor.js?v=20260813-9');
|
||||
|
||||
assert.notEqual(scrollIndex, -1);
|
||||
assert.notEqual(monitorIndex, -1);
|
||||
assert.ok(scrollIndex < monitorIndex);
|
||||
});
|
||||
|
||||
test('直接点击项目对话也会写入 hash 以便刷新后恢复并补流', () => {
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const syncSource = functionSource(chat, 'syncChatConversationHash', 'getConversationLiteFromCache');
|
||||
const streamSource = functionSource(monitor, 'setCurrentConversationIdFromStream', 'shouldSkipTaskEventReplayAttach');
|
||||
|
||||
assert.match(syncSource, /window\.location\.hash\.split\('\?'\)\[0\] !== '#chat'/);
|
||||
assert.match(syncSource, /#chat\?conversation=/);
|
||||
assert.match(syncSource, /window\.history\.replaceState/);
|
||||
assert.match(loadSource, /syncChatConversationHash\(conversationId\)/);
|
||||
assert.match(streamSource, /window\.syncChatConversationHash\(cid\)/);
|
||||
});
|
||||
|
||||
test('刷新指定对话时立即恢复且加载完成前不闪出无项目状态', () => {
|
||||
const scheduleSource = functionSource(router, 'scheduleChatConversationFromHash', 'navigateToConversation');
|
||||
const restoreStateSource = functionSource(router, 'setChatConversationRestorePending', 'finishChatConversationRestore');
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
|
||||
assert.match(router, /scheduleChatConversationFromHash\(0\)/);
|
||||
assert.doesNotMatch(router, /scheduleChatConversationFromHash\((200|500)\)/);
|
||||
assert.match(scheduleSource, /setChatConversationRestorePending\(conversationId, true\)/);
|
||||
assert.match(restoreStateSource, /is-conversation-restoring/);
|
||||
assert.match(restoreStateSource, /aria-busy/);
|
||||
assert.match(loadSource, /finally \{[\s\S]*?finishChatConversationRestore\(conversationId\)/);
|
||||
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-messages/);
|
||||
assert.match(css, /\.chat-container\.is-conversation-restoring #chat-input-container/);
|
||||
assert.match(html, /router\.js\?v=20260813-2/);
|
||||
assert.match(html, /chat\.js\?v=20260813-4/);
|
||||
});
|
||||
|
||||
test('刷新运行中回复会复用已持久化 planning 并继续追加未来增量', () => {
|
||||
const findSource = functionSource(monitor, 'findRestoredMainResponseStreamItem', 'responseStreamStateFromRestoredItem');
|
||||
const handleSource = functionSource(monitor, 'handleStreamEvent', 'hitlApprovalTranslate');
|
||||
|
||||
assert.match(findSource, /timeline-item-planning/);
|
||||
assert.match(findSource, /dataset\.responseStreamId/);
|
||||
assert.match(handleSource, /case 'response_start':[\s\S]*?findRestoredMainResponseStreamItem/);
|
||||
assert.match(handleSource, /case 'response_delta':[\s\S]*?responseStreamStateFromRestoredItem/);
|
||||
assert.match(monitor, /item\.dataset\.responseStreamId = String\(options\.data\.streamId\)/);
|
||||
});
|
||||
|
||||
test('非仪表盘 hash 首屏在路由确定前隐藏默认仪表盘', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(html, /document\.documentElement\.classList\.add\('initial-route-pending'\)/);
|
||||
assert.match(router, /document\.documentElement\.classList\.remove\('initial-route-pending'\)/);
|
||||
assert.match(css, /html\.initial-route-pending \.content-area \{[\s\S]*?visibility: hidden;/);
|
||||
});
|
||||
|
||||
test('刷新恢复运行中助手消息时隐藏处理中占位且终态正文会重新显示', () => {
|
||||
const loadSource = functionSource(chat, 'loadConversation', 'attachDeleteTurnButton');
|
||||
const updateSource = functionSource(monitor, 'updateAssistantBubbleContent', 'isConversationTaskRunning');
|
||||
|
||||
assert.match(loadSource, /hideAssistantPlaceholder: isAssistantPlaceholder/);
|
||||
assert.match(chat, /bubble\.hidden = true/);
|
||||
assert.match(updateSource, /assistant-placeholder-content/);
|
||||
assert.match(updateSource, /bubble\.hidden = false/);
|
||||
});
|
||||
|
||||
test('刷新补流任务完成后强制折叠自动展开的迭代详情', () => {
|
||||
const collapseSource = functionSource(monitor, 'collapseAllProgressDetails', 'getAssistantId');
|
||||
const attachSource = functionSource(monitor, 'attachRunningTaskEventStream', 'parseToolCallArgsFromData');
|
||||
|
||||
assert.match(collapseSource, /options/);
|
||||
assert.match(collapseSource, /forceCollapse/);
|
||||
assert.match(collapseSource, /delete detailsContainer\.dataset\.userExpanded/);
|
||||
assert.match(attachSource, /collapseAllProgressDetails\(finalAssistant\.id, progressId, \{ force: true \}\)/);
|
||||
assert.doesNotMatch(attachSource, /if \(keepExpanded\)/);
|
||||
});
|
||||
|
||||
test('暗色模式用户气泡使用协调的深蓝灰层级', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.message\.user \.message-bubble \{[\s\S]*?background: #1b2638;/);
|
||||
assert.match(css, /border-color: rgba\(96, 165, 250, 0\.18\)/);
|
||||
});
|
||||
|
||||
test('暗色模式对话三点悬浮不会触发浅色父行背景', () => {
|
||||
const css = fs.readFileSync('web/static/css/style.css', 'utf8');
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-conversation-row:hover \.project-conversation-item/);
|
||||
assert.match(css, /html\[data-theme="dark"\] \.project-folder-action:hover,[\s\S]*?background: rgba\(71, 85, 105, 0\.28\);[\s\S]*?box-shadow: none;/);
|
||||
assert.match(html, /style\.css\?v=20260813-6/);
|
||||
});
|
||||
+549
-49
@@ -7,32 +7,322 @@
|
||||
|
||||
/** 距底部在此范围内才继续自动跟随(宜小,避免“差一点也被拽回去”) */
|
||||
const CHAT_SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
||||
/** FAB 隐藏:用户已手动滚近底部 */
|
||||
const CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX = 120;
|
||||
/** 只有真正到达底部才恢复跟随;2px 用于兼容高分屏的亚像素滚动。 */
|
||||
const CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX = 2;
|
||||
/** 到达此范围视为位于最后一轮 */
|
||||
const CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX = 120;
|
||||
/** 用户上滑后的短暂锁,防止 SSE 与 scroll 事件竞态抢滚动 */
|
||||
const DETACH_LOCK_MS = 280;
|
||||
const DETACH_LOCK_MS = 900;
|
||||
/** 刷新恢复会跨越历史消息、过程详情、字体与流订阅等多轮异步布局。 */
|
||||
const CONVERSATION_RESTORE_SETTLE_MIN_MS = 3000;
|
||||
const CONVERSATION_RESTORE_SETTLE_MAX_MS = 6000;
|
||||
const CONVERSATION_RESTORE_STABLE_FRAMES = 12;
|
||||
|
||||
/** @type {'following' | 'detached'} */
|
||||
let scrollMode = 'following';
|
||||
let scrollFollowRaf = 0;
|
||||
let scrollSettleGeneration = 0;
|
||||
let conversationRestoreGeneration = 0;
|
||||
/** 用户脱离跟随后,下方是否有未读的新输出(不按 SSE 次数计) */
|
||||
let hasPendingNewBelow = false;
|
||||
let listenersBound = false;
|
||||
let lastScrollTop = 0;
|
||||
let lastScrollHeight = 0;
|
||||
let programmaticScroll = false;
|
||||
let detachLockUntil = 0;
|
||||
/** 最近一次由用户发起的滚动意图;布局变化或脚本滚动不得据此恢复粘底。 */
|
||||
let userScrollIntentUntil = 0;
|
||||
let turnRailRefreshRaf = 0;
|
||||
let turnRailSignature = '';
|
||||
let activeTurnIndex = -1;
|
||||
let turnRailObserver = null;
|
||||
let chatMessagesResizeObserver = null;
|
||||
let turnPreviewHideTimer = 0;
|
||||
|
||||
function getChatMessagesEl() {
|
||||
return document.getElementById('chat-messages');
|
||||
}
|
||||
|
||||
/** 主 POST 流 + 刷新后 task-events 补流均视为「流式进行中」 */
|
||||
function getTurnRailEl() {
|
||||
return document.getElementById('chat-turn-rail');
|
||||
}
|
||||
|
||||
function getTurnRailMarkersEl() {
|
||||
return document.getElementById('chat-turn-rail-markers');
|
||||
}
|
||||
|
||||
function getReturnLatestButton() {
|
||||
return document.getElementById('chat-return-latest');
|
||||
}
|
||||
|
||||
function normalizePreviewText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function trimPreviewText(value, maxLength) {
|
||||
const text = normalizePreviewText(value);
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.slice(0, Math.max(1, maxLength - 1)).trimEnd() + '…';
|
||||
}
|
||||
|
||||
function messagePreviewText(messageEl) {
|
||||
if (!messageEl) return '';
|
||||
const original = messageEl.dataset ? messageEl.dataset.originalContent : '';
|
||||
if (original) return normalizePreviewText(original);
|
||||
const bubble = messageEl.querySelector('.assistant-final-result, .message-bubble');
|
||||
if (!bubble) return '';
|
||||
const clone = bubble.cloneNode(true);
|
||||
clone.querySelectorAll('button, .message-copy-btn, .progress-actions, .progress-footer, .process-details-content').forEach(function (el) {
|
||||
el.remove();
|
||||
});
|
||||
return normalizePreviewText(clone.textContent);
|
||||
}
|
||||
|
||||
/** 每条用户消息开始一轮,直到下一条用户消息前的助手消息都归入该轮。 */
|
||||
function collectConversationTurns() {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
if (!messagesEl) return [];
|
||||
const turns = [];
|
||||
let currentTurn = null;
|
||||
Array.from(messagesEl.children).forEach(function (messageEl) {
|
||||
if (!messageEl.classList || !messageEl.classList.contains('message')) return;
|
||||
if (messageEl.classList.contains('user')) {
|
||||
currentTurn = { user: messageEl, assistants: [] };
|
||||
turns.push(currentTurn);
|
||||
return;
|
||||
}
|
||||
if (currentTurn && messageEl.classList.contains('assistant')) {
|
||||
currentTurn.assistants.push(messageEl);
|
||||
}
|
||||
});
|
||||
return turns;
|
||||
}
|
||||
|
||||
function localizedTurnLabel(index, question) {
|
||||
const number = index + 1;
|
||||
const prefix = typeof window.t === 'function'
|
||||
? window.t('chat.turnNumber', { number: number })
|
||||
: '第 ' + number + ' 轮';
|
||||
const safePrefix = prefix && prefix !== 'chat.turnNumber' ? prefix : ('第 ' + number + ' 轮');
|
||||
return question ? safePrefix + ':' + question : safePrefix;
|
||||
}
|
||||
|
||||
function turnPreviewData(turn, index) {
|
||||
const question = trimPreviewText(messagePreviewText(turn && turn.user), 100)
|
||||
|| localizedTurnLabel(index, '');
|
||||
const assistants = turn && turn.assistants ? turn.assistants : [];
|
||||
let assistant = null;
|
||||
for (let i = assistants.length - 1; i >= 0; i--) {
|
||||
if (!assistants[i].classList.contains('progress-message')) {
|
||||
assistant = assistants[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!assistant && assistants.length) assistant = assistants[assistants.length - 1];
|
||||
let summary = trimPreviewText(messagePreviewText(assistant), 220);
|
||||
if (!summary) {
|
||||
summary = typeof window.t === 'function' ? window.t('chat.turnPending') : '正在处理…';
|
||||
if (!summary || summary === 'chat.turnPending') summary = '正在处理…';
|
||||
}
|
||||
return { question: question, summary: summary };
|
||||
}
|
||||
|
||||
function hideTurnPreview() {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
const preview = document.getElementById('chat-turn-rail-preview');
|
||||
if (preview) preview.hidden = true;
|
||||
}
|
||||
|
||||
function scheduleHideTurnPreview() {
|
||||
if (turnPreviewHideTimer) window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = window.setTimeout(hideTurnPreview, 160);
|
||||
}
|
||||
|
||||
function showTurnPreview(marker, index) {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
const preview = document.getElementById('chat-turn-rail-preview');
|
||||
const title = document.getElementById('chat-turn-rail-preview-title');
|
||||
const summary = document.getElementById('chat-turn-rail-preview-summary');
|
||||
const turn = collectConversationTurns()[index];
|
||||
if (!preview || !title || !summary || !marker || !turn) return;
|
||||
|
||||
const data = turnPreviewData(turn, index);
|
||||
title.textContent = data.question;
|
||||
summary.textContent = data.summary;
|
||||
preview.hidden = false;
|
||||
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
const left = Math.min(markerRect.right + 18, window.innerWidth - previewRect.width - 12);
|
||||
const desiredTop = markerRect.top + markerRect.height / 2 - previewRect.height / 2;
|
||||
const top = Math.max(12, Math.min(desiredTop, window.innerHeight - previewRect.height - 12));
|
||||
preview.style.left = Math.max(12, left) + 'px';
|
||||
preview.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function setActiveTurnMarker(index) {
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
if (!markersEl) return;
|
||||
const markers = Array.from(markersEl.querySelectorAll('.chat-turn-rail-marker'));
|
||||
if (!markers.length) return;
|
||||
const nextIndex = Math.max(0, Math.min(index, markers.length - 1));
|
||||
markers.forEach(function (marker, markerIndex) {
|
||||
const active = markerIndex === nextIndex;
|
||||
marker.classList.toggle('is-active', active);
|
||||
if (active) marker.setAttribute('aria-current', 'step');
|
||||
else marker.removeAttribute('aria-current');
|
||||
});
|
||||
markers[markers.length - 1].classList.toggle('has-pending-new', hasPendingNewBelow);
|
||||
|
||||
if (activeTurnIndex !== nextIndex) {
|
||||
activeTurnIndex = nextIndex;
|
||||
const activeMarker = markers[nextIndex];
|
||||
const markerTop = activeMarker.offsetTop;
|
||||
const markerBottom = markerTop + activeMarker.offsetHeight;
|
||||
if (markerTop < markersEl.scrollTop) {
|
||||
markersEl.scrollTop = Math.max(0, markerTop - 8);
|
||||
} else if (markerBottom > markersEl.scrollTop + markersEl.clientHeight) {
|
||||
markersEl.scrollTop = markerBottom - markersEl.clientHeight + 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateTurnRailActive() {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
const turns = collectConversationTurns();
|
||||
if (!messagesEl || !turns.length) return;
|
||||
if (isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX)) {
|
||||
setActiveTurnMarker(turns.length - 1);
|
||||
return;
|
||||
}
|
||||
const readingLine = messagesEl.scrollTop + messagesEl.clientHeight * 0.34;
|
||||
let index = 0;
|
||||
for (let i = 0; i < turns.length; i++) {
|
||||
if (turns[i].user.offsetTop <= readingLine) index = i;
|
||||
else break;
|
||||
}
|
||||
setActiveTurnMarker(index);
|
||||
}
|
||||
|
||||
function jumpToConversationTurn(index) {
|
||||
const messagesEl = getChatMessagesEl();
|
||||
const turn = collectConversationTurns()[index];
|
||||
if (!messagesEl || !turn || !turn.user) return;
|
||||
setScrollDetached();
|
||||
programmaticScroll = true;
|
||||
messagesEl.scrollTo({
|
||||
top: Math.max(0, turn.user.offsetTop - 20),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
setActiveTurnMarker(index);
|
||||
hideTurnPreview();
|
||||
window.setTimeout(function () {
|
||||
programmaticScroll = false;
|
||||
lastScrollTop = messagesEl.scrollTop;
|
||||
updateTurnRailActive();
|
||||
}, 420);
|
||||
}
|
||||
|
||||
function focusTurnMarker(index) {
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
const marker = markersEl && markersEl.querySelector('.chat-turn-rail-marker[data-turn-index="' + index + '"]');
|
||||
if (marker) marker.focus();
|
||||
}
|
||||
|
||||
function rebuildTurnRail(force) {
|
||||
const rail = getTurnRailEl();
|
||||
const markersEl = getTurnRailMarkersEl();
|
||||
if (!rail || !markersEl) return;
|
||||
const turns = collectConversationTurns();
|
||||
rail.hidden = turns.length === 0;
|
||||
if (!turns.length) {
|
||||
markersEl.replaceChildren();
|
||||
turnRailSignature = '';
|
||||
activeTurnIndex = -1;
|
||||
hideTurnPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = turns.map(function (turn, index) {
|
||||
return (turn.user.id || ('turn-' + index)) + ':' + messagePreviewText(turn.user);
|
||||
}).join('|');
|
||||
if (!force && signature === turnRailSignature) {
|
||||
updateTurnRailActive();
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
turns.forEach(function (turn, index) {
|
||||
const marker = document.createElement('button');
|
||||
const question = trimPreviewText(messagePreviewText(turn.user), 88);
|
||||
marker.type = 'button';
|
||||
marker.className = 'chat-turn-rail-marker';
|
||||
marker.dataset.turnIndex = String(index);
|
||||
marker.setAttribute('aria-label', localizedTurnLabel(index, question));
|
||||
marker.addEventListener('click', function () {
|
||||
jumpToConversationTurn(index);
|
||||
});
|
||||
marker.addEventListener('mouseenter', function () {
|
||||
showTurnPreview(marker, index);
|
||||
});
|
||||
marker.addEventListener('mouseleave', scheduleHideTurnPreview);
|
||||
marker.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(Math.min(turns.length - 1, index + 1));
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(Math.max(0, index - 1));
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(0);
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
focusTurnMarker(turns.length - 1);
|
||||
}
|
||||
});
|
||||
fragment.appendChild(marker);
|
||||
});
|
||||
markersEl.replaceChildren(fragment);
|
||||
turnRailSignature = signature;
|
||||
activeTurnIndex = -1;
|
||||
updateTurnRailActive();
|
||||
}
|
||||
|
||||
function scheduleTurnRailRefresh(force) {
|
||||
cancelAnimationFrame(turnRailRefreshRaf);
|
||||
turnRailRefreshRaf = requestAnimationFrame(function () {
|
||||
rebuildTurnRail(force === true);
|
||||
});
|
||||
}
|
||||
|
||||
function streamBelongsToVisibleConversation(stream) {
|
||||
if (!stream || !stream.active) return false;
|
||||
const visibleConversationId = typeof window.currentConversationId === 'string'
|
||||
? window.currentConversationId.trim()
|
||||
: '';
|
||||
const streamConversationId = typeof stream.conversationId === 'string'
|
||||
? stream.conversationId.trim()
|
||||
: '';
|
||||
|
||||
// 新建对话在后端返回 conversationId 前,两边都为空,仍属于当前界面。
|
||||
if (!streamConversationId) return !visibleConversationId;
|
||||
return streamConversationId === visibleConversationId;
|
||||
}
|
||||
|
||||
/** 只有当前可见对话的主 POST 流 / task-events 补流才视为「正在输出」 */
|
||||
function isStreamActive() {
|
||||
try {
|
||||
const live = window.__csAgentLiveStream;
|
||||
if (live && live.active) return true;
|
||||
if (streamBelongsToVisibleConversation(live)) return true;
|
||||
const replay = window.__csTaskEventStream;
|
||||
return !!(replay && replay.active);
|
||||
return streamBelongsToVisibleConversation(replay);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -51,34 +341,42 @@
|
||||
}
|
||||
|
||||
function isChatMessagesPinnedToBottom() {
|
||||
return isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
|
||||
return isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
/** 已在底部时恢复 following(解决:手动滚到底但 scrollMode 仍为 detached) */
|
||||
function resumeFollowingIfAtBottom() {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (!isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX)) return false;
|
||||
if (scrollMode === 'detached') setScrollFollowing();
|
||||
function resumeFollowingIfAtBottom(thresholdPx, userInitiated) {
|
||||
if (!userInitiated && Date.now() < detachLockUntil) return false;
|
||||
const threshold = Number.isFinite(Number(thresholdPx))
|
||||
? Math.max(0, Number(thresholdPx))
|
||||
: CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX;
|
||||
if (!isNearBottom(threshold)) return false;
|
||||
// detached 是用户明确上滑后的阅读状态。布局变化、流式增高和模式切换
|
||||
// 即使让视口暂时接近底部,也不能自行恢复;只有用户明确向下滚到底才恢复。
|
||||
if (scrollMode === 'detached') {
|
||||
if (!userInitiated) return false;
|
||||
setScrollFollowing();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function captureScrollPinState() {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (resumeFollowingIfAtBottom()) return true;
|
||||
return scrollMode === 'following';
|
||||
}
|
||||
|
||||
function setScrollFollowing() {
|
||||
scrollMode = 'following';
|
||||
detachLockUntil = 0;
|
||||
userScrollIntentUntil = 0;
|
||||
hasPendingNewBelow = false;
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function markPendingNewBelow() {
|
||||
if (scrollMode !== 'detached') return;
|
||||
hasPendingNewBelow = true;
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function setScrollDetached() {
|
||||
@@ -88,7 +386,7 @@
|
||||
if (isStreamActive()) {
|
||||
hasPendingNewBelow = true;
|
||||
}
|
||||
updateScrollToBottomFab();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function scrollChatToBottomInstant() {
|
||||
@@ -98,6 +396,7 @@
|
||||
programmaticScroll = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
requestAnimationFrame(function () {
|
||||
programmaticScroll = false;
|
||||
});
|
||||
@@ -111,34 +410,52 @@
|
||||
requestAnimationFrame(function () {
|
||||
programmaticScroll = false;
|
||||
const node = getChatMessagesEl();
|
||||
if (node) lastScrollTop = node.scrollTop;
|
||||
if (node) {
|
||||
lastScrollTop = node.scrollTop;
|
||||
lastScrollHeight = node.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateScrollToBottomFab() {
|
||||
const fab = document.getElementById('chat-scroll-to-bottom');
|
||||
if (!fab) return;
|
||||
function updateTurnRailState() {
|
||||
updateTurnRailActive();
|
||||
updateReturnLatestButton();
|
||||
}
|
||||
|
||||
const show = scrollMode === 'detached' && !isNearBottom(CHAT_SCROLL_FAB_HIDE_THRESHOLD_PX);
|
||||
fab.classList.toggle('visible', show);
|
||||
function updateReturnLatestButton() {
|
||||
const button = getReturnLatestButton();
|
||||
const messagesEl = getChatMessagesEl();
|
||||
if (!button || !messagesEl) return;
|
||||
const scrollable = messagesEl.scrollHeight > messagesEl.clientHeight + 2;
|
||||
const shouldShow = scrollable && !isNearBottom(CHAT_SCROLL_NAV_BOTTOM_THRESHOLD_PX);
|
||||
const streaming = shouldShow && isStreamActive();
|
||||
button.hidden = !shouldShow;
|
||||
button.classList.toggle('is-streaming', streaming);
|
||||
button.classList.toggle('has-pending-new', shouldShow && hasPendingNewBelow);
|
||||
}
|
||||
|
||||
let label;
|
||||
if (hasPendingNewBelow) {
|
||||
label = typeof window.t === 'function'
|
||||
? window.t('chat.scrollToBottomHasNew')
|
||||
: '↓ 有新内容';
|
||||
} else {
|
||||
label = typeof window.t === 'function'
|
||||
? window.t('chat.scrollToBottom')
|
||||
: '回到底部';
|
||||
function isolateReturnLatestPointerEvent(event) {
|
||||
if (!event) return;
|
||||
// 该按钮会在点击后立即隐藏。阻止指针事件继续冒泡,避免长历史对话中
|
||||
// 按钮隐藏与底部审批卡片重排发生在同一帧时产生点击穿透。
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onReturnLatestClick(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
forceScrollChatToBottom(true);
|
||||
const button = getReturnLatestButton();
|
||||
if (button) {
|
||||
button.hidden = true;
|
||||
button.blur();
|
||||
}
|
||||
fab.setAttribute('aria-label', label);
|
||||
fab.textContent = label;
|
||||
}
|
||||
|
||||
function canAutoScrollNow(wasPinnedBeforeDomUpdate) {
|
||||
if (Date.now() < detachLockUntil) return false;
|
||||
if (resumeFollowingIfAtBottom()) return true;
|
||||
if (scrollMode === 'detached') return false;
|
||||
if (wasPinnedBeforeDomUpdate === true) return true;
|
||||
return isNearBottom(CHAT_SCROLL_FOLLOW_THRESHOLD_PX);
|
||||
@@ -153,6 +470,79 @@
|
||||
scrollFollowRaf = requestAnimationFrame(scrollChatToBottomInstant);
|
||||
}
|
||||
|
||||
/**
|
||||
* 长详情恢复/终态对账会跨多个 requestAnimationFrame 分批增高 DOM。
|
||||
* 单次滚底可能早于最后一批节点;在仍处于 following 时连续若干帧校准,
|
||||
* 用户一旦主动上滑进入 detached,后续帧立即停止,避免抢回阅读位置。
|
||||
*/
|
||||
function settleChatToBottomIfFollowing(frameCount) {
|
||||
const frames = Number.isFinite(Number(frameCount))
|
||||
? Math.max(1, Math.min(30, Math.floor(Number(frameCount))))
|
||||
: 12;
|
||||
const generation = ++scrollSettleGeneration;
|
||||
|
||||
function settleFrame(remaining) {
|
||||
if (generation !== scrollSettleGeneration) return;
|
||||
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
|
||||
scrollChatToBottomInstant();
|
||||
if (remaining > 1) {
|
||||
requestAnimationFrame(function () {
|
||||
settleFrame(remaining - 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
settleFrame(frames);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新恢复长会话时,消息、详情和审批卡会跨多帧继续增高。
|
||||
* 进入恢复流程时明确回到 following;用户随后若主动上滑,既有输入监听会立即
|
||||
* 切换为 detached,并使后续校准帧停止,不会抢回阅读位置。
|
||||
*/
|
||||
function settleConversationRestoreToBottom(frameCount) {
|
||||
setScrollFollowing();
|
||||
const requestedFrames = Number.isFinite(Number(frameCount))
|
||||
? Math.max(1, Math.floor(Number(frameCount)))
|
||||
: 30;
|
||||
const minimumDuration = Math.max(
|
||||
CONVERSATION_RESTORE_SETTLE_MIN_MS,
|
||||
Math.ceil(requestedFrames * (1000 / 60))
|
||||
);
|
||||
const generation = ++conversationRestoreGeneration;
|
||||
const startedAt = Date.now();
|
||||
let lastHeight = -1;
|
||||
let stableFrames = 0;
|
||||
|
||||
function settleRestoreFrame() {
|
||||
if (generation !== conversationRestoreGeneration) return;
|
||||
// wheel / touch / keyboard / scrollbar drag 会进入 detached;立即尊重用户阅读位置。
|
||||
if (scrollMode !== 'following' || Date.now() < detachLockUntil) return;
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
|
||||
scrollChatToBottomInstant();
|
||||
const currentHeight = el.scrollHeight;
|
||||
if (currentHeight === lastHeight && isNearBottom(1)) {
|
||||
stableFrames += 1;
|
||||
} else {
|
||||
stableFrames = 0;
|
||||
}
|
||||
lastHeight = currentHeight;
|
||||
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const reachedStableMinimum = elapsed >= minimumDuration
|
||||
&& stableFrames >= CONVERSATION_RESTORE_STABLE_FRAMES;
|
||||
if (!reachedStableMinimum && elapsed < CONVERSATION_RESTORE_SETTLE_MAX_MS) {
|
||||
requestAnimationFrame(settleRestoreFrame);
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(settleRestoreFrame);
|
||||
}
|
||||
|
||||
/** @param {boolean} wasPinned DOM 更新前是否应跟随(由 captureScrollPinState 传入) */
|
||||
function scrollChatMessagesToBottomIfPinned(wasPinned) {
|
||||
scheduleChatScrollToBottomIfFollowing(wasPinned);
|
||||
@@ -210,7 +600,8 @@
|
||||
try {
|
||||
window.__csTaskEventStream = { active: false, conversationId: null, assistantDomId: null, progressId: null };
|
||||
} catch (e) { /* ignore */ }
|
||||
updateScrollToBottomFab();
|
||||
scheduleTurnRailRefresh(true);
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
/** 刷新后会话 task-events 补流开始时,与 sendMessage 主流程对齐 */
|
||||
@@ -225,7 +616,8 @@
|
||||
} catch (e) { /* ignore */ }
|
||||
markProcessDetailsStreaming(true, assistantDomId);
|
||||
resumeFollowingIfAtBottom();
|
||||
updateScrollToBottomFab();
|
||||
scheduleTurnRailRefresh();
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function onTaskEventStreamEnd() {
|
||||
@@ -233,6 +625,7 @@
|
||||
}
|
||||
|
||||
function applyMessageScrollOption(options) {
|
||||
scheduleTurnRailRefresh();
|
||||
const opt = (options && options.scroll) || 'follow';
|
||||
if (opt === 'none') return;
|
||||
if (opt === 'force') {
|
||||
@@ -252,22 +645,51 @@
|
||||
const el = getChatMessagesEl();
|
||||
if (!el) return;
|
||||
|
||||
const st = el.scrollTop;
|
||||
const sh = el.scrollHeight;
|
||||
const hasUserScrollIntent = Date.now() <= userScrollIntentUntil;
|
||||
|
||||
if (programmaticScroll) {
|
||||
lastScrollTop = el.scrollTop;
|
||||
// 正在执行恢复/流式粘底时,用户仍可能反向滚轮或拖动滚动条。
|
||||
// 脚本滚底只会让 scrollTop 增大;此处出现减小必定是用户在中断跟随。
|
||||
if (st < lastScrollTop - 1 && (scrollMode === 'detached' || hasUserScrollIntent)) {
|
||||
setScrollDetached();
|
||||
}
|
||||
lastScrollTop = st;
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
return;
|
||||
}
|
||||
|
||||
const st = el.scrollTop;
|
||||
const scrolledUp = st < lastScrollTop - 1;
|
||||
const scrolledDown = st > lastScrollTop + 1;
|
||||
const contentShrank = sh < lastScrollHeight - 1;
|
||||
|
||||
if (scrolledUp) {
|
||||
// 刷新/终态重绘会先清空或折叠旧 DOM,浏览器会被动把 scrollTop 压小。
|
||||
// 这不是用户上滑,不应错误退出 following。
|
||||
if (contentShrank) {
|
||||
lastScrollTop = st;
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
return;
|
||||
}
|
||||
|
||||
// 刷新恢复会重建消息和详情,滚动锚定可能在没有用户输入时让 scrollTop
|
||||
// 暂时减小。只有明确的滚轮、触控、键盘或滚动条意图才解除粘底。
|
||||
if (scrolledUp && (scrollMode === 'detached' || hasUserScrollIntent)) {
|
||||
setScrollDetached();
|
||||
} else if (resumeFollowingIfAtBottom()) {
|
||||
/* 拖滚动条/点击轨道跳到底部时也恢复跟随 */
|
||||
} else if (
|
||||
scrolledDown &&
|
||||
hasUserScrollIntent &&
|
||||
resumeFollowingIfAtBottom(CHAT_SCROLL_FOLLOW_RESUME_THRESHOLD_PX, true)
|
||||
) {
|
||||
// 仅在用户明确向下滚动并到达真实底部时恢复跟随,不主动改写 scrollTop。
|
||||
// 后续新增内容再按 following 状态自然粘底,避免接近底部时突然跳动。
|
||||
}
|
||||
|
||||
lastScrollTop = st;
|
||||
updateScrollToBottomFab();
|
||||
lastScrollHeight = sh;
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
function bindChatScrollListeners() {
|
||||
@@ -276,13 +698,38 @@
|
||||
if (!el) return;
|
||||
listenersBound = true;
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
|
||||
el.addEventListener('wheel', function (e) {
|
||||
if (e.deltaY < -1) setScrollDetached();
|
||||
if (Math.abs(e.deltaY) > 1) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
}
|
||||
if (e.deltaY < -1) {
|
||||
setScrollDetached();
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// 拖动原生纵向滚动条不会产生 wheel;先记录指针意图,再由 scroll 事件确认方向。
|
||||
el.addEventListener('pointerdown', function (e) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (e.clientX >= rect.right - 18) {
|
||||
userScrollIntentUntil = Date.now() + 1800;
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
el.addEventListener('keydown', function (e) {
|
||||
const scrollKeys = ['ArrowUp', 'PageUp', 'Home', 'ArrowDown', 'PageDown', 'End', ' '];
|
||||
if (scrollKeys.includes(e.key)) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'PageUp' || e.key === 'Home' || (e.key === ' ' && e.shiftKey)) {
|
||||
setScrollDetached();
|
||||
}
|
||||
});
|
||||
|
||||
el.addEventListener('touchmove', function (e) {
|
||||
if (e.touches && e.touches.length === 1) {
|
||||
userScrollIntentUntil = Date.now() + 1200;
|
||||
el._csTouchLastY = el._csTouchLastY != null ? el._csTouchLastY : e.touches[0].clientY;
|
||||
if (e.touches[0].clientY > el._csTouchLastY + 4) {
|
||||
setScrollDetached();
|
||||
@@ -301,19 +748,68 @@
|
||||
|
||||
el.addEventListener('scroll', onChatMessagesScroll, { passive: true });
|
||||
|
||||
const fab = document.getElementById('chat-scroll-to-bottom');
|
||||
if (fab) {
|
||||
fab.addEventListener('click', function () {
|
||||
forceScrollChatToBottom(true);
|
||||
});
|
||||
const returnLatestButton = getReturnLatestButton();
|
||||
if (returnLatestButton) {
|
||||
returnLatestButton.addEventListener('pointerdown', isolateReturnLatestPointerEvent);
|
||||
returnLatestButton.addEventListener('pointerup', isolateReturnLatestPointerEvent);
|
||||
returnLatestButton.addEventListener('click', onReturnLatestClick);
|
||||
}
|
||||
|
||||
const turnPreview = document.getElementById('chat-turn-rail-preview');
|
||||
if (turnPreview) {
|
||||
turnPreview.addEventListener('mouseenter', function () {
|
||||
if (turnPreviewHideTimer) {
|
||||
window.clearTimeout(turnPreviewHideTimer);
|
||||
turnPreviewHideTimer = 0;
|
||||
}
|
||||
});
|
||||
turnPreview.addEventListener('mouseleave', scheduleHideTurnPreview);
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === 'function') {
|
||||
turnRailObserver = new MutationObserver(function () {
|
||||
scheduleTurnRailRefresh();
|
||||
// 最终回复会替换消息气泡内部 HTML,任务详情也会在子树内持续增高。
|
||||
// 只在仍处于 following 时按帧合并粘底;用户上滑后的 detached 状态不受影响。
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
}
|
||||
});
|
||||
turnRailObserver.observe(el, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
chatMessagesResizeObserver = new ResizeObserver(function () {
|
||||
// 顶部运行任务条、输入框或视口变化会改变消息区 clientHeight,
|
||||
// 但不会触发消息子树 MutationObserver。跟随模式下需重新精确粘底。
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
} else {
|
||||
updateTurnRailState();
|
||||
}
|
||||
});
|
||||
chatMessagesResizeObserver.observe(el);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
hideTurnPreview();
|
||||
if (scrollMode === 'following' && Date.now() >= detachLockUntil) {
|
||||
scheduleChatScrollToBottomIfFollowing(true);
|
||||
} else {
|
||||
updateTurnRailState();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function initChatScroll() {
|
||||
bindChatScrollListeners();
|
||||
const el = getChatMessagesEl();
|
||||
if (el) lastScrollTop = el.scrollTop;
|
||||
updateScrollToBottomFab();
|
||||
if (el) {
|
||||
lastScrollTop = el.scrollTop;
|
||||
lastScrollHeight = el.scrollHeight;
|
||||
}
|
||||
scheduleTurnRailRefresh(true);
|
||||
updateTurnRailState();
|
||||
}
|
||||
|
||||
window.CyberStrikeChatScroll = {
|
||||
@@ -325,6 +821,8 @@
|
||||
captureScrollPinState: captureScrollPinState,
|
||||
scheduleScroll: scheduleChatScrollToBottomIfFollowing,
|
||||
scrollIfPinned: scrollChatMessagesToBottomIfPinned,
|
||||
settleToBottomIfFollowing: settleChatToBottomIfFollowing,
|
||||
settleConversationRestoreToBottom: settleConversationRestoreToBottom,
|
||||
forceScrollToBottom: forceScrollChatToBottom,
|
||||
applyMessageScroll: applyMessageScrollOption,
|
||||
scrollIntoViewIfFollowing: scrollElementIntoViewIfFollowing,
|
||||
@@ -333,6 +831,8 @@
|
||||
markProcessDetailsStreaming: markProcessDetailsStreaming,
|
||||
setScrollFollowing: setScrollFollowing,
|
||||
setScrollDetached: setScrollDetached,
|
||||
refreshReturnLatest: updateReturnLatestButton,
|
||||
refreshTurnRail: function () { scheduleTurnRailRefresh(true); },
|
||||
};
|
||||
|
||||
window.isChatMessagesPinnedToBottom = isChatMessagesPinnedToBottom;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user