Compare commits

..
27 Commits
Author SHA1 Message Date
公明 c345880765 Update version number to v1.7.13 2026-08-14 15:44:45 +08:00
RuoJi6 a1615143be feat(ui): 显示 Agent 任务进度列表 (#251) 2026-08-14 10:44:03 +08:00
公明 7c45011074 Add files via upload 2026-08-13 22:15:08 +08:00
RuoJi6 eb6bab574f 优化项目对话、刷新续流、实时滚动与工具状态恢复 (#245)
* feat(chat): add project-based conversation sidebar

* feat(chat): refine Codex-style conversation UI

* feat(chat): add Codex-style conversation workflow

* feat(ui): 优化对话框与项目侧边栏交互

* fix(chat): 修复暗色输入框圆角填色

* fix(chat): 恢复输入区分层错位布局

* fix(hitl): isolate reviewer state per conversation

* feat(hitl): 增加双入口审批与倒计时进度

* fix(hitl): 汇总项目审批并隔离对话状态

* fix(ui): 修复审批状态与无项目新任务

* fix(ui): 优化审批状态与对话切换性能

* fix(ui): 修复中断任务审批仍计时

* fix(ui): 修复多对话并发切换卡顿

* fix(hitl): 主动同步审批并关闭中断状态

* fix(ui): 固定项目审批汇总为绿色

* feat(ui): 同步系统模型与推理强度

* feat(ui): 完善项目侧栏预览与新建入口

* fix(ui): 防止无项目文件夹误展开

* fix(ui): 防止长历史对话滚动误触审批

* fix(ui): 修复对话操作并补充项目置顶

* fix(ui): 移除对话分组并调整项目置顶排序

* feat(ui): 优化迭代导航与审批交互

* fix(hitl): 将 write_file 加入内置免审批工具

* fix(chat): 支持回车发送与 Shift 回车换行

* fix(chat): 优化对话刷新与 Codex 风格交互

* fix(ui): 显示对话具体更新时间

* fix(ui): 优化对话刷新与项目加载

* fix(ui): 修复 Agent 审查文字裁切

* fix(chat): 修复刷新续流与多标签页同步

* fix(chat): 修复滚动跟随与中断任务终态

* fix(ui): 修复流式滚动跳动与工具状态恢复

* fix(ui): 修复刷新后流式输出停止粘底
2026-08-13 21:28:37 +08:00
公明 b170f2c4b1 Add files via upload 2026-08-10 10:28:14 +08:00
RuoJi6 02216a4660 feat: exempt get_project_fact from HITL approval (#239) 2026-08-08 23:44:00 +08:00
tian-IRT a53e4a4a64 fix: recover from duplicate streaming tool call indexes (#231) 2026-08-06 17:52:04 +08:00
公明 3bb8ec57fd Update config.example.yaml 2026-08-06 17:42:04 +08:00
公明 95c8e3b1a2 Add files via upload 2026-08-06 17:16:50 +08:00
公明 ed47ee202e Add files via upload 2026-08-06 17:14:06 +08:00
公明 d3714c9913 Add files via upload 2026-08-06 17:12:21 +08:00
公明 30513dbbfd Add files via upload 2026-08-06 10:50:34 +08:00
RuoJi6 aff9c9301f feat: refine HITL audit policy defaults (#236) 2026-08-05 18:02:48 +08:00
公明 f7ba7070ca Add files via upload 2026-08-04 11:13:14 +08:00
公明 5d1f5d2886 Add files via upload 2026-07-31 21:24:09 +08:00
公明 0f92817261 Add files via upload 2026-07-31 21:21:47 +08:00
公明 2ec5953416 Add files via upload 2026-07-31 21:19:50 +08:00
公明 b4fe2e795e Add files via upload 2026-07-31 21:18:21 +08:00
公明 904d860797 Add files via upload 2026-07-31 21:15:42 +08:00
公明 dc08199af6 Add files via upload 2026-07-31 21:14:10 +08:00
公明 84e99220ff Add files via upload 2026-07-31 21:12:01 +08:00
公明 86f1d10a8b Add files via upload 2026-07-31 21:09:45 +08:00
公明 5c643a1606 Add files via upload 2026-07-31 21:06:22 +08:00
公明 b9854192c6 Add files via upload 2026-07-31 21:04:11 +08:00
公明 5a5762e1d1 Add files via upload 2026-07-29 17:50:38 +08:00
公明 8882d70393 Add files via upload 2026-07-29 11:43:53 +08:00
公明 5747ebc612 Add files via upload 2026-07-29 11:42:23 +08:00
107 changed files with 15811 additions and 1518 deletions
+80 -26
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.9"
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 一致
+43 -11
View File
@@ -34,7 +34,39 @@ Saved workflows can be bound to a role under **Role Management**. When `workflow
---
## 3. Execution model (read this before configuring)
## 3. Natural-language Draft Generation
The Workflows page provides a **Create from natural language** entry point. After a user describes a security operations goal, CyberStrikeAI generates an editable draft and returns a structured audit result:
- Draft generation does not save, dry-run, or execute tools automatically.
- The server endpoint is `POST /api/workflows/generate-draft`, protected by `workflow:write`.
- The response includes `graph`, `meta`, `capabilities`, `audit`, and `stats`; after applying the draft to the canvas, normal save validation still runs.
- High-risk language such as executing scripts, isolating hosts, blocking, deleting, or exploitation is marked as `high_risk` and defaults to HITL approval or `requires_human_confirmation`.
- Tool capabilities are matched against the available tool list; unmatched capabilities fall back to Agent draft nodes and are surfaced in `audit.missing_fields` / `audit.assumptions`.
- If server generation is unavailable, the frontend uses a local deterministic fallback and still returns an editable draft with risk notes.
Example request:
```http
POST /api/workflows/generate-draft
Content-Type: application/json
{
"prompt": "Scan target assets for open ports, create a vulnerability task when critical ports are found, ask the owner for approval, and output a report",
"options": {
"include_objective": true,
"allow_schedule": false,
"allow_high_risk": false
},
"available_tools": [
{ "key": "nmap", "name": "nmap", "enabled": true }
]
}
```
---
## 4. Execution model (read this before configuring)
The engine executes the workflow as a **directed graph**, starting from the **Start** node and following edges to downstream nodes.
@@ -116,7 +148,7 @@ Agent B still receives Agent As output even when a condition node lies betwee
---
## 4. Template syntax
## 5. Template syntax
### 4.1 Basic format
@@ -198,7 +230,7 @@ Field bindings can read ordinary fields such as `output` or `message`, and also
---
## 5. Node types and configuration
## 6. Node types and configuration
### 5.1 Start
@@ -318,7 +350,7 @@ Optional node for an end summary template (less common in role-bound flows).
---
## 6. Edge configuration
## 7. Edge configuration
Select an **edge** to configure its **condition** in the right panel.
@@ -335,7 +367,7 @@ If no edge condition is set:
---
## 7. Full example: passing Agent output across a condition
## 8. Full example: passing Agent output across a condition
### 7.1 Graph structure
@@ -384,7 +416,7 @@ Start → Agent (initial value) → Condition → Agent (transform) → Output
---
## 8. Bind to a role and run
## 9. Bind to a role and run
### 8.1 Bind in Role Management
@@ -414,7 +446,7 @@ If no Output node is reached or no branch matches, `outputs` may be empty and th
---
## 9. Debugging, dry-run, and replay
## 10. Debugging, dry-run, and replay
### 9.1 Safe dry-run
@@ -487,7 +519,7 @@ Token and cost metrics depend on whether the underlying model/Agent events repor
---
## 10. Validation before save
## 11. Validation before save
On save, the system checks:
@@ -510,7 +542,7 @@ On save, the system checks:
---
## 11. Troubleshooting
## 12. Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
@@ -526,7 +558,7 @@ On save, the system checks:
---
## 12. Best practices
## 13. Best practices
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
@@ -539,7 +571,7 @@ On save, the system checks:
---
## 13. Code references (for developers)
## 14. Code references (for developers)
| Module | Path |
|--------|------|
+43 -11
View File
@@ -34,7 +34,39 @@
---
## 三、执行模型(先理解再配置)
## 三、自然语言生成草稿
工作流页面提供 **用自然语言创建** 入口。用户描述一句安全作业目标后,平台会生成一个可编辑草稿,并返回结构化审计结果:
- 只生成草稿,不会自动保存、试运行或真实执行工具。
- 服务端接口为 `POST /api/workflows/generate-draft`,权限为 `workflow:write`
- 生成结果包含 `graph``meta``capabilities``audit``stats`,前端应用到画布后仍走现有保存校验。
- 高风险语义(执行脚本、隔离、封禁、删除、利用等)会标记 `high_risk`,并默认插入 HITL 审批或 `requires_human_confirmation`
- 工具能力按已有工具列表匹配;未匹配到时降级为 Agent 草稿,并在 `audit.missing_fields` / `audit.assumptions` 中提示需要补配置。
- 如果服务端生成不可用,前端会使用本地确定性兜底生成器,继续给出可编辑草稿和风险提示。
示例请求:
```http
POST /api/workflows/generate-draft
Content-Type: application/json
{
"prompt": "",
"options": {
"include_objective": true,
"allow_schedule": false,
"allow_high_risk": false
},
"available_tools": [
{ "key": "nmap", "name": "nmap", "enabled": true }
]
}
```
---
## 四、执行模型(先理解再配置)
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
@@ -116,7 +148,7 @@ outputs["你填的变量名"] = 节点输出内容
---
## 、模板语法
## 、模板语法
### 4.1 基本格式
@@ -198,7 +230,7 @@ jq({{outputs.scan}}, ".severity") == "high"
---
## 、节点类型与配置
## 、节点类型与配置
### 5.1 开始(start
@@ -318,7 +350,7 @@ HITL 等待信息会记录:
---
## 、连线配置
## 、连线配置
选中 **连线** 后,右侧可配置 **连线条件**
@@ -335,7 +367,7 @@ HITL 等待信息会记录:
---
## 、完整示例:跨条件节点传递 Agent 输出
## 、完整示例:跨条件节点传递 Agent 输出
### 7.1 流程结构
@@ -384,7 +416,7 @@ HITL 等待信息会记录:
---
## 、绑定角色并运行
## 、绑定角色并运行
### 8.1 在角色管理中绑定
@@ -414,7 +446,7 @@ workflow_policy: auto
---
## 、调试、试运行与复盘
## 、调试、试运行与复盘
### 9.1 安全试运行(dry-run
@@ -487,7 +519,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、保存前校验规则
## 十、保存前校验规则
保存时系统会自动检查:
@@ -510,7 +542,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、排错指南
## 十、排错指南
| 现象 | 可能原因 | 处理建议 |
|------|----------|----------|
@@ -526,7 +558,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、最佳实践
## 十、最佳实践
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result``parsed_targets`,避免都叫 `agent_result`
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
@@ -539,7 +571,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
---
## 十、相关代码位置(开发者参考)
## 十、相关代码位置(开发者参考)
| 模块 | 路径 |
|------|------|
Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

+3
View File
@@ -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)
+12 -12
View File
@@ -291,16 +291,13 @@ func (l *HTTPBeaconListener) handleResult(w http.ResponseWriter, r *http.Request
}
var report TaskResultReport
plaintext, decErr := DecryptAESGCM(l.rec.EncryptionKey, string(body))
if decErr == nil {
if err := json.Unmarshal(plaintext, &report); err != nil {
l.disguisedReject(w)
return
}
} else {
if err := json.Unmarshal(body, &report); err != nil {
l.disguisedReject(w)
return
}
if decErr != nil {
l.disguisedReject(w)
return
}
if err := json.Unmarshal(plaintext, &report); err != nil {
l.disguisedReject(w)
return
}
if err := l.manager.IngestTaskResult(report); err != nil {
http.Error(w, "ingest result failed", http.StatusInternalServerError)
@@ -341,12 +338,15 @@ func (l *HTTPBeaconListener) handleUpload(w http.ResponseWriter, r *http.Request
l.disguisedReject(w)
return
}
dir := filepath.Join(l.manager.StorageDir(), "uploads")
dir, dst, err := uploadPathForTask(l.manager.StorageDir(), taskID)
if err != nil {
l.disguisedReject(w)
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
http.Error(w, "mkdir failed", http.StatusInternalServerError)
return
}
dst := filepath.Join(dir, taskID+".bin")
if err := os.WriteFile(dst, plaintext, 0o644); err != nil {
http.Error(w, "save failed", http.StatusInternalServerError)
return
+85
View File
@@ -7,6 +7,7 @@ import (
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
@@ -227,3 +228,87 @@ func TestHTTPBeaconListener_HandleFileServe(t *testing.T) {
})
}
}
func TestHTTPBeaconListener_HandleUploadConfinesTaskID(t *testing.T) {
tmp := t.TempDir()
store := filepath.Join(tmp, "c2store")
keyB64, err := GenerateAESKey()
if err != nil {
t.Fatal(err)
}
token := "test-implant-token-upload"
l := &HTTPBeaconListener{
rec: &database.C2Listener{
EncryptionKey: keyB64,
ImplantToken: token,
},
manager: NewManager(nil, zap.NewNop(), store),
logger: zap.NewNop(),
}
encrypted, err := EncryptAESGCM(keyB64, []byte("safe upload"))
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/upload?task_id=t_safe123", strings.NewReader(encrypted))
req.Header.Set("X-Implant-Token", token)
rr := httptest.NewRecorder()
l.handleUpload(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
}
got, err := os.ReadFile(filepath.Join(store, "uploads", "t_safe123.bin"))
if err != nil {
t.Fatal(err)
}
if string(got) != "safe upload" {
t.Fatalf("content=%q", got)
}
evilBody, err := EncryptAESGCM(keyB64, []byte("owned"))
if err != nil {
t.Fatal(err)
}
evilReq := httptest.NewRequest(http.MethodPost, "/upload?task_id=..%2Fowned", strings.NewReader(evilBody))
evilReq.Header.Set("X-Implant-Token", token)
evilRR := httptest.NewRecorder()
l.handleUpload(evilRR, evilReq)
if evilRR.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%q", evilRR.Code, evilRR.Body.String())
}
if _, err := os.Stat(filepath.Join(store, "owned.bin")); !os.IsNotExist(err) {
t.Fatalf("outside file exists or stat failed unexpectedly: %v", err)
}
}
func TestHTTPBeaconListener_HandleResultRejectsPlaintextJSON(t *testing.T) {
keyB64, err := GenerateAESKey()
if err != nil {
t.Fatal(err)
}
l := &HTTPBeaconListener{
rec: &database.C2Listener{
EncryptionKey: keyB64,
ImplantToken: "test-implant-token-result",
},
logger: zap.NewNop(),
}
req := httptest.NewRequest(http.MethodPost, "/result", strings.NewReader(`{"task_id":"t_test","success":true}`))
req.Header.Set("X-Implant-Token", "test-implant-token-result")
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
l.handleResult(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status=%d body=%q", rr.Code, rr.Body.String())
}
if !strings.Contains(rr.Body.String(), "404 Not Found") {
t.Fatalf("expected disguised 404 body, got %q", rr.Body.String())
}
}
+62 -5
View File
@@ -42,6 +42,11 @@ type Manager struct {
// MCPToolC2Task 与 MCP builtin、c2_task 工具名一致,供 HITL 白名单与 Agent 侧对齐。
const MCPToolC2Task = "c2_task"
var (
resultBlobSuffixPattern = regexp.MustCompile(`^\.[A-Za-z0-9][A-Za-z0-9_-]{0,31}$`)
uploadTaskIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
)
// HITLBridge 把"危险任务"桥到现有 internal/handler/hitl 审批流的接口。
// internal/app 实例化时传入;空实现表示禁用 HITL 拦截(开发期方便)。
type HITLBridge interface {
@@ -736,18 +741,24 @@ func (m *Manager) IngestTaskResult(report TaskResultReport) error {
}
func (m *Manager) saveResultBlob(taskID, b64Content, suffix string) (string, error) {
suffix = strings.TrimSpace(suffix)
if suffix == "" {
suffix = ".bin"
taskID = strings.TrimSpace(taskID)
if taskID == "" || taskID == "." || taskID == ".." ||
strings.ContainsAny(taskID, `/\`) {
return "", fmt.Errorf("invalid task_id")
}
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
suffix, err := normalizeResultBlobSuffix(suffix)
if err != nil {
return "", err
}
dir := filepath.Join(m.storageDir, "results")
if err := osMkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, taskID+suffix)
if err := ensurePathInDir(dir, path); err != nil {
return "", err
}
data, err := base64Decode(b64Content)
if err != nil {
return "", err
@@ -758,6 +769,52 @@ func (m *Manager) saveResultBlob(taskID, b64Content, suffix string) (string, err
return path, nil
}
func uploadPathForTask(storageDir, taskID string) (dir, path string, err error) {
taskID = strings.TrimSpace(taskID)
if !uploadTaskIDPattern.MatchString(taskID) {
return "", "", fmt.Errorf("invalid task_id")
}
dir = filepath.Join(storageDir, "uploads")
path = filepath.Join(dir, taskID+".bin")
if err := ensurePathInDir(dir, path); err != nil {
return "", "", err
}
return dir, path, nil
}
func normalizeResultBlobSuffix(suffix string) (string, error) {
suffix = strings.TrimSpace(suffix)
if suffix == "" {
return ".bin", nil
}
if !strings.HasPrefix(suffix, ".") {
suffix = "." + suffix
}
if !resultBlobSuffixPattern.MatchString(suffix) {
return "", fmt.Errorf("invalid blob suffix")
}
return suffix, nil
}
func ensurePathInDir(dir, path string) error {
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
absPath, err := filepath.Abs(path)
if err != nil {
return err
}
rel, err := filepath.Rel(absDir, absPath)
if err != nil {
return err
}
if rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
return fmt.Errorf("path escapes result directory")
}
return nil
}
// ----------------------------------------------------------------------------
// 事件总线辅助
// ----------------------------------------------------------------------------
+75
View File
@@ -0,0 +1,75 @@
package c2
import (
"encoding/base64"
"os"
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestManagerSaveResultBlobConfinesPath(t *testing.T) {
tmp := t.TempDir()
mgr := NewManager(nil, zap.NewNop(), filepath.Join(tmp, "c2store"))
content := base64.StdEncoding.EncodeToString([]byte("result bytes"))
got, err := mgr.saveResultBlob("t_safe123", content, "txt")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(tmp, "c2store", "results", "t_safe123.txt")
if got != want {
t.Fatalf("path=%q want %q", got, want)
}
raw, err := os.ReadFile(want)
if err != nil {
t.Fatal(err)
}
if string(raw) != "result bytes" {
t.Fatalf("content=%q", raw)
}
outside := filepath.Join(tmp, "owned")
if _, err := mgr.saveResultBlob("t_safe123", content, "./../../owned"); err == nil {
t.Fatal("expected traversal suffix to be rejected")
}
if _, err := os.Stat(outside); !os.IsNotExist(err) {
t.Fatalf("outside file exists or stat failed unexpectedly: %v", err)
}
}
func TestUploadPathForTaskConfinesPath(t *testing.T) {
tmp := t.TempDir()
store := filepath.Join(tmp, "c2store")
dir, got, err := uploadPathForTask(store, "t_safe123")
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(store, "uploads"); dir != want {
t.Fatalf("dir=%q want %q", dir, want)
}
if want := filepath.Join(store, "uploads", "t_safe123.bin"); got != want {
t.Fatalf("path=%q want %q", got, want)
}
for _, taskID := range []string{"", ".", "..", "../owned", `..\owned`, "sub/owned", "sub\\owned", "task.with.dot", "-leading"} {
if _, _, err := uploadPathForTask(store, taskID); err == nil {
t.Fatalf("task_id %q unexpectedly accepted", taskID)
}
}
}
func TestNormalizeResultBlobSuffix(t *testing.T) {
for _, suffix := range []string{"", "png", ".jpg", ".7z", ".safe_name-1"} {
if _, err := normalizeResultBlobSuffix(suffix); err != nil {
t.Fatalf("suffix %q rejected: %v", suffix, err)
}
}
for _, suffix := range []string{".", "..", "../x", "./../../x", "/tmp/x", `..\x`, ".name.with.dot", ".toolong012345678901234567890123456789"} {
if _, err := normalizeResultBlobSuffix(suffix); err == nil {
t.Fatalf("suffix %q unexpectedly accepted", suffix)
}
}
}
+4 -2
View File
@@ -209,11 +209,13 @@ func (l *TCPReverseListener) handleTCPBeaconSession(conn net.Conn, br *bufio.Rea
if err != nil {
return
}
dir := filepath.Join(l.manager.StorageDir(), "uploads")
dir, dst, err := uploadPathForTask(l.manager.StorageDir(), up.TaskID)
if err != nil {
return
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
dst := filepath.Join(dir, up.TaskID+".bin")
if err := os.WriteFile(dst, plainFile, 0o644); err != nil {
return
}
+40 -13
View File
@@ -1120,29 +1120,56 @@ const hitlAuditAgentPromptBase = `你是 CyberStrikeAI 人机协同审计 Agent
你会收到 JSON包含 hitlModetoolNamearguments/argumentsObjuserMessagethinkingreasoningChainplanning 等字段
规则优先级
- 必须 reject 的规则
- 必须 approve 的规则
- 默认放行规则
规则匹配要求
- 如果同时命中 reject approve必须 reject
- 每次裁决必须给出一个规则编号不得输出命中规则
- 未命中明确 reject approve 规则但未发现明确破坏行为时命中 D1默认放行
- 不得仅凭本地执行设置超时文件名包含 test判断脚本无害必须检查实际 command代码或请求参数
- 如果脚本具体内容不可见应表述为当前参数未显示明确高危操作不能断言无持久化影响
裁决基调默认放行
- 常规低风险的渗透测试操作 approve如信息收集端口/服务扫描目录枚举只读查询无害探测命令
- 与用户授权当前任务目标一致且未见明确高危迹象 approve
- 仅在可能对系统造成实质影响 reject
- 绝大多数渗透测试操作包括各类攻击 payload都应放行
- 在裁决之前需要判断出1此请求的是干什么理解请求内容2此请求造成什么后果
- 仅在明确会直接破坏目标业务系统正常运行或造成真实用户数据丢失/不可逆篡改 reject
- 信息不足参数略宽泛仅为探测意图时 approve
- 拿不准时 approve宁可放过可恢复的测试不要误拦合法攻击
必须 reject 的高危情形示例非穷举
- 删库清表批量删除数据格式化磁盘不可逆破坏
- 修改/重置密码创建或篡改管理员账号持久化后门开机自启
- 向生产环境写入恶意载荷勒索加密停止关键服务修改系统核心配置
- 明显越权与任务/授权目标无关的破坏性操作
- 修改/重置任意用户或管理员密码强制下线真实用户
- 修改服务器或服务配置系统配置Web/数据库参数防火墙启动项计划任务
- 修改/创建/删除用户角色权限账户体系篡改
- 删除或大规模破坏数据/文件DROP/TRUNCATE/DELETE 全表rm 重要文件清空数据库
- 停止禁用重启业务服务systemctl stopkill 关键进程shutdownreboot
- 大规模压测或高并发造成 DoS
不应单独作为 reject 理由的情形
- 常规 nmap/curl/grep/读文件/枚举类命令本身
- 参数略显宽泛但无明确破坏意图审查编辑模式可收窄参数后 approve
- 仅因信息不足若无上述高危迹象 approve 并可在 comment 中提示注意点`
以下情形必须 approve即使看起来攻击性强或包含写操作
- 各类漏洞探测与利用 payloadSQL 注入含读写型命令注入反序列化文件包含路径遍历SSRFSSTIXXE
- 文件上传测试包括上传 webshell写入 webshell 文件
- 命令执行探测反弹 Shelldnslog / 数据外带探测
- 数据库命令执行写文件写计划任务内存马注入等 getshell 利用
- 常规速率的口令爆破 DoS
- 信息收集端口/目录扫描枚举抓取
- 读取查询探测类操作含敏感文件
- 攻陷后无害侦察命令whoami/id/uname/ls/cat
- 参数宽泛但无明确破坏意图的请求
判定原则
- 本身不是拦截理由渗透测试大量依赖写操作注入上传爆破getshell
- 关键看是否破坏业务可用性 / 关键数据完整性
- 只有明确命中上方 reject 清单才 reject`
const hitlAuditAgentPromptApprovalOutput = `
仅输出一行 JSON不要 markdown 代码块
{"decision":"approve"|"reject","comment":"简要理由"}`
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:..."}`
const hitlAuditAgentPromptReviewEditOutput = `
仅输出一行 JSON不要 markdown 代码块
{"decision":"approve"|"reject","comment":"简要理由","editedArguments":{...}}
{"decision":"approve"|"reject","comment":"实际操作:...;成功后的后果:...;命中规则:...","editedArguments":{...}}
editedArguments 规则 approve 且需要改参时填写否则省略该字段
- 提供完整替换后的工具参数对象键名与 argumentsObj 一致
+31
View File
@@ -0,0 +1,31 @@
package config
import (
"strings"
"testing"
)
func TestDefaultHitlAuditAgentPromptIncludesPrioritizedRules(t *testing.T) {
prompt := DefaultHitlAuditAgentPrompt()
for _, want := range []string{
"如果同时命中 reject 和 approve,必须 reject",
"修改/重置任意用户或管理员密码",
"修改/创建/删除用户、角色、权限",
"停止、禁用、重启业务服务",
"命中规则:...",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("default approval prompt missing %q", want)
}
}
}
func TestDefaultHitlAuditAgentPromptReviewEditKeepsEditedArguments(t *testing.T) {
prompt := DefaultHitlAuditAgentPromptReviewEdit()
if !strings.Contains(prompt, `"editedArguments":{...}`) {
t.Fatal("review-edit prompt must preserve editedArguments output")
}
if !strings.Contains(prompt, "命中规则:...") {
t.Fatal("review-edit prompt must require a matched rule")
}
}
+150 -8
View File
@@ -13,6 +13,7 @@ import (
"unicode/utf8"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/net/idna"
)
@@ -50,6 +51,7 @@ type Asset struct {
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
VulnerabilityCount int `json:"vulnerability_count"`
RiskLevel string `json:"risk_level"`
RiskScore int `json:"-"`
OwnerUserID string `json:"-"`
}
@@ -443,15 +445,15 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
args = append(args, *filter.Port)
}
if filter.RiskLevel != "" {
query += " AND " + assetRiskLevelExpr + " = ?"
query += " AND " + assetRiskLevelCachedExpr + " = ?"
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
}
if filter.MinVulnerabilities != nil {
query += " AND " + assetVulnerabilityCountExpr + " >= ?"
query += " AND " + assetVulnerabilityCountCachedExpr + " >= ?"
args = append(args, *filter.MinVulnerabilities)
}
if filter.MaxVulnerabilities != nil {
query += " AND " + assetVulnerabilityCountExpr + " <= ?"
query += " AND " + assetVulnerabilityCountCachedExpr + " <= ?"
args = append(args, *filter.MaxVulnerabilities)
}
for _, item := range []struct {
@@ -573,20 +575,24 @@ const assetVulnerabilityMatchExpr = `(
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + assetVulnerabilityMatchExpr + `)`
const assetRiskScoreExpr = `COALESCE((
const assetRiskScoreQueryExpr = `COALESCE((
SELECT MAX(CASE LOWER(COALESCE(v.severity,'')) WHEN 'critical' THEN 5 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'low' THEN 2 WHEN 'info' THEN 1 ELSE 0 END)
FROM vulnerabilities v
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
),0)`
const assetRiskLevelExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreExpr + `
const assetRiskLevelQueryExpr = `(CASE WHEN ` + assetEffectiveLastScanExpr + ` IS NULL THEN 'unassessed' ELSE CASE ` + assetRiskScoreQueryExpr + `
WHEN 5 THEN 'critical' WHEN 4 THEN 'high' WHEN 3 THEN 'medium' WHEN 2 THEN 'low' WHEN 1 THEN 'info' ELSE 'normal' END END)`
const assetVulnerabilityCountCachedExpr = `COALESCE(assets.vulnerability_count,0)`
const assetRiskScoreCachedExpr = `COALESCE(assets.risk_score,0)`
const assetRiskLevelCachedExpr = `COALESCE(NULLIF(assets.risk_level,''),'unassessed')`
const assetSelectColumns = `assets.id,COALESCE(assets.project_id,''),COALESCE(p.name,''),assets.host,assets.ip,assets.port,assets.domain,assets.protocol,assets.title,assets.server,assets.country,
assets.province,assets.city,assets.responsible_person,assets.department,assets.business_system,assets.environment,assets.criticality,
assets.source,assets.source_query,assets.status,assets.tags_json,assets.first_seen_at,assets.last_seen_at,assets.created_at,assets.updated_at,
` + assetEffectiveLastScanExpr + `,COALESCE(assets.last_scan_conversation_id,''),COALESCE(assets.last_scan_queue_id,''),COALESCE(assets.last_scan_task_id,''),
` + assetVulnerabilityCountExpr + `,` + assetRiskLevelExpr
` + assetVulnerabilityCountCachedExpr + `,` + assetRiskLevelCachedExpr
// MarkAssetScanned links an asset to the conversation or batch subtask created from it.
// The link lets the asset list show the latest scan time and vulnerabilities produced by that scan.
@@ -601,6 +607,9 @@ func (db *DB) MarkAssetScanned(id, conversationID, queueID, taskID string, acces
if n == 0 {
return sql.ErrNoRows
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
return nil
}
@@ -629,6 +638,9 @@ func (db *DB) CompleteAssetScan(id, conversationID string, access RBACListAccess
if n == 0 {
return sql.ErrNoRows
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
return nil
}
@@ -638,6 +650,136 @@ func (db *DB) BatchTaskBelongsToQueue(taskID, queueID string) bool {
return err == nil && count > 0
}
func assetRiskLevelFromScore(score int, scanned bool) string {
if !scanned {
return "unassessed"
}
switch score {
case 5:
return "critical"
case 4:
return "high"
case 3:
return "medium"
case 2:
return "low"
case 1:
return "info"
default:
return "normal"
}
}
// RefreshAssetRiskCache recalculates the denormalized fields used by the asset
// list. Keeping this in the database layer makes Web API and MCP writes share
// one consistency path.
func (db *DB) RefreshAssetRiskCache(assetID string) error {
assetID = strings.TrimSpace(assetID)
if assetID == "" {
return nil
}
var count int
if err := db.QueryRow("SELECT "+assetVulnerabilityCountExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&count); err != nil {
if err == sql.ErrNoRows {
return nil
}
return fmt.Errorf("刷新资产漏洞数量失败: %w", err)
}
var score int
if err := db.QueryRow("SELECT "+assetRiskScoreQueryExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&score); err != nil {
return fmt.Errorf("刷新资产风险分数失败: %w", err)
}
var lastScan interface{}
if err := db.QueryRow("SELECT "+assetEffectiveLastScanExpr+" FROM assets WHERE assets.id=?", assetID).Scan(&lastScan); err != nil {
return fmt.Errorf("刷新资产扫描状态失败: %w", err)
}
level := assetRiskLevelFromScore(score, lastScan != nil)
if _, err := db.Exec(`UPDATE assets SET vulnerability_count=?, risk_score=?, risk_level=? WHERE id=?`, count, score, level, assetID); err != nil {
return fmt.Errorf("更新资产风险缓存失败: %w", err)
}
return nil
}
func (db *DB) RefreshAllAssetRiskCache() error {
rows, err := db.Query(`SELECT id FROM assets`)
if err != nil {
return fmt.Errorf("查询资产列表失败: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
}
return rows.Err()
}
func (db *DB) AssetIDsForVulnerabilityConversations(conversationIDs []string) ([]string, error) {
seen := map[string]struct{}{}
cleaned := make([]string, 0, len(conversationIDs))
for _, id := range conversationIDs {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
cleaned = append(cleaned, id)
}
if len(cleaned) == 0 {
return nil, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(cleaned)), ",")
args := make([]interface{}, 0, len(cleaned)*2)
for _, id := range cleaned {
args = append(args, id)
}
for _, id := range cleaned {
args = append(args, id)
}
rows, err := db.Query(`SELECT DISTINCT assets.id FROM assets
WHERE assets.last_scan_conversation_id IN (`+placeholders+`)
OR assets.last_scan_task_id IN (SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id IN (`+placeholders+`))`, args...)
if err != nil {
return nil, fmt.Errorf("查询受影响资产失败: %w", err)
}
defer rows.Close()
assetIDs := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
assetIDs = append(assetIDs, id)
}
return assetIDs, rows.Err()
}
func (db *DB) RefreshAssetRiskCacheForConversations(conversationIDs ...string) error {
assetIDs, err := db.AssetIDsForVulnerabilityConversations(conversationIDs)
if err != nil {
return err
}
for _, id := range assetIDs {
if err := db.RefreshAssetRiskCache(id); err != nil {
return err
}
}
return nil
}
func (db *DB) refreshAssetRiskCacheForConversationsBestEffort(conversationIDs ...string) {
if err := db.RefreshAssetRiskCacheForConversations(conversationIDs...); err != nil && db.logger != nil {
db.logger.Warn("刷新资产风险缓存失败", zap.Error(err))
}
}
func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
if limit < 1 {
limit = 20
@@ -726,9 +868,9 @@ func assetOrderBy(sortBy, sortOrder string) string {
case "port":
expression = "assets.port"
case "vulnerability_count":
expression = assetVulnerabilityCountExpr
expression = assetVulnerabilityCountCachedExpr
case "risk_level":
expression = assetRiskScoreExpr
expression = assetRiskScoreCachedExpr
default:
expression = "assets.last_seen_at"
}
+6 -1
View File
@@ -383,7 +383,12 @@ func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
t.Fatalf("unexpected scan metadata: %#v", linked)
}
if _, err := db.Exec(`UPDATE vulnerabilities SET status='fixed' WHERE conversation_id=?`, conv.ID); err != nil {
vulns, err := db.ListVulnerabilities(10, 0, VulnerabilityListFilter{ConversationID: conv.ID})
if err != nil || len(vulns) != 1 {
t.Fatalf("list linked vulnerabilities: len=%d err=%v", len(vulns), err)
}
vulns[0].Status = "fixed"
if err := db.UpdateVulnerability(vulns[0].ID, vulns[0]); err != nil {
t.Fatal(err)
}
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
+87
View File
@@ -1353,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(
@@ -1420,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"`
@@ -1441,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
}
@@ -1552,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
}
+7 -1
View File
@@ -421,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
@@ -745,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);
@@ -977,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
}
@@ -1027,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
+125
View File
@@ -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
}
+104
View File
@@ -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())
+31
View File
@@ -191,6 +191,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
if err != nil {
return nil, fmt.Errorf("创建漏洞失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(vuln.ConversationID)
return vuln, nil
}
@@ -299,6 +300,8 @@ func (db *DB) CountVulnerabilitiesForAccess(filter VulnerabilityListFilter, acce
// UpdateVulnerability 更新漏洞
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
vuln.UpdatedAt = time.Now()
var oldConversationID string
_ = db.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&oldConversationID)
query := `
UPDATE vulnerabilities
@@ -318,6 +321,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
return fmt.Errorf("更新漏洞失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(oldConversationID, vuln.ConversationID)
return nil
}
@@ -337,6 +341,10 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
args := []interface{}{}
where, args = filter.appendWhere(where, args)
where, args = appendVulnerabilityAccessFilter(where, args, access)
affectedConversations, err := collectVulnerabilityConversationIDs(tx, where, args)
if err != nil {
return 0, err
}
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
@@ -356,6 +364,7 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("提交事务失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(affectedConversations...)
return deleted, nil
}
@@ -366,6 +375,8 @@ func (db *DB) DeleteVulnerability(id string) error {
return fmt.Errorf("开启事务失败: %w", err)
}
defer func() { _ = tx.Rollback() }()
var conversationID string
_ = tx.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&conversationID)
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
@@ -377,9 +388,29 @@ func (db *DB) DeleteVulnerability(id string) error {
if err := tx.Commit(); err != nil {
return fmt.Errorf("提交事务失败: %w", err)
}
db.refreshAssetRiskCacheForConversationsBestEffort(conversationID)
return nil
}
func collectVulnerabilityConversationIDs(tx *sql.Tx, where string, args []interface{}) ([]string, error) {
rows, err := tx.Query(`SELECT DISTINCT COALESCE(conversation_id,'') FROM vulnerabilities `+where, args...)
if err != nil {
return nil, fmt.Errorf("查询受影响漏洞会话失败: %w", err)
}
defer rows.Close()
ids := []string{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
if strings.TrimSpace(id) != "" {
ids = append(ids, id)
}
}
return ids, rows.Err()
}
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
+70 -23
View File
@@ -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
@@ -976,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{}{
@@ -992,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 == "" {
@@ -1068,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事件中的参数
@@ -1329,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
}
@@ -1439,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()
@@ -1455,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 -2
View File
@@ -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))
@@ -321,6 +321,17 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
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())
+79
View File
@@ -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)
}
}
+19 -2
View File
@@ -767,7 +767,7 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
body["include"] = fields
}
var apiResp struct {
Code int `json:"code"`
Code interface{} `json:"code"`
Message string `json:"message"`
TotalCount int `json:"total_count"`
Data []map[string]interface{} `json:"data"`
@@ -780,7 +780,7 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
if !h.doJSONRequest(c, http.MethodPost, u.String(), apiKey, "X-QuakeToken", body, &apiResp, "Quake") {
return
}
if apiResp.Code != 0 {
if !isZeroSpaceSearchCode(apiResp.Code) {
msg := strings.TrimSpace(apiResp.Message)
if msg == "" {
msg = "Quake 返回错误"
@@ -801,6 +801,23 @@ func (h *FofaHandler) searchQuake(c *gin.Context, req fofaSearchRequest, apiKey
})
}
func isZeroSpaceSearchCode(code interface{}) bool {
switch v := code.(type) {
case nil:
return false
case int:
return v == 0
case int64:
return v == 0
case float64:
return v == 0
case string:
return strings.TrimSpace(v) == "0"
default:
return false
}
}
func (h *FofaHandler) searchShodan(c *gin.Context, req fofaSearchRequest, apiKey string) {
baseURL := strings.TrimRight(h.resolveBaseURL("shodan"), "/") + "/shodan/host/search"
u, err := url.Parse(baseURL)
+40
View File
@@ -155,6 +155,46 @@ func TestShodanSearchReportsShortfallWhenTotalExceedsMatches(t *testing.T) {
}
}
func TestQuakeSearchHandlesStringErrorCode(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("QUAKE_API_KEY", "")
quakeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-QuakeToken"); got != "test-quake-key" {
t.Fatalf("Quake token = %q, want test-quake-key", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"q5000","message":"查询语法错误"}`))
}))
defer quakeServer.Close()
h := NewFofaHandler(&config.Config{
Quake: config.SpaceSearchConfig{
BaseURL: quakeServer.URL,
APIKey: "test-quake-key",
},
}, zap.NewNop())
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
body := `{"provider":"quake","query":"bad query","fields":"ip,port","size":10,"page":1}`
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/fofa/search", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
h.Search(ctx)
if recorder.Code != http.StatusBadGateway {
t.Fatalf("Search() status = %d, want %d, body = %s", recorder.Code, http.StatusBadGateway, recorder.Body.String())
}
bodyText := recorder.Body.String()
if !strings.Contains(bodyText, "查询语法错误") {
t.Fatalf("response should include Quake error message, got %s", bodyText)
}
if strings.Contains(bodyText, "cannot unmarshal") {
t.Fatalf("response exposed JSON type decoding failure: %s", bodyText)
}
}
func TestExtractInfoCollectJSONObject(t *testing.T) {
t.Parallel()
cases := []struct {
+51 -6
View File
@@ -1,8 +1,11 @@
package handler
import (
"errors"
"net/http"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/security"
@@ -17,6 +20,11 @@ type GroupHandler struct {
logger *zap.Logger
}
const (
maxGroupNameRunes = 64
maxGroupIconRunes = 16
)
// NewGroupHandler 创建新的分组处理器
func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler {
return &GroupHandler{
@@ -25,6 +33,41 @@ func NewGroupHandler(db *database.DB, logger *zap.Logger) *GroupHandler {
}
}
func validateGroupTextField(field, value string, maxRunes int, required bool) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
if required {
return "", errors.New(field + "不能为空")
}
return "", nil
}
if utf8.RuneCountInString(value) > maxRunes {
return "", errors.New(field + "过长")
}
for _, r := range value {
switch r {
case '<', '>', '"', '\'', '`':
return "", errors.New(field + "包含非法字符")
}
if r < 0x20 || r == 0x7f {
return "", errors.New(field + "包含非法控制字符")
}
}
return value, nil
}
func validateGroupFields(name, icon string) (string, string, error) {
validName, err := validateGroupTextField("分组名称", name, maxGroupNameRunes, true)
if err != nil {
return "", "", err
}
validIcon, err := validateGroupTextField("分组图标", icon, maxGroupIconRunes, false)
if err != nil {
return "", "", err
}
return validName, validIcon, nil
}
// CreateGroupRequest 创建分组请求
type CreateGroupRequest struct {
Name string `json:"name"`
@@ -39,13 +82,14 @@ func (h *GroupHandler) CreateGroup(c *gin.Context) {
return
}
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称不能为空"})
name, icon, err := validateGroupFields(req.Name, req.Icon)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
session, _ := security.CurrentSession(c)
group, err := h.db.CreateGroup(req.Name, req.Icon, session.UserID)
group, err := h.db.CreateGroup(name, icon, session.UserID)
if err != nil {
h.logger.Error("创建分组失败", zap.Error(err))
// 如果是名称重复错误,返回400状态码
@@ -111,12 +155,13 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
return
}
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "分组名称不能为空"})
name, icon, err := validateGroupFields(req.Name, req.Icon)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.UpdateGroup(id, req.Name, req.Icon); err != nil {
if err := h.db.UpdateGroup(id, name, icon); err != nil {
h.logger.Error("更新分组失败", zap.Error(err))
// 如果是名称重复错误,返回400状态码
if err.Error() == "分组名称已存在" {
+41
View File
@@ -0,0 +1,41 @@
package handler
import (
"strings"
"testing"
)
func TestValidateGroupFieldsAllowsNormalNamesAndIcons(t *testing.T) {
name, icon, err := validateGroupFields(" 日常安全巡检 ", " 📁 ")
if err != nil {
t.Fatalf("validateGroupFields returned error: %v", err)
}
if name != "日常安全巡检" {
t.Fatalf("name = %q, want trimmed normal name", name)
}
if icon != "📁" {
t.Fatalf("icon = %q, want trimmed icon", icon)
}
}
func TestValidateGroupFieldsRejectsStoredXSSPayloads(t *testing.T) {
tests := []struct {
name string
icon string
}{
{name: `<img src=x onerror="alert(1)">`, icon: "📁"},
{name: "日常安全巡检", icon: `<svg onload=alert(1)>`},
{name: "日常安全巡检`onmouseover=alert(1)", icon: "📁"},
{name: "日常安全巡检\x00", icon: "📁"},
{name: strings.Repeat("分", maxGroupNameRunes+1), icon: "📁"},
{name: "日常安全巡检", icon: strings.Repeat("📁", maxGroupIconRunes+1)},
}
for _, tt := range tests {
t.Run(tt.name+"/"+tt.icon, func(t *testing.T) {
if _, _, err := validateGroupFields(tt.name, tt.icon); err == nil {
t.Fatal("validateGroupFields returned nil error for unsafe input")
}
})
}
}
+280 -57
View File
@@ -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
}
+16 -10
View File
@@ -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) {
+236
View File
@@ -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)
}
}
+21
View File
@@ -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")
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
h.agentHandler = ah
}
const monitorPageTopTools = 3
const monitorPageTopTools = 6
// MonitorStatsSummary 工具调用汇总
type MonitorStatsSummary struct {
+45
View File
@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"errors"
"net/http"
"strings"
@@ -47,6 +48,12 @@ type workflowDryRunRequest struct {
Inputs map[string]interface{} `json:"inputs,omitempty"`
}
type workflowGenerateDraftRequest struct {
Prompt string `json:"prompt"`
Options workflowrunner.DraftOptions `json:"options"`
AvailableTools []workflowrunner.DraftTool `json:"available_tools,omitempty"`
}
func (h *WorkflowHandler) List(c *gin.Context) {
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
@@ -126,6 +133,44 @@ func (h *WorkflowHandler) DryRun(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"result": result})
}
func (h *WorkflowHandler) GenerateDraft(c *gin.Context) {
var req workflowGenerateDraftRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
return
}
draftReq := workflowrunner.DraftRequest{
Prompt: req.Prompt,
Options: req.Options,
AvailableTools: req.AvailableTools,
}
var result *workflowrunner.DraftResult
var llmErr error
if h.cfg != nil {
if llmCfg, _, ok := h.cfg.ResolveAIChannel(""); ok && strings.TrimSpace(llmCfg.APIKey) != "" && strings.TrimSpace(llmCfg.Model) != "" {
result, llmErr = workflowrunner.GenerateDraftFromLLM(c.Request.Context(), draftReq, llmCfg, h.logger)
} else {
llmErr = errors.New("AI 通道未配置 api_key 或 model")
}
} else {
llmErr = errors.New("工作流生成器未加载平台 AI 配置")
}
if llmErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "大模型生成失败: " + llmErr.Error()})
return
}
if h.audit != nil {
h.audit.RecordOK(c, "workflow", "generate_draft", "自然语言生成工作流草稿", "", "", map[string]interface{}{
"generator": result.Generator,
"nodes": result.Stats["nodes"],
"edges": result.Stats["edges"],
"high_risk": result.Audit.HighRisk,
"savable": result.Audit.Savable,
})
}
c.JSON(http.StatusOK, gin.H{"result": result})
}
func (h *WorkflowHandler) Update(c *gin.Context) {
h.save(c, c.Param("id"))
}
+24
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
@@ -76,3 +77,26 @@ func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
t.Fatalf("saved=%#v", saved)
}
}
func TestWorkflowHandlerGenerateDraft(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewWorkflowHandler(nil, zap.NewNop())
body := bytes.NewBufferString(`{"prompt":"对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告","options":{"include_objective":true},"available_tools":[{"key":"nmap","name":"nmap","enabled":true}]}`)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/workflows/generate-draft", body)
c.Request.Header.Set("Content-Type", "application/json")
h.GenerateDraft(c)
if w.Code != http.StatusBadGateway {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var resp struct {
Error string `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !strings.Contains(resp.Error, "大模型生成失败") {
t.Fatalf("unexpected error: %#v", resp.Error)
}
}
+2 -3
View File
@@ -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
+2 -1
View File
@@ -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)
}
}
}
+8 -4
View File
@@ -218,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)
@@ -308,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 {
@@ -481,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 的独立 ChatModelToolChoiceForced 兼容)",
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 {
+3 -1
View File
@@ -172,6 +172,8 @@ func permissionForRequest(method, fullPath string) string {
return "workflow:read"
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
return "workflow:write"
case path == "/workflows/generate-draft":
return "workflow:write"
case strings.HasPrefix(path, "/workflows"):
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
return "workflow:execute"
@@ -265,7 +267,7 @@ func isProcessGlobalMutationPath(path string) bool {
}
if strings.HasPrefix(path, "/workflows") {
// Workflow runs inherit conversation access; definitions are global.
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run"
return !strings.HasPrefix(path, "/workflows/runs/") && path != "/workflows/validate" && path != "/workflows/dry-run" && path != "/workflows/generate-draft"
}
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
return true
@@ -157,9 +157,15 @@ func TestWorkflowRunPermissionIsSeparateFromDefinitionManagement(t *testing.T) {
if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" {
t.Fatalf("resume permission = %q, want workflow:execute", got)
}
if got := permissionForRequest(http.MethodPost, "/api/workflows/generate-draft"); got != "workflow:write" {
t.Fatalf("generate draft permission = %q, want workflow:write", got)
}
if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
t.Fatalf("definition permission = %q, want workflow:write", got)
}
if isProcessGlobalMutationPath("/workflows/generate-draft") {
t.Fatalf("generate draft should not be treated as a process-global mutation")
}
}
func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) {
+782
View File
@@ -0,0 +1,782 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"hash/fnv"
"regexp"
"strings"
"time"
"unicode/utf8"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/openai"
"go.uber.org/zap"
)
type DraftTool struct {
Key string `json:"key"`
Name string `json:"name,omitempty"`
Enabled bool `json:"enabled"`
}
type DraftOptions struct {
IncludeObjective bool `json:"include_objective"`
AllowSchedule bool `json:"allow_schedule"`
AllowHighRisk bool `json:"allow_high_risk"`
}
type DraftRequest struct {
Prompt string `json:"prompt"`
Options DraftOptions `json:"options"`
AvailableTools []DraftTool `json:"available_tools,omitempty"`
}
type DraftMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
}
type DraftCapability struct {
Label string `json:"label"`
ToolName string `json:"tool_name,omitempty"`
ToolCandidates []string `json:"tool_candidates,omitempty"`
}
type DraftAudit struct {
Savable bool `json:"savable"`
Validation []string `json:"validation,omitempty"`
MissingFields []string `json:"missing_fields,omitempty"`
RiskWarnings []string `json:"risk_warnings,omitempty"`
Assumptions []string `json:"assumptions,omitempty"`
HighRisk bool `json:"high_risk"`
NeedsHITL bool `json:"needs_hitl"`
}
type DraftResult struct {
Graph *graphDef `json:"graph"`
Meta DraftMeta `json:"meta"`
Generator string `json:"generator"`
Audit DraftAudit `json:"audit"`
Capabilities []DraftCapability `json:"capabilities,omitempty"`
Stats map[string]int `json:"stats"`
}
type llmDraftEnvelope struct {
Graph graphDef `json:"graph"`
Meta DraftMeta `json:"meta"`
Capabilities []DraftCapability `json:"capabilities,omitempty"`
Audit DraftAudit `json:"audit,omitempty"`
}
type draftToolHint struct {
Label string
Keywords []string
Tools []string
}
var draftToolHints = []draftToolHint{
{Label: "子域名发现", Keywords: []string{"子域名", "subdomain", "subfinder", "amass"}, Tools: []string{"subfinder", "amass"}},
{Label: "端口扫描", Keywords: []string{"端口", "port", "nmap", "rustscan", "masscan"}, Tools: []string{"nmap", "rustscan", "masscan"}},
{Label: "漏洞扫描", Keywords: []string{"漏洞", "vuln", "漏洞扫描", "nuclei", "nikto", "zap"}, Tools: []string{"nuclei", "nikto", "zap"}},
{Label: "暴露面探测", Keywords: []string{"目录", "路径", "暴露页面", "dir", "ffuf", "gobuster", "feroxbuster"}, Tools: []string{"ffuf", "gobuster", "feroxbuster", "dirsearch"}},
{Label: "证书与域名线索收集", Keywords: []string{"证书", "certificate", "crt"}, Tools: []string{"subfinder"}},
{Label: "云配置审计", Keywords: []string{"云", "cloud", "配置审计", "prowler", "scout"}, Tools: []string{"prowler", "scout-suite"}},
{Label: "容器安全检查", Keywords: []string{"容器", "镜像", "k8s", "kubernetes", "trivy", "kube"}, Tools: []string{"trivy", "kube-bench", "kube-hunter"}},
{Label: "威胁情报收集", Keywords: []string{"情报", "威胁情报", "threat", "ioc", "virustotal", "shodan", "fofa"}, Tools: []string{"virustotal_search", "shodan_search", "fofa_search"}},
}
var highRiskDraftRE = regexp.MustCompile(`(?i)(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)`)
func GenerateDraftFromNaturalLanguage(ctx context.Context, req DraftRequest) (*DraftResult, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, fmt.Errorf("工作流需求不能为空")
}
capabilities := detectDraftCapabilities(prompt, req.AvailableTools)
wantsApproval := containsAnyFold(prompt, "审批", "确认", "审核", "负责人", "人工", "review", "approve", "approval", "human")
wantsReport := containsAnyFold(prompt, "报告", "汇总", "输出", "通知", "任务", "工单", "report", "summary", "notify", "ticket")
wantsCondition := containsAnyFold(prompt, "如果", "发现", "存在", "高危", "新增", "失败", "通过", "否则", "if", "when", "high", "critical", "new", "fail")
highRisk := highRiskDraftRE.MatchString(prompt)
builder := &draftGraphBuilder{x: 120, y: 150}
assumptions := make([]string, 0)
riskWarnings := make([]string, 0)
missingFields := make([]string, 0)
start := builder.add("start", "开始", map[string]any{"input_keys": "message, conversationId, projectId, target"}, 0)
previous := start
for _, capability := range capabilities {
hasTool := strings.TrimSpace(capability.ToolName) != ""
var id string
if hasTool {
id = builder.add("tool", capability.Label, map[string]any{
"tool_name": capability.ToolName,
"arguments": `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`,
"timeout_seconds": "120",
"join_strategy": "all_merge",
}, 0)
} else {
id = builder.add("agent", capability.Label, map[string]any{
"agent_mode": "eino_single",
"input_binding": map[string]any{"from": "previous", "field": "output"},
"instruction": capability.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt,
"output_key": "agent_result",
"join_strategy": "all_merge",
"missing_tool_candidates": strings.Join(capability.ToolCandidates, ", "),
}, 0)
if len(capability.ToolCandidates) > 0 {
assumptions = append(assumptions, capability.Label+" 未匹配到已启用工具,已生成 Agent 草稿节点。")
missingFields = append(missingFields, capability.Label+": 选择或启用对应 MCP 工具")
}
}
builder.connect(previous, id, "", nil)
previous = id
}
openConditionID := ""
if wantsCondition {
expr := `{{previous.output}} != ""`
label := "是否满足触发条件"
if highRisk {
expr = `{{previous.output}} contains "高危"`
label = "是否需要高风险处置"
}
condition := builder.add("condition", label, map[string]any{"expression": expr, "join_strategy": "all_merge"}, 0)
builder.connect(previous, condition, "", nil)
openConditionID = condition
report := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
"output_key": "result",
"source_binding": map[string]any{"from": "previous", "field": "output"},
"static_value": "",
"join_strategy": "all_merge",
}, 130)
builder.connect(condition, report, "否", map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"})
previous = condition
}
insertedHITL := false
if highRisk {
if !req.Options.AllowHighRisk || wantsApproval {
approval := builder.add("hitl", "人工审批", map[string]any{
"prompt": "请确认是否允许继续执行高风险处置:" + prompt,
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
"reviewer": "human",
"join_strategy": "all_merge",
"risk_level": "high",
}, 0)
builder.connect(previous, approval, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
if previous == openConditionID {
openConditionID = ""
}
previous = approval
insertedHITL = true
}
action := builder.add("agent", "执行受控处置", map[string]any{
"agent_mode": "eino_single",
"input_binding": map[string]any{"from": "previous", "field": "output"},
"instruction": "仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:" + prompt,
"output_key": "remediation_plan",
"join_strategy": "all_merge",
"risk_level": "high",
"requires_human_confirmation": "true",
}, 0)
builder.connect(previous, action, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
if previous == openConditionID {
openConditionID = ""
}
previous = action
if insertedHITL {
riskWarnings = append(riskWarnings, "检测到高风险动作,已加入人工审批与 requires_human_confirmation 标记。")
} else {
riskWarnings = append(riskWarnings, "检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。")
}
} else if wantsApproval {
approval := builder.add("hitl", "人工审批", map[string]any{
"prompt": "请审核工作流阶段结果:" + prompt,
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
"reviewer": "human",
"join_strategy": "all_merge",
}, 0)
builder.connect(previous, approval, "", nil)
previous = approval
insertedHITL = true
}
output := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
"output_key": "result",
"source_binding": map[string]any{"from": "previous", "field": "output"},
"static_value": "",
"join_strategy": "all_merge",
}, 0)
builder.connect(previous, output, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
graph := &graphDef{Nodes: builder.nodes, Edges: builder.edges, Config: map[string]any{
"schema_version": 1,
"generated_by": "natural_language",
"source_prompt": prompt,
}}
if req.Options.IncludeObjective {
graph.Config["objective"] = prompt
}
if req.Options.AllowSchedule && containsAnyFold(prompt, "每天", "每周", "定时", "周期", "持续", "daily", "weekly", "schedule", "monitor") {
if containsAnyFold(prompt, "每天", "daily") {
graph.Config["trigger_suggestion"] = "daily"
} else {
graph.Config["trigger_suggestion"] = "scheduled"
}
assumptions = append(assumptions, "已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。")
}
raw, _ := json.Marshal(graph)
validation := make([]string, 0)
if err := ValidateGraphJSON(ctx, string(raw)); err != nil {
validation = append(validation, err.Error())
}
return &DraftResult{
Graph: graph,
Meta: DraftMeta{ID: draftSlug(prompt), Name: draftName(prompt), Description: prompt, Enabled: true},
Generator: "deterministic",
Audit: DraftAudit{
Savable: len(validation) == 0,
Validation: validation,
MissingFields: missingFields,
RiskWarnings: riskWarnings,
Assumptions: assumptions,
HighRisk: highRisk,
NeedsHITL: insertedHITL,
},
Capabilities: capabilities,
Stats: map[string]int{"nodes": len(graph.Nodes), "edges": len(graph.Edges)},
}, nil
}
func GenerateDraftFromLLM(ctx context.Context, req DraftRequest, oa config.OpenAIConfig, logger *zap.Logger) (*DraftResult, error) {
prompt := strings.TrimSpace(req.Prompt)
if prompt == "" {
return nil, fmt.Errorf("工作流需求不能为空")
}
if strings.TrimSpace(oa.APIKey) == "" || strings.TrimSpace(oa.Model) == "" {
return nil, fmt.Errorf("AI 通道未配置 api_key 或 model")
}
if logger == nil {
logger = zap.NewNop()
}
callCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
defer cancel()
toolJSON, _ := json.Marshal(req.AvailableTools)
systemPrompt := `你是 CyberStrikeAI 的工作流编排助手你必须把用户的一句话需求转换为可保存的工作流草稿 JSON
只返回 JSON 对象不要 Markdown不要解释JSON 必须符合
{
"meta": {"id":"kebab-case-id","name":"短名称","description":"用户需求","enabled":true},
"graph": {
"nodes": [{"id":"start-1","type":"start","label":"显示名","position":{"x":120,"y":150},"config":{}}],
"edges": [{"id":"edge-1","source":"start-1","target":"node-2","label":"","config":{}}],
"config": {"schema_version":1,"generated_by":"llm","source_prompt":"用户原文"}
},
"capabilities": [{"label":"能力名","tool_name":"已匹配工具名","tool_candidates":["候选工具"]}],
"audit": {"assumptions":[],"missing_fields":[],"risk_warnings":[]}
}
硬性规则
- 只能输出一个合法 JSON object不要输出 JSON Schema注释解释文字Markdown 代码块或多余前后缀
- 不要在 JSON 字符串值中使用竖线枚举写法type 字段一次只能填写一个节点类型字符串
- 至少 1 start 1 outputoutput/end 不能有出边
- 节点 type 只能从这些字符串中选择starttoolagentconditionhitloutputend
- 每个 agenttooloutput 节点都必须配置唯一的 output_keyoutput 节点默认使用 result
- agent 节点必须配置 instruction input_binding默认 input_binding {"from":"previous","field":"output"}
- output 节点必须配置 source_binding static_value默认 source_binding {"from":"previous","field":"output"}
- tool 节点必须配置 tool_nameargumentstimeout_secondsarguments 必须是合法 JSON 字符串
- 所有非 start 且可能有多个上游的节点必须配置 join_strategy:"all_merge"
- condition 最多 2 条出边必须用 branch true/false并用 label /
- tool 节点只有在 available_tools 中存在启用工具时才使用否则用 agent 节点并在 audit.missing_fields 写明缺失工具
- 高风险动作执行脚本隔离封禁删除利用payload命令执行等必须加入 hitl 审批或在高风险节点 config 中标记 requires_human_confirmation:"true"risk_level:"high"
- 不要生成会真实执行攻击的参数工具参数使用 {{inputs.target}}{{inputs.message}} 占位
- 所有节点 config generated_by:"llm" needs_review:"true"`
userPrompt := fmt.Sprintf("用户需求:%s\n\n选项:%+v\n\n可用工具 JSON%s", prompt, req.Options, string(toolJSON))
requestBody := map[string]interface{}{
"model": strings.TrimSpace(oa.Model),
"messages": []map[string]interface{}{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userPrompt},
},
"temperature": 0,
"max_completion_tokens": 4096,
"response_format": map[string]interface{}{"type": "json_object"},
"thinking": map[string]interface{}{"type": "disabled"},
}
var apiResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
} `json:"message"`
} `json:"choices"`
}
client := openai.NewClient(&oa, nil, logger)
if err := client.ChatCompletion(callCtx, requestBody, &apiResponse); err != nil {
return nil, fmt.Errorf("调用大模型失败: %w", err)
}
if len(apiResponse.Choices) == 0 {
return nil, fmt.Errorf("大模型未返回候选结果")
}
raw := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
if raw == "" {
raw = strings.TrimSpace(apiResponse.Choices[0].Message.ReasoningContent)
}
env, err := parseLLMDraftEnvelope(raw)
if err != nil {
return nil, err
}
result := normalizeLLMDraft(prompt, req, env)
graphRaw, _ := json.Marshal(result.Graph)
validation := make([]string, 0)
if err := ValidateGraphJSON(ctx, string(graphRaw)); err != nil {
validation = append(validation, err.Error())
}
result.Audit.Validation = validation
result.Audit.Savable = len(validation) == 0
if !result.Audit.Savable {
return nil, fmt.Errorf("大模型生成的工作流未通过校验: %s", strings.Join(validation, ""))
}
return result, nil
}
type draftGraphBuilder struct {
nodes []graphNode
edges []graphEdge
x float64
y float64
nodeSeq int
edgeSeq int
}
func (b *draftGraphBuilder) add(nodeType, label string, config map[string]any, yOffset float64) string {
b.nodeSeq++
id := fmt.Sprintf("%s-%d", nodeType, b.nodeSeq)
if config == nil {
config = make(map[string]any)
}
config["generated_by"] = "natural_language"
config["needs_review"] = "true"
b.nodes = append(b.nodes, graphNode{
ID: id,
Type: nodeType,
Label: label,
Position: graphPosition{X: b.x, Y: b.y + yOffset},
Config: config,
})
b.x += 210
return id
}
func (b *draftGraphBuilder) connect(source, target, label string, config map[string]any) {
b.edgeSeq++
if config == nil {
config = make(map[string]any)
}
b.edges = append(b.edges, graphEdge{ID: fmt.Sprintf("edge-ai-%d", b.edgeSeq), Source: source, Target: target, Label: label, Config: config})
}
func parseLLMDraftEnvelope(raw string) (llmDraftEnvelope, error) {
var lastErr error
for _, candidate := range jsonObjectCandidates(raw) {
var env llmDraftEnvelope
if err := json.Unmarshal([]byte(candidate), &env); err == nil {
if len(env.Graph.Nodes) == 0 {
lastErr = fmt.Errorf("大模型 JSON 缺少 graph.nodes")
continue
}
return env, nil
} else {
lastErr = err
}
}
if lastErr == nil {
lastErr = fmt.Errorf("大模型响应为空")
}
return llmDraftEnvelope{}, fmt.Errorf("解析大模型工作流 JSON 失败: %w", lastErr)
}
func jsonObjectCandidates(raw string) []string {
s := strings.TrimSpace(raw)
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
candidates := []string{s}
if start := strings.Index(s, "{"); start >= 0 {
if end := strings.LastIndex(s, "}"); end > start {
candidates = append(candidates, s[start:end+1])
}
}
return candidates
}
func normalizeLLMDraft(prompt string, req DraftRequest, env llmDraftEnvelope) *DraftResult {
g := env.Graph
if g.Config == nil {
g.Config = make(map[string]any)
}
g.Config["schema_version"] = 1
g.Config["generated_by"] = "llm"
g.Config["source_prompt"] = prompt
if req.Options.IncludeObjective {
g.Config["objective"] = prompt
}
enabledTools := enabledDraftToolNames(req.AvailableTools)
usedOutputKeys := make(map[string]bool)
nodeTypes := make(map[string]string, len(g.Nodes))
for i := range g.Nodes {
if strings.TrimSpace(g.Nodes[i].ID) == "" {
g.Nodes[i].ID = fmt.Sprintf("%s-%d", firstNonEmpty(g.Nodes[i].Type, "node"), i+1)
}
if strings.TrimSpace(g.Nodes[i].Type) == "" {
g.Nodes[i].Type = "agent"
}
if strings.TrimSpace(g.Nodes[i].Label) == "" {
g.Nodes[i].Label = displayNodeType(g.Nodes[i].Type)
}
if g.Nodes[i].Position.X == 0 && g.Nodes[i].Position.Y == 0 {
g.Nodes[i].Position = graphPosition{X: 120 + float64(i)*210, Y: 150}
}
if g.Nodes[i].Config == nil {
g.Nodes[i].Config = make(map[string]any)
}
g.Nodes[i].Config["generated_by"] = "llm"
g.Nodes[i].Config["needs_review"] = "true"
normalizeLLMNodeConfig(prompt, &g.Nodes[i], enabledTools, usedOutputKeys)
nodeTypes[g.Nodes[i].ID] = strings.ToLower(strings.TrimSpace(g.Nodes[i].Type))
}
conditionBranchCounts := make(map[string]int)
for i := range g.Edges {
if strings.TrimSpace(g.Edges[i].ID) == "" {
g.Edges[i].ID = fmt.Sprintf("edge-llm-%d", i+1)
}
if g.Edges[i].Config == nil {
g.Edges[i].Config = make(map[string]any)
}
normalizeLLMEdgeConfig(&g.Edges[i], nodeTypes, conditionBranchCounts)
}
audit := env.Audit
highRisk := highRiskDraftRE.MatchString(prompt) || graphHasHighRisk(g)
audit.HighRisk = highRisk
audit.NeedsHITL = graphHasNodeType(g, "hitl")
if highRisk && !audit.NeedsHITL && !graphHasConfirmation(g) {
audit.RiskWarnings = append(audit.RiskWarnings, "大模型生成包含高风险语义,请补充人工审批或确认标记后再运行。")
}
if len(audit.RiskWarnings) == 0 && highRisk {
audit.RiskWarnings = append(audit.RiskWarnings, "检测到高风险动作,已标记为需要重点审计。")
}
meta := env.Meta
if strings.TrimSpace(meta.Description) == "" {
meta.Description = prompt
}
if strings.TrimSpace(meta.Name) == "" {
meta.Name = draftName(prompt)
}
if strings.TrimSpace(meta.ID) == "" {
meta.ID = draftSlug(prompt)
}
meta.Enabled = true
return &DraftResult{
Graph: &g,
Meta: meta,
Generator: "llm",
Audit: audit,
Capabilities: env.Capabilities,
Stats: map[string]int{"nodes": len(g.Nodes), "edges": len(g.Edges)},
}
}
func normalizeLLMEdgeConfig(edge *graphEdge, nodeTypes map[string]string, conditionBranchCounts map[string]int) {
if nodeTypes[strings.TrimSpace(edge.Source)] != "condition" {
return
}
if conditionBranchHint(*edge) != "" {
return
}
conditionBranchCounts[edge.Source]++
branch := "true"
label := "是"
if conditionBranchCounts[edge.Source] > 1 {
branch = "false"
label = "否"
}
edge.Label = label
edge.Config["branch"] = branch
}
func normalizeLLMNodeConfig(prompt string, node *graphNode, enabledTools map[string]bool, usedOutputKeys map[string]bool) {
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
switch nodeType {
case "start":
if cfgString(node.Config, "input_keys") == "" {
node.Config["input_keys"] = "message, conversationId, projectId, target"
}
case "tool":
toolName := cfgString(node.Config, "tool_name")
if toolName == "" || !enabledTools[strings.ToLower(toolName)] {
node.Type = "agent"
node.Config["missing_tool_name"] = toolName
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
return
}
if cfgString(node.Config, "arguments") == "" {
node.Config["arguments"] = `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`
}
if cfgString(node.Config, "timeout_seconds") == "" {
node.Config["timeout_seconds"] = "120"
}
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "tool_result"))
ensureJoinStrategy(node)
case "agent":
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
case "condition":
if cfgString(node.Config, "expression") == "" {
node.Config["expression"] = `{{previous.output}} != ""`
}
ensureJoinStrategy(node)
case "hitl":
if cfgString(node.Config, "prompt") == "" {
node.Config["prompt"] = "请审核工作流阶段结果:" + prompt
}
if cfgString(node.Config, "reviewer") == "" {
node.Config["reviewer"] = "human"
}
ensureJoinStrategy(node)
case "output":
ensureNodeOutputKey(node, usedOutputKeys, "result")
if cfgString(node.Config, "static_value") == "" {
if _, ok := parseFieldBinding(node.Config, "source_binding"); !ok {
node.Config["source_binding"] = map[string]any{"from": "previous", "field": "output"}
}
}
ensureJoinStrategy(node)
case "end":
ensureJoinStrategy(node)
}
}
func normalizeAgentDraftConfig(prompt string, node *graphNode, usedOutputKeys map[string]bool) {
if cfgString(node.Config, "agent_mode") == "" {
node.Config["agent_mode"] = "eino_single"
}
if cfgString(node.Config, "instruction") == "" {
node.Config["instruction"] = node.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt
}
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
node.Config["input_binding"] = map[string]any{"from": "previous", "field": "output"}
}
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "agent_result"))
ensureJoinStrategy(node)
}
func ensureJoinStrategy(node *graphNode) {
if cfgString(node.Config, "join_strategy") == "" {
node.Config["join_strategy"] = "all_merge"
}
}
func ensureNodeOutputKey(node *graphNode, used map[string]bool, fallback string) {
current := sanitizeOutputKey(cfgString(node.Config, "output_key"))
if current == "" {
current = sanitizeOutputKey(fallback)
}
if current == "" {
current = "result"
}
base := current
for i := 2; used[current]; i++ {
current = fmt.Sprintf("%s_%d", base, i)
}
node.Config["output_key"] = current
used[current] = true
}
func draftOutputKeyBase(node *graphNode, fallback string) string {
if name := cfgString(node.Config, "tool_name"); name != "" {
return name + "_result"
}
if node.ID != "" {
return node.ID + "_result"
}
return fallback
}
func sanitizeOutputKey(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
var b strings.Builder
lastUnderscore := false
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastUnderscore = false
continue
}
if b.Len() > 0 && !lastUnderscore {
b.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(b.String(), "_")
}
func enabledDraftToolNames(tools []DraftTool) map[string]bool {
names := make(map[string]bool, len(tools)*2)
for _, tool := range tools {
if !tool.Enabled {
continue
}
if key := strings.ToLower(strings.TrimSpace(tool.Key)); key != "" {
names[key] = true
}
if name := strings.ToLower(strings.TrimSpace(tool.Name)); name != "" {
names[name] = true
}
}
return names
}
func graphHasNodeType(g graphDef, nodeType string) bool {
for _, node := range g.Nodes {
if strings.EqualFold(node.Type, nodeType) {
return true
}
}
return false
}
func graphHasConfirmation(g graphDef) bool {
for _, node := range g.Nodes {
if cfgString(node.Config, "requires_human_confirmation") == "true" {
return true
}
}
return false
}
func graphHasHighRisk(g graphDef) bool {
for _, node := range g.Nodes {
if cfgString(node.Config, "risk_level") == "high" || cfgString(node.Config, "requires_human_confirmation") == "true" {
return true
}
if highRiskDraftRE.MatchString(node.Label) || highRiskDraftRE.MatchString(cfgString(node.Config, "instruction")) {
return true
}
}
return false
}
func detectDraftCapabilities(prompt string, tools []DraftTool) []DraftCapability {
capabilities := make([]DraftCapability, 0)
for _, hint := range draftToolHints {
if containsAnyFold(prompt, hint.Keywords...) {
capabilities = append(capabilities, DraftCapability{
Label: hint.Label,
ToolName: matchDraftTool(hint.Tools, tools),
ToolCandidates: append([]string(nil), hint.Tools...),
})
}
}
if len(capabilities) == 0 {
capabilities = append(capabilities, DraftCapability{Label: "节点能力", ToolCandidates: nil})
}
return capabilities
}
func matchDraftTool(candidates []string, tools []DraftTool) string {
if len(candidates) == 0 || len(tools) == 0 {
return ""
}
for _, enabledOnly := range []bool{true, false} {
for _, candidate := range candidates {
candidate = strings.ToLower(strings.TrimSpace(candidate))
for _, tool := range tools {
if enabledOnly && !tool.Enabled {
continue
}
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(tool.Key, tool.Name)))
if key != "" && strings.Contains(key, candidate) {
return firstNonEmpty(tool.Key, tool.Name)
}
}
}
}
return ""
}
func containsAnyFold(text string, needles ...string) bool {
lower := strings.ToLower(text)
for _, needle := range needles {
if strings.Contains(lower, strings.ToLower(needle)) {
return true
}
}
return false
}
func draftOutputLabel(wantsReport bool) string {
if wantsReport {
return "输出报告"
}
return "输出"
}
func branchLabel(source, conditionID string) string {
if source == conditionID && conditionID != "" {
return "是"
}
return ""
}
func branchConfig(source, conditionID string, yes bool) map[string]any {
if source != conditionID || conditionID == "" {
return nil
}
if yes {
return map[string]any{"condition": `{{previous.matched}} == "true"`, "branch": "true"}
}
return map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"}
}
func draftName(prompt string) string {
runes := []rune(strings.TrimSpace(prompt))
if len(runes) > 22 {
return string(runes[:22]) + "..."
}
return string(runes)
}
func draftSlug(prompt string) string {
lower := strings.ToLower(strings.TrimSpace(prompt))
var b strings.Builder
lastDash := false
for _, r := range lower {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash && b.Len() > 0 {
b.WriteByte('-')
lastDash = true
}
}
slug := strings.Trim(b.String(), "-")
if slug != "" {
if len(slug) > 48 {
return strings.Trim(slug[:48], "-")
}
return slug
}
h := fnv.New32a()
_, _ = h.Write([]byte(lower))
if !utf8.ValidString(lower) || lower == "" {
lower = "workflow"
}
return fmt.Sprintf("ai-workflow-%x", h.Sum32())
}
+213
View File
@@ -0,0 +1,213 @@
package workflow
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cyberstrike-ai/internal/config"
)
func TestGenerateDraftFromNaturalLanguageHighRiskAddsHITLAndValidGraph(t *testing.T) {
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
Prompt: "对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告",
Options: DraftOptions{
IncludeObjective: true,
AllowSchedule: false,
AllowHighRisk: false,
},
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
})
if err != nil {
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
}
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
}
if !result.Audit.HighRisk || !result.Audit.NeedsHITL || len(result.Audit.RiskWarnings) == 0 {
t.Fatalf("audit did not flag high-risk HITL path: %#v", result.Audit)
}
var hasTool, hasHITL, hasConfirmation bool
for _, node := range result.Graph.Nodes {
if node.Type == "tool" && cfgString(node.Config, "tool_name") == "nmap" {
hasTool = true
}
if node.Type == "hitl" {
hasHITL = true
}
if cfgString(node.Config, "requires_human_confirmation") == "true" {
hasConfirmation = true
}
}
if !hasTool || !hasHITL || !hasConfirmation {
t.Fatalf("expected nmap tool, HITL, and confirmation marker; tool=%v hitl=%v confirmation=%v", hasTool, hasHITL, hasConfirmation)
}
}
func TestGenerateDraftAllowHighRiskStillLabelsConditionBranch(t *testing.T) {
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
Prompt: "如果漏洞扫描发现高危漏洞,允许生成执行修复脚本的草稿并输出报告",
Options: DraftOptions{
AllowHighRisk: true,
},
AvailableTools: []DraftTool{{Key: "nuclei", Name: "nuclei", Enabled: true}},
})
if err != nil {
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
}
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
}
branches := map[string]bool{}
for _, edge := range result.Graph.Edges {
if branch := cfgString(edge.Config, "branch"); branch != "" {
branches[branch] = true
}
}
if !branches["true"] || !branches["false"] {
t.Fatalf("condition branches = %#v, want true and false", branches)
}
}
func TestGenerateDraftFromLLMUsesOpenAICompatibleEndpoint(t *testing.T) {
called := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
if r.URL.Path != "/chat/completions" {
t.Fatalf("path = %s, want /chat/completions", r.URL.Path)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Fatalf("authorization = %q", got)
}
var payload struct {
Temperature float64 `json:"temperature"`
ResponseFormat struct {
Type string `json:"type"`
} `json:"response_format"`
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"messages"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode request: %v", err)
}
if payload.Temperature != 0 || payload.ResponseFormat.Type != "json_object" {
t.Fatalf("unexpected structured output controls: temperature=%v response_format=%#v", payload.Temperature, payload.ResponseFormat)
}
if len(payload.Messages) == 0 || strings.Contains(payload.Messages[0].Content, "start|tool|agent") {
t.Fatalf("system prompt still contains pipe enum: %q", payload.Messages[0].Content)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"llm-port-scan\",\"name\":\"端口扫描\",\"description\":\"端口扫描\",\"enabled\":true},\"graph\":{\"nodes\":[{\"id\":\"start-1\",\"type\":\"start\",\"label\":\"开始\",\"position\":{\"x\":120,\"y\":150},\"config\":{\"input_keys\":\"message, target\"}},{\"id\":\"tool-2\",\"type\":\"tool\",\"label\":\"端口扫描\",\"position\":{\"x\":330,\"y\":150},\"config\":{\"tool_name\":\"nmap\",\"arguments\":\"{\\\"target\\\":\\\"{{inputs.target}}\\\"}\",\"timeout_seconds\":\"120\",\"join_strategy\":\"all_merge\"}},{\"id\":\"output-3\",\"type\":\"output\",\"label\":\"输出报告\",\"position\":{\"x\":540,\"y\":150},\"config\":{\"source_binding\":{\"from\":\"previous\",\"field\":\"output\"},\"join_strategy\":\"all_merge\"}}],\"edges\":[{\"id\":\"edge-1\",\"source\":\"start-1\",\"target\":\"tool-2\"},{\"id\":\"edge-2\",\"source\":\"tool-2\",\"target\":\"output-3\"}],\"config\":{\"schema_version\":1}},\"capabilities\":[{\"label\":\"端口扫描\",\"tool_name\":\"nmap\",\"tool_candidates\":[\"nmap\"]}],\"audit\":{\"assumptions\":[]}}"}}]}`))
}))
defer srv.Close()
result, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
Prompt: "对目标做端口扫描并输出报告",
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
if err != nil {
t.Fatalf("GenerateDraftFromLLM: %v", err)
}
if !called {
t.Fatal("expected LLM endpoint to be called")
}
if result.Generator != "llm" || !result.Audit.Savable || result.Meta.ID != "llm-port-scan" {
t.Fatalf("unexpected result: %#v", result)
}
for _, node := range result.Graph.Nodes {
if node.Type == "output" && cfgString(node.Config, "output_key") != "result" {
t.Fatalf("output_key = %q, want result", cfgString(node.Config, "output_key"))
}
}
}
func TestGenerateDraftFromLLMReturnsErrorOnMalformedJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"bad\"},|\"graph\":{\"nodes\":[]}}"}}]}`))
}))
defer srv.Close()
_, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
Prompt: "随便生成一个工作流,要求所有节点都用到输出变量",
Options: DraftOptions{
IncludeObjective: true,
},
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
if err == nil {
t.Fatal("expected malformed JSON error")
}
if !strings.Contains(err.Error(), "解析大模型工作流 JSON 失败") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestNormalizeLLMDraftRepairsMissingRequiredConfig(t *testing.T) {
result := normalizeLLMDraft("随便生成一个工作流", DraftRequest{}, llmDraftEnvelope{
Graph: graphDef{
Nodes: []graphNode{
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
{ID: "agent-1", Type: "agent", Label: "分析", Config: map[string]any{}},
{ID: "out-1", Type: "output", Label: "输出结果", Config: map[string]any{}},
},
Edges: []graphEdge{
{ID: "e1", Source: "start-1", Target: "agent-1"},
{ID: "e2", Source: "agent-1", Target: "out-1"},
},
},
})
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
}
var agentKey, outputKey string
for _, node := range result.Graph.Nodes {
switch node.Type {
case "agent":
agentKey = cfgString(node.Config, "output_key")
case "output":
outputKey = cfgString(node.Config, "output_key")
}
}
if agentKey == "" || outputKey != "result" {
t.Fatalf("agentKey=%q outputKey=%q", agentKey, outputKey)
}
}
func TestNormalizeLLMDraftRepairsConditionBranches(t *testing.T) {
result := normalizeLLMDraft("如果发现异常则输出详情,否则输出正常", DraftRequest{}, llmDraftEnvelope{
Graph: graphDef{
Nodes: []graphNode{
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
{ID: "cond-1", Type: "condition", Label: "判断", Config: map[string]any{"expression": `{{inputs.message}} != ""`}},
{ID: "out-yes", Type: "output", Label: "异常", Config: map[string]any{}},
{ID: "out-no", Type: "output", Label: "正常", Config: map[string]any{}},
},
Edges: []graphEdge{
{ID: "e1", Source: "start-1", Target: "cond-1"},
{ID: "e2", Source: "cond-1", Target: "out-yes"},
{ID: "e3", Source: "cond-1", Target: "out-no"},
},
},
})
raw, _ := json.Marshal(result.Graph)
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
}
branches := map[string]bool{}
for _, edge := range result.Graph.Edges {
if edge.Source == "cond-1" {
branches[cfgString(edge.Config, "branch")] = true
}
}
if !branches["true"] || !branches["false"] {
t.Fatalf("branches = %#v, want true and false", branches)
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
name: active-directory-attack
description: >-
内网域攻击:BloodHound,Kerberoast,ADCS ESC1/ESC8,NTLM Relay,Coerce,DACL,DCSync,Zerologon/NoPac/PrintNightmare,mitm6,LLMNR,Linux内网。Use when attacking Active Directory, ADCS, NTLM relay, or internal domain.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 内网域攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: ai-llm-app-attack
description: >-
AI/LLM应用攻击:提示注入,Agent工具滥用RCE,RAG投毒,MCP供应链,torch.load pickle RCE。Use when testing LLM apps, agents, RAG, MCP plugins, or AI model file risks.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## AI / LLM 应用攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: attack-surface-recon
description: >-
侦察/攻击面测绘:被动whois/amass/crt.sh/FOFA/Shodan,主动subfinder/httpx/naabu/katana/nuclei,DNS地域/CDN/Nginx catch-all/宝塔/UniApp指纹。开局第一动作,认知写入项目黑板。Use when starting recon, asset mapping, fingerprinting, or CDN/DNS bypass discovery.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 侦察 / 攻击面测绘
+2 -1
View File
@@ -2,7 +2,8 @@
name: binary-mobile-reversing
description: >-
APK/EXE/二进制:UniApp/DCloud/Flutter逆向,证书固定绕过,导出组件,内存破坏exploit链,IoT固件。Use when reversing APK/EXE, UniApp/Flutter, native .so, or memory-corruption exploits.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## APK / EXE / 二进制逆向
+2 -1
View File
@@ -2,7 +2,8 @@
name: blockchain-contract-attack
description: >-
区块链/智能合约:Etherscan,slither/mythril,重入/访问控制/预言机/闪电贷,跨链桥,RPC暴露。Use when auditing smart contracts, DeFi, or blockchain attack surfaces.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 区块链 / 智能合约
+2 -1
View File
@@ -2,7 +2,8 @@
name: capability-primitive-search
description: >-
能力原语+状态空间搜索:read/write/exec/ssrf等原语凑RCE等式A-F,低危映射,正反向搜索,跨域兑现。Use when no single RCE, chaining low-severity vulns, or deriving novel attack chains.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 能力原语 + 状态空间搜索
+2 -1
View File
@@ -2,7 +2,8 @@
name: cloud-attack-methods
description: >-
云攻击:元数据API,S3/K8s,AWS/Azure/GCP身份提权,MinIO矩阵,阿里云FC,ChengZi SDK解密。Use when attacking cloud metadata, IAM, K8s, MinIO, Aliyun FC, or cloud post-ex.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 云攻击手法
+2 -1
View File
@@ -2,7 +2,8 @@
name: component-vuln-intel
description: >-
联网情报收集:识别组件后必做CVE/搜索引擎/中文社区/GitHub PoC/资产引擎/即时情报/依赖扩展+受阻换路。Use when a framework/component/version is identified and must search before exploit.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 联网情报收集(识别组件→立即全网搜;结果=线索/tentative,验证前不是 confirmed Fact
+2 -1
View File
@@ -2,7 +2,8 @@
name: initial-access-phishing
description: >-
初始访问/钓鱼/社工:凭据喷洒,AiTM,设备码,OAuth同意钓鱼,载荷,vishing。Use when needing initial access, phishing, AiTM, device code, or social engineering.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 初始访问 / 钓鱼 / 社工
+2 -1
View File
@@ -6,7 +6,8 @@ description: >-
(联网情报/Web/认证/服务端/源码/社工/后渗透/二进制/内网域/云/区块链/AI/无线/硬件)+0day+
组合拳+代理自举。核心:全网搜不到洞时现场推导独属于目标的攻击链。本文件为套件索引。
Use when starting a full-chain pentest engagement or needing the skill map for this suite.
tags: [渗透测试, penetration-testing, 红队, autonomous]
metadata:
tags: [渗透测试, penetration-testing, 红队, autonomous]
---
# 渗透测试Agent操作系统
+2 -1
View File
@@ -4,7 +4,8 @@ description: >-
CyberStrikeAI 项目黑板:跨会话 Fact 图(SQLite+ upsert_project_fact/record_vulnerability
边渗透边记录节奏、关系边 links、confidence、与多代理协调落库。Use when managing project
facts, blackboard index, writing evidence, or avoiding context-loss after compression.
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
metadata:
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
---
# 项目黑板(与本产品对齐)
+2 -1
View File
@@ -3,7 +3,8 @@ name: pentest-output-standards
description: >-
输出规范:中文分析,思维链,漏洞报告模板,负结果,黑板状态总览,改动台账,死锁突破。
Use when reporting findings, maintaining change ledger, or formatting pentest output.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 输出规范
+2 -1
View File
@@ -3,7 +3,8 @@ name: pentest-verification
description: >-
验证铁律:搜索≠漏洞,confirmed Fact须证据,tentative表线索,禁止空泛推测,负结果也落库,
想象力拉满+单步验证零容忍。Use when writing project facts, validating findings, or avoiding hallucination.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 验证铁律(全系统最高规则,违反即幻觉)
+2 -1
View File
@@ -2,7 +2,8 @@
name: post-exploitation
description: >-
后渗透/提权+凭据破解+密码学:反弹shell,Linux/Windows提权,横向,隧道,免杀,C2持久化,hashcat/Padding Oracle/hash长度扩展。Use when post-exploitation, privilege escalation, lateral movement, or cracking crypto.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 后渗透 / 提权 / 凭据破解 / 密码学
+2 -1
View File
@@ -2,7 +2,8 @@
name: proxy-tool-bootstrap
description: >-
自找代理+工具自举:SOCKS5/HTTP/Tor换路序列,工具Python自举,字典自生成,OOB基础设施。Use when blocked by 403/429/WAF/timeout, missing tools, or needing OOB confirmation.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 自找代理 + 工具自举(被拦换路,没工具自己写)
+2 -1
View File
@@ -2,7 +2,8 @@
name: redteam-opsec
description: >-
OPSEC隐蔽作战纪律:IP黑名单绕过,速率时序,流量混淆,最小足迹,反取证,渐进暴露。Use when maintaining stealth, bypassing IP bans, or planning covert red-team ops.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## OPSEC / 隐蔽作战纪律(免杀 / 稳定 / 隐蔽)
+2 -1
View File
@@ -2,7 +2,8 @@
name: source-code-hunting
description: >-
源码狩猎:.git泄露,危险函数grep,JS RC4解混淆,semgrep/CodeQL,trufflehog,patch diff,供应链/CI。Use when hunting source leaks, secrets, JS deobfuscation, or supply-chain issues.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 源码狩猎
+2 -1
View File
@@ -2,7 +2,8 @@
name: specialized-attack-playbooks
description: >-
专题实战利用:GoEdge私钥导出,灰产CDN取证,ARP MITM,CDN→S3 STS链,宝塔+UniApp,AI IDE API反代,OCS+MinIO;含references/scripts支持文件索引。Use when applying specialized playbooks for GoEdge, CDN, ARP MITM, BT Panel, OCS, MinIO.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 专题实战利用(全内联)
+2 -1
View File
@@ -2,7 +2,8 @@
name: unlimited-attack-scope
description: >-
不设限原则:搜索/验证/串联/记录一切,全领域适用,被拦换路,不达不休。Use when reminding scope is unlimited across Web/APK/cloud/AI/wireless/social eng.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 不设限原则
+2 -1
View File
@@ -2,7 +2,8 @@
name: web-attack-methods
description: >-
Web全栈攻击:SQLi/命令注入/SSTI/XSS/SSRF/NoSQL,认证JWT/OAuth/SAML,LFI/上传,Tomcat/WS/STOMP/XFF/PATH_INFO/CDN502/网宿JS挑战绕过。Use when testing Web injection, auth bypass, server-side, WAF/CDN bypass.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## Web 攻击手法(注入 / 认证 / 服务端 / 杂项 / CDN)
+2 -1
View File
@@ -2,7 +2,8 @@
name: wireless-hardware-attack
description: >-
无线/硬件:WiFi PMKID/WPS/Evil Twin,BLE,Zigbee,NFC,SDR,UART/JTAG/SPI,侧信道,故障注入。Use when attacking WiFi, BLE, RFID, SDR, or hardware interfaces.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 无线 / 硬件攻击
+2 -1
View File
@@ -2,7 +2,8 @@
name: zero-day-discovery
description: >-
0day自主发现引擎:变体分析/补丁间隙/差分/Fuzzing/污点推理/N-day武器化/猎人思维。Use when public vulns not found and need to discover 0day or weaponize N-day.
tags: [渗透测试, penetration-testing, 红队]
metadata:
tags: [渗透测试, penetration-testing, 红队]
---
## 0day 自主发现引擎(全网搜不到漏洞时自己挖)
+25 -15
View File
@@ -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 {
+203
View File
@@ -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;
}
}
+3458 -192
View File
File diff suppressed because it is too large Load Diff
+201 -6
View File
@@ -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 35 minutes",
"approvalUrgencyOneToThree": "Earliest approval expires in 13 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
View File
@@ -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
View File
@@ -198,7 +198,7 @@ function renderSidebar() {
const li = document.createElement('li');
li.className = 'api-group-item';
const groupLabel = translateApiDocTag(group);
li.innerHTML = `<a href="#" class="api-group-link" data-group="${escapeHtml(group)}">${escapeHtml(groupLabel)}</a>`;
li.innerHTML = `<a href="#" class="api-group-link" data-group="${escapeAttr(group)}">${escapeHtml(groupLabel)}</a>`;
groupList.appendChild(li);
});
@@ -265,7 +265,7 @@ function createEndpointCard(endpoint) {
<div class="api-endpoint-header">
<div class="api-endpoint-title">
<span class="api-method ${methodClass}">${endpoint.method.toUpperCase()}</span>
<span class="api-path">${endpoint.path}</span>
<span class="api-path">${escapeHtml(endpoint.path)}</span>
${tagHtml}
</div>
</div>
@@ -541,7 +541,7 @@ function renderTestSection(endpoint) {
bodyInput = `
<div class="api-test-input-group">
<label>${escapeHtml(_t('apiDocs.requestBodyJson'))}</label>
<textarea id="${bodyInputId}" class="test-body-input" placeholder='${escapeHtml(_t('apiDocs.requestBodyPlaceholder'))}'>${defaultBody}</textarea>
<textarea id="${escapeAttr(bodyInputId)}" class="test-body-input" placeholder='${escapeAttr(_t('apiDocs.requestBodyPlaceholder'))}'>${escapeHtml(defaultBody)}</textarea>
</div>
`;
}
@@ -554,8 +554,8 @@ function renderTestSection(endpoint) {
const inputId = `test-param-${param.name}-${escapeId(path)}-${method}`;
return `
<div class="api-test-input-group">
<label>${param.name} <span style="color: var(--error-color);">*</span></label>
<input type="text" id="${inputId}" placeholder="${param.description || param.name}" required>
<label>${escapeHtml(param.name)} <span style="color: var(--error-color);">*</span></label>
<input type="text" id="${escapeAttr(inputId)}" placeholder="${escapeAttr(param.description || param.name)}" required>
</div>
`;
}).join('');
@@ -572,11 +572,11 @@ function renderTestSection(endpoint) {
const required = param.required ? '<span style="color: var(--error-color);">*</span>' : '<span style="color: var(--text-muted);">' + escapeHtml(_t('apiDocs.optional')) + '</span>';
return `
<div class="api-test-input-group">
<label>${param.name} ${required}</label>
<label>${escapeHtml(param.name)} ${required}</label>
<input type="${param.schema?.type === 'number' || param.schema?.type === 'integer' ? 'number' : 'text'}"
id="${inputId}"
placeholder="${placeholder}"
value="${defaultValue}"
id="${escapeAttr(inputId)}"
placeholder="${escapeAttr(placeholder)}"
value="${escapeAttr(defaultValue)}"
${param.required ? 'required' : ''}>
</div>
`;
@@ -598,13 +598,13 @@ function renderTestSection(endpoint) {
${queryParamsInput ? `<div style="margin-top: 16px;"><div style="font-weight: 500; margin-bottom: 8px; color: var(--text-primary);">${queryParamsTitle}</div>${queryParamsInput}</div>` : ''}
${bodyInput}
<div class="api-test-buttons">
<button class="api-test-btn primary" onclick="testAPI('${method}', '${escapeHtml(path)}', '${endpoint.operationId || ''}')">
<button class="api-test-btn primary" onclick="testAPI(${escapeJsStringAttr(method)}, ${escapeJsStringAttr(path)}, ${escapeJsStringAttr(endpoint.operationId || '')})">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
${sendRequestLabel}
</button>
<button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, '${method}', '${escapeHtml(path)}')" title="${copyCurlTitle}">
<button class="api-test-btn copy-curl" onclick="copyCurlCommand(event, ${escapeJsStringAttr(method)}, ${escapeJsStringAttr(path)})" title="${copyCurlTitle}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="2"/>
@@ -1006,6 +1006,18 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
// ID转义(用于HTML ID属性)
function escapeId(text) {
return text.replace(/[{}]/g, '').replace(/\//g, '-');
+6 -2
View File
@@ -11,6 +11,10 @@ function getAssetPageSize() {
const assetPageState = { page: 1, pageSize: getAssetPageSize(), total: 0, totalPages: 1, items: [], projects: [], projectsLoaded: false, detailIndex: -1, editIndex: -1, detailAsset: null, editAsset: null, selected: new Map(), selectionQuery: '', allMatchingSelected: false, scanMode: 'chat', scanAssets: [], editorTags: [], editorDirty: false, editorBusy: false, editorReturnFocus: null, editorInteractionsReady: false, editorParsedTarget: '', importRows: [], importFileName: '', importBusy: false, importInteractionsReady: false, importReturnFocus: null };
let assetOverviewDays = 30;
function assetEscapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const ASSET_CUSTOM_SELECT_IDS = [
'asset-status-filter',
'asset-project-filter',
@@ -1240,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
View File
@@ -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, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
/** @param {string} text @param {{ profile?: 'chat'|'timeline' }} [options] */
function formatMarkdown(text, options) {
if (typeof window.csMarkdownSanitize !== 'undefined') {
@@ -863,7 +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
View File
@@ -113,10 +113,10 @@
if (!id || seen.has(id)) return;
seen.add(id);
const label = c2ProjectDisplayName(id, name);
html += `<option value="${escapeHtml(id)}"${id === selected ? ' selected' : ''}>${escapeHtml(label)}</option>`;
html += `<option value="${escapeAttr(id)}"${id === selected ? ' selected' : ''}>${escapeHtml(label)}</option>`;
});
if (selected && !seen.has(selected)) {
html += `<option value="${escapeHtml(selected)}" selected>${escapeHtml(c2ProjectDisplayName(selected))}</option>`;
html += `<option value="${escapeAttr(selected)}" selected>${escapeHtml(c2ProjectDisplayName(selected))}</option>`;
}
return html;
}
@@ -147,7 +147,7 @@
}
function c2ProjectBindSelectHtml(listener) {
return `<select class="c2-project-bind-select" data-id="${escapeHtml(listener.id || '')}" title="${escapeHtml(c2t('assets.project') || '所属项目')}" onclick="event.stopPropagation()" onchange="C2.bindListenerProject(this.dataset.id, this.value)">${c2ProjectOptionsHtml(c2ResourceProjectId(listener))}</select>`;
return `<select class="c2-project-bind-select" data-id="${escapeAttr(listener.id || '')}" title="${escapeAttr(c2t('assets.project') || '所属项目')}" onclick="event.stopPropagation()" onchange="C2.bindListenerProject(this.dataset.id, this.value)">${c2ProjectOptionsHtml(c2ResourceProjectId(listener))}</select>`;
}
function withC2ProjectQuery(url) {
@@ -478,7 +478,7 @@
if (empty) valueClasses.push('is-empty');
const rowCls = opts && opts.full ? ' c2-session-info-dl__row--full' : '';
const copyBtn = (opts && opts.copy && !empty)
? `<button type="button" class="c2-session-info-copy" title="${escapeHtml(c2t('c2.sessions.infoCopy'))}" aria-label="${escapeHtml(c2t('c2.sessions.infoCopy'))}" onclick="event.stopPropagation(); C2.copyText(${JSON.stringify(String(value))})">
? `<button type="button" class="c2-session-info-copy" title="${escapeAttr(c2t('c2.sessions.infoCopy'))}" aria-label="${escapeAttr(c2t('c2.sessions.infoCopy'))}" data-c2-copy-value="${escapeAttr(String(value))}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
</button>`
: '';
@@ -486,7 +486,7 @@
<div class="c2-session-info-dl__row${rowCls}">
<dt class="c2-session-info-dl__label">${escapeHtml(label)}</dt>
<dd class="${valueClasses.join(' ')}">
<span class="c2-session-info-dl__text" title="${escapeHtml(empty ? '' : display)}">${escapeHtml(display)}</span>
<span class="c2-session-info-dl__text" title="${escapeAttr(empty ? '' : display)}">${escapeHtml(display)}</span>
${copyBtn}
</dd>
</div>`;
@@ -543,16 +543,16 @@
<div class="c2-session-info-block__head">
<span class="c2-session-info-block__icon c2-session-info-block__icon--note" aria-hidden="true"></span>
<span>${escapeHtml(c2t('c2.sessions.infoSectionNote'))}</span>
<button type="button" class="c2-session-note-edit-btn" data-require-permission="c2:write" onclick='C2.beginEditSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('c2.sessions.noteEdit'))}</button>
<button type="button" class="c2-session-note-edit-btn" data-require-permission="c2:write" data-c2-action="session-note-edit" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('c2.sessions.noteEdit'))}</button>
</div>
<div class="c2-session-info-note${noteEmpty ? ' is-empty' : ''}" id="c2-session-note-view">${escapeHtml(noteText || c2t('c2.sessions.infoNoteEmpty'))}</div>
<div class="c2-session-note-editor" id="c2-session-note-editor" hidden>
<textarea id="c2-session-note-input" class="c2-session-note-textarea" maxlength="2000" rows="4" placeholder="${escapeHtml(c2t('c2.sessions.notePlaceholder'))}">${escapeHtml(noteText)}</textarea>
<textarea id="c2-session-note-input" class="c2-session-note-textarea" maxlength="2000" rows="4" placeholder="${escapeAttr(c2t('c2.sessions.notePlaceholder'))}">${escapeHtml(noteText)}</textarea>
<div class="c2-session-note-editor__footer">
<span class="c2-session-note-counter" id="c2-session-note-counter">${noteText.length}/2000</span>
<div class="c2-session-note-editor__actions">
<button type="button" class="btn-secondary btn-sm" onclick="C2.cancelEditSessionNote()">${escapeHtml(c2t('common.cancel'))}</button>
<button type="button" class="btn-primary btn-sm" id="c2-session-note-save" onclick='C2.saveSessionNote(${JSON.stringify(s.id)})'>${escapeHtml(c2t('common.save'))}</button>
<button type="button" class="btn-primary btn-sm" id="c2-session-note-save" data-c2-action="session-note-save" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('common.save'))}</button>
</div>
</div>
</div>
@@ -638,15 +638,19 @@
return div.innerHTML;
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/** 任务列表操作按钮(查看/取消/删除)— 事件委托 */
function bindC2TaskActionDelegation() {
if (document.documentElement.dataset.c2TaskActionsBound === '1') return;
document.documentElement.dataset.c2TaskActionsBound = '1';
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-c2-task-action]');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const btn = e.target.closest('[data-c2-task-action]');
if (!btn) return;
e.preventDefault();
e.stopImmediatePropagation();
const action = btn.getAttribute('data-c2-task-action');
const id = btn.getAttribute('data-task-id');
if (!id) return;
@@ -657,6 +661,75 @@
}
bindC2TaskActionDelegation();
/** C2 动态内容操作按钮 — 避免把用户可控值拼入 inline onclick */
function bindC2SafeActionDelegation() {
if (document.documentElement.dataset.c2SafeActionsBound === '1') return;
document.documentElement.dataset.c2SafeActionsBound = '1';
document.addEventListener('click', function(e) {
const copyBtn = e.target.closest('[data-c2-copy-value]');
if (copyBtn) {
e.preventDefault();
e.stopPropagation();
C2.copyText(copyBtn.getAttribute('data-c2-copy-value') || '');
return;
}
const fileBtn = e.target.closest('[data-c2-file-action]');
if (fileBtn) {
e.preventDefault();
e.stopPropagation();
const name = fileBtn.getAttribute('data-c2-file-name') || '';
const action = fileBtn.getAttribute('data-c2-file-action');
if (action === 'open') C2.openDirectory(name);
else if (action === 'download') C2.downloadFile(name);
return;
}
const stopEl = e.target.closest('[data-c2-stop-action]');
const actionEl = e.target.closest('[data-c2-action]');
if (stopEl && (!actionEl || !stopEl.contains(actionEl))) return;
if (!actionEl) return;
e.preventDefault();
e.stopPropagation();
runC2SafeAction(actionEl);
});
document.addEventListener('keydown', function(e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
const actionEl = e.target.closest('[data-c2-action][role="button"]');
if (!actionEl) return;
e.preventDefault();
runC2SafeAction(actionEl);
});
}
bindC2SafeActionDelegation();
function runC2SafeAction(el) {
const action = el.getAttribute('data-c2-action');
const id = el.getAttribute('data-c2-id') || '';
switch (action) {
case 'session-note-edit': C2.beginEditSessionNote(id); break;
case 'session-note-save': C2.saveSessionNote(id); break;
case 'listener-start': C2.startListener(id); break;
case 'listener-stop': C2.stopListener(id); break;
case 'listener-edit': C2.editListener(id); break;
case 'listener-delete': C2.deleteListener(id); break;
case 'listener-save': C2.saveListener(id); break;
case 'session-select': C2.selectSession(id); break;
case 'session-delete': C2.deleteSessionRecord(id); break;
case 'session-sleep': C2.setSessionSleep(id); break;
case 'session-kill': C2.killSession(id); break;
case 'session-tasks-refresh': C2.loadSessionTasks(id); break;
case 'task-view': C2.viewTask(id); break;
case 'event-view': C2.viewEvent(id); break;
case 'event-delete': C2.deleteEventById(id); break;
case 'profile-delete': C2.deleteProfile(id); break;
case 'payload-download': {
if (typeof window.__c2DownloadPayload === 'function') window.__c2DownloadPayload(id);
break;
}
}
}
/** 监听器表单:Malleable Profile 下拉选项 HTMLvalue / 文本已转义) */
function listenerProfileSelectHtml(selectedProfileId) {
const sel = selectedProfileId ? String(selectedProfileId) : '';
@@ -665,7 +738,7 @@
if (!p) continue;
const pid = p.id || p.ID;
if (!pid) continue;
const idEsc = escapeHtml(String(pid));
const idEsc = escapeAttr(String(pid));
const nameEsc = escapeHtml(p.name || pid);
const selected = sel && String(pid) === sel ? ' selected' : '';
opts += `<option value="${idEsc}"${selected}>${nameEsc}</option>`;
@@ -852,7 +925,7 @@
const profilePid = listenerResolvedProfileId(l);
const profileName = listenerProfileDisplayName(l);
const profileBadge = profilePid
? '<div class="c2-listener-profile-badge" title="' + escapeHtml(c2t('c2.listeners.profileBadgeTitle')) + '"><span class="c2-listener-profile-dot" aria-hidden="true"></span><span>' + escapeHtml(profileName) + '</span></div>'
? '<div class="c2-listener-profile-badge" title="' + escapeAttr(c2t('c2.listeners.profileBadgeTitle')) + '"><span class="c2-listener-profile-dot" aria-hidden="true"></span><span>' + escapeHtml(profileName) + '</span></div>'
: '';
const cb = C2.getListenerCallbackHost(l);
const cbRow = cb
@@ -868,7 +941,7 @@
const bindVal = escapeHtml(String(l.bindHost)) + ':' + escapeHtml(String(l.bindPort));
return `
<article class="c2-listener-card c2-listener-card--${stUi}" data-listener-id="${escapeHtml(l.id)}">
<article class="c2-listener-card c2-listener-card--${stUi}" data-listener-id="${escapeAttr(l.id)}">
<div class="c2-listener-card-head">
<div class="c2-ltype-mark ${typeVis}" title="${fullType}"><span>${typeMark}</span></div>
<div class="c2-listener-card-head-main">
@@ -877,7 +950,7 @@
<span class="c2-listener-pill c2-listener-pill--${stUi}">${pillLabel}</span>
</div>
<div class="c2-listener-id-row">
<code class="c2-listener-id-full" title="${escapeHtml(l.id)}">${escapeHtml(l.id)}</code>
<code class="c2-listener-id-full" title="${escapeAttr(l.id)}">${escapeHtml(l.id)}</code>
</div>
</div>
</div>
@@ -894,11 +967,11 @@
</div>
<div class="c2-listener-card-actions">
${l.status === 'stopped'
? `<button type="button" class="btn-primary btn-sm" data-require-permission="c2:write" onclick="C2.startListener('${l.id}')">▶ ${escapeHtml(c2t('c2.listeners.start'))}</button>`
: `<button type="button" class="btn-secondary btn-sm" data-require-permission="c2:write" onclick="C2.stopListener('${l.id}')">⏹ ${escapeHtml(c2t('c2.listeners.stop'))}</button>`
? `<button type="button" class="btn-primary btn-sm" data-require-permission="c2:write" data-c2-action="listener-start" data-c2-id="${escapeAttr(l.id)}">▶ ${escapeHtml(c2t('c2.listeners.start'))}</button>`
: `<button type="button" class="btn-secondary btn-sm" data-require-permission="c2:write" data-c2-action="listener-stop" data-c2-id="${escapeAttr(l.id)}">⏹ ${escapeHtml(c2t('c2.listeners.stop'))}</button>`
}
<button type="button" class="btn-secondary btn-sm" data-require-permission="c2:write" onclick="C2.editListener('${l.id}')">${escapeHtml(c2t('c2.listeners.edit'))}</button>
<button type="button" class="btn-danger btn-sm" data-require-permission="c2:delete" onclick="C2.deleteListener('${l.id}')">${escapeHtml(c2t('c2.listeners.delete'))}</button>
<button type="button" class="btn-secondary btn-sm" data-require-permission="c2:write" data-c2-action="listener-edit" data-c2-id="${escapeAttr(l.id)}">${escapeHtml(c2t('c2.listeners.edit'))}</button>
<button type="button" class="btn-danger btn-sm" data-require-permission="c2:delete" data-c2-action="listener-delete" data-c2-id="${escapeAttr(l.id)}">${escapeHtml(c2t('c2.listeners.delete'))}</button>
</div>
</article>`;
}).join('');
@@ -963,7 +1036,7 @@
<div class="c2-form-row">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
<input type="text" id="c2-listener-name" class="form-control" placeholder="${escapeHtml(c2t('c2.listeners.placeholderNameExample'))}">
<input type="text" id="c2-listener-name" class="form-control" placeholder="${escapeAttr(c2t('c2.listeners.placeholderNameExample'))}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.type'))}</label>
@@ -1003,7 +1076,7 @@
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.remark'))}</label>
<input type="text" id="c2-listener-remark" class="form-control" placeholder="${escapeHtml(c2t('c2.listeners.placeholderRemarkLong'))}">
<input type="text" id="c2-listener-remark" class="form-control" placeholder="${escapeAttr(c2t('c2.listeners.placeholderRemarkLong'))}">
</div>
<div class="c2-form-group" id="c2-listener-legacy-shell-group" style="display:none;">
<label class="c2-checkbox-label">
@@ -1191,7 +1264,7 @@
<div class="c2-modal-body">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.name'))}</label>
<input type="text" id="c2-listener-name" class="form-control" value="${escapeHtml(l.name)}">
<input type="text" id="c2-listener-name" class="form-control" value="${escapeAttr(l.name)}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('assets.project') || '所属项目')}</label>
@@ -1200,7 +1273,7 @@
<div class="c2-form-row">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindHost'))}</label>
<input type="text" id="c2-listener-host" class="form-control" value="${escapeHtml(String(l.bindHost))}">
<input type="text" id="c2-listener-host" class="form-control" value="${escapeAttr(String(l.bindHost))}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.bindPort'))}</label>
@@ -1215,12 +1288,12 @@
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.callbackHost'))}</label>
<input type="text" id="c2-listener-callback-host" class="form-control" value="${escapeHtml(cbHost)}">
<input type="text" id="c2-listener-callback-host" class="form-control" value="${escapeAttr(cbHost)}">
<div class="form-hint">${escapeHtml(c2t('c2.listeners.callbackHostHint'))}</div>
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.listeners.remark'))}</label>
<input type="text" id="c2-listener-remark" class="form-control" value="${escapeHtml(l.remark || '')}">
<input type="text" id="c2-listener-remark" class="form-control" value="${escapeAttr(l.remark || '')}">
</div>
${lt === 'tcp_reverse' ? `
<div class="c2-form-group" id="c2-listener-legacy-shell-group">
@@ -1233,7 +1306,7 @@
</div>
<div class="c2-modal-footer">
<button class="btn-secondary" onclick="C2.closeModal()">${escapeHtml(c2t('common.cancel'))}</button>
<button class="btn-primary" onclick="C2.saveListener('${l.id}')">${escapeHtml(c2t('common.save'))}</button>
<button class="btn-primary" data-c2-action="listener-save" data-c2-id="${escapeAttr(l.id)}">${escapeHtml(c2t('common.save'))}</button>
</div>
`;
C2.refreshFormSelects(content);
@@ -1304,11 +1377,11 @@
const listenerOpts = ['<option value="">' + escapeHtml(c2t('c2.sessions.filterAllListeners')) + '</option>']
.concat(listeners.map(l => {
const sel = f.listener_id === l.id ? ' selected' : '';
return `<option value="${escapeHtml(l.id)}"${sel}>${escapeHtml(l.name)}</option>`;
return `<option value="${escapeAttr(l.id)}"${sel}>${escapeHtml(l.name)}</option>`;
})).join('');
toolbar.innerHTML = `
<div class="c2-sessions-filter-row">
<select id="c2-session-filter-status" class="form-control c2-native-select" title="${escapeHtml(c2t('c2.sessions.status'))}" onchange="C2.applySessionFilter()">
<select id="c2-session-filter-status" class="form-control c2-native-select" title="${escapeAttr(c2t('c2.sessions.status'))}" onchange="C2.applySessionFilter()">
<option value="">${escapeHtml(c2t('c2.sessions.filterAllStatus'))}</option>
<option value="active"${f.status === 'active' ? ' selected' : ''}>${escapeHtml(c2t('c2.sessions.active'))}</option>
<option value="sleeping"${f.status === 'sleeping' ? ' selected' : ''}>${escapeHtml(c2t('c2.sessions.sleeping'))}</option>
@@ -1316,7 +1389,7 @@
</select>
<select id="c2-session-filter-listener" class="form-control c2-native-select" onchange="C2.applySessionFilter()">${listenerOpts}</select>
</div>
<input type="text" id="c2-session-filter-search" class="form-control" placeholder="${escapeHtml(c2t('c2.sessions.filterSearchPlaceholder'))}" value="${escapeHtml(f.search || '')}" onkeydown="if(event.key==='Enter'){C2.applySessionFilter();}">
<input type="text" id="c2-session-filter-search" class="form-control" placeholder="${escapeAttr(c2t('c2.sessions.filterSearchPlaceholder'))}" value="${escapeAttr(f.search || '')}" onkeydown="if(event.key==='Enter'){C2.applySessionFilter();}">
<div class="c2-sessions-toolbar-meta">
<label class="c2-sessions-select-all-label">
<input type="checkbox" id="c2-sessions-select-all" onchange="C2.onSessionsSelectAll(this.checked)">
@@ -1452,10 +1525,13 @@
const osEmpty = isEmptyInfoValue(s.os) && isEmptyInfoValue(s.arch);
return `
<div class="c2-session-item ${s.id === C2.selectedSessionId ? 'active' : ''}"
data-status="${escapeHtml(s.status || '')}"
onclick="C2.selectSession('${s.id}')">
<input type="checkbox" class="c2-session-item-check c2-session-row-check" data-id="${escapeHtml(s.id)}"
onclick="event.stopPropagation();" onchange="C2.syncSessionsToolbar()">
data-status="${escapeAttr(s.status || '')}"
data-c2-action="session-select"
data-c2-id="${escapeAttr(s.id)}"
role="button"
tabindex="0">
<input type="checkbox" class="c2-session-item-check c2-session-row-check" data-id="${escapeAttr(s.id)}"
data-c2-stop-action="1" onchange="C2.syncSessionsToolbar()">
<div class="c2-session-item-body">
<div class="c2-session-header">
<div class="c2-session-host-row">
@@ -1474,8 +1550,8 @@
<span class="c2-session-chip c2-session-chip--dim">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
</div>
<div class="c2-session-item-footer">
<span class="c2-session-meta c2-session-item-time" title="${escapeHtml(formatTime(s.lastCheckIn))}">${escapeHtml(formatRelativeTime(s.lastCheckIn) || formatTime(s.lastCheckIn))}</span>
<button type="button" class="c2-session-card-delete" data-require-permission="c2:delete" onclick="event.stopPropagation(); C2.deleteSessionRecord('${s.id}');">${escapeHtml(c2t('c2.sessions.cardDeleteSession'))}</button>
<span class="c2-session-meta c2-session-item-time" title="${escapeAttr(formatTime(s.lastCheckIn))}">${escapeHtml(formatRelativeTime(s.lastCheckIn) || formatTime(s.lastCheckIn))}</span>
<button type="button" class="c2-session-card-delete" data-require-permission="c2:delete" data-c2-action="session-delete" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('c2.sessions.cardDeleteSession'))}</button>
</div>
</div>
</div>`;
@@ -1531,7 +1607,7 @@
</div>
<div class="c2-session-hero__sub${isEmptyInfoValue(s.username) && isEmptyInfoValue(s.os) ? ' is-muted' : ''}">${escapeHtml(sessionMetaLine(s))}</div>
<div class="c2-session-hero__chips">
<span class="c2-session-hero-chip is-mono" title="${escapeHtml(c2t('c2.sessions.infoSessionId'))}">${escapeHtml(s.id)}</span>
<span class="c2-session-hero-chip is-mono" title="${escapeAttr(c2t('c2.sessions.infoSessionId'))}">${escapeHtml(s.id)}</span>
<span class="c2-session-hero-chip">${escapeHtml(s.internalIp || '—')}</span>
<span class="c2-session-hero-chip">PID ${escapeHtml(String(s.pid != null ? s.pid : '—'))}</span>
</div>
@@ -1544,8 +1620,8 @@
<span class="c2-session-hero__heartbeat-value">${escapeHtml(heartbeatRel)}</span>
</div>
<div class="c2-session-actions">
<button class="btn-secondary btn-sm" data-require-permission="c2:write" onclick="C2.setSessionSleep('${s.id}')">${escapeHtml(c2t('c2.sessions.btnSleep'))}</button>
<button class="btn-danger btn-sm" data-require-permission="c2:write" onclick="C2.killSession('${s.id}')">${escapeHtml(c2t('c2.sessions.kill'))}</button>
<button class="btn-secondary btn-sm" data-require-permission="c2:write" data-c2-action="session-sleep" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('c2.sessions.btnSleep'))}</button>
<button class="btn-danger btn-sm" data-require-permission="c2:write" data-c2-action="session-kill" data-c2-id="${escapeAttr(s.id)}">${escapeHtml(c2t('c2.sessions.kill'))}</button>
</div>
</div>
</div>
@@ -1571,7 +1647,7 @@
<div class="c2-file-toolbar">
<button class="btn-ghost btn-sm" onclick="C2.goToParentDirectory()">${escapeHtml(c2t('c2.files.parent'))}</button>
<button class="btn-ghost btn-sm" onclick="C2.refreshFiles()">${escapeHtml(c2t('c2.files.refresh'))}</button>
<button type="button" class="btn-ghost btn-sm" id="c2-file-upload-btn" data-require-permission="c2:write" onclick="C2.openFileUploadPicker()" title="${escapeHtml(c2t('c2.files.upload'))}">${escapeHtml(c2t('c2.files.upload'))}</button>
<button type="button" class="btn-ghost btn-sm" id="c2-file-upload-btn" data-require-permission="c2:write" onclick="C2.openFileUploadPicker()" title="${escapeAttr(c2t('c2.files.upload'))}">${escapeHtml(c2t('c2.files.upload'))}</button>
<input type="file" id="c2-file-upload-input" style="display:none" onchange="C2.onC2FileUploadPick(event)" />
<span id="c2-current-path" class="c2-path-breadcrumb">/</span>
</div>
@@ -1698,7 +1774,7 @@
<p class="c2-sleep-modal__host">${escapeHtml(hostLabel)}</p>
</div>
</div>
<button type="button" class="c2-modal-close" onclick="C2.closeModal()" aria-label="${escapeHtml(c2t('common.close'))}">&times;</button>
<button type="button" class="c2-modal-close" onclick="C2.closeModal()" aria-label="${escapeAttr(c2t('common.close'))}">&times;</button>
</div>
<div class="c2-sleep-modal__current">${escapeHtml(currentLine)}</div>
<div class="c2-sleep-modal__body">
@@ -2778,16 +2854,16 @@
<td>
<div class="c2-file-name">
<span class="${iconCls}" aria-hidden="true"></span>
<span class="c2-file-name-text" title="${escapeHtml(entry.name)}">${escapeHtml(entry.name)}</span>
<span class="c2-file-name-text" title="${escapeAttr(entry.name)}">${escapeHtml(entry.name)}</span>
</div>
</td>
<td title="${escapeHtml(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
<td title="${escapeAttr(String(entry.size || ''))}">${escapeHtml(sizeLabel)}</td>
<td>${escapeHtml(entry.mode)}</td>
<td>
${entry.isDir
? `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.openDirectory(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.open'))}</button>`
: `<button class="btn-ghost btn-sm c2-file-action-btn" onclick='C2.downloadFile(${JSON.stringify(entry.name)})'>${escapeHtml(c2t('c2.files.download'))}</button>`
}
<button type="button"
class="btn-ghost btn-sm c2-file-action-btn"
data-c2-file-action="${entry.isDir ? 'open' : 'download'}"
data-c2-file-name="${escapeAttr(entry.name)}">${escapeHtml(c2t(entry.isDir ? 'c2.files.open' : 'c2.files.download'))}</button>
</td>
</tr>
`;
@@ -3366,7 +3442,7 @@
<span class="c2-session-tasks-heading">${escapeHtml(c2t('c2.tasks.sessionTaskHistory'))}</span>
<span class="c2-session-tasks-count">0</span>
</div>
<button type="button" class="btn-ghost btn-sm c2-session-tasks-refresh" onclick="C2.loadSessionTasks('${escapeHtml(sessionId)}')">${refreshBtn}</button>
<button type="button" class="btn-ghost btn-sm c2-session-tasks-refresh" data-c2-action="session-tasks-refresh" data-c2-id="${escapeAttr(sessionId)}">${refreshBtn}</button>
</div>
<div class="c2-empty-inline">
<div class="c2-empty-inline__icon" aria-hidden="true"></div>
@@ -3383,7 +3459,7 @@
<span class="c2-session-tasks-heading">${escapeHtml(c2t('c2.tasks.sessionTaskHistory'))}</span>
<span class="c2-session-tasks-count">${countLabel}</span>
</div>
<button type="button" class="btn-ghost btn-sm c2-session-tasks-refresh" onclick="C2.loadSessionTasks('${escapeHtml(sessionId)}')">${refreshBtn}</button>
<button type="button" class="btn-ghost btn-sm c2-session-tasks-refresh" data-c2-action="session-tasks-refresh" data-c2-id="${escapeAttr(sessionId)}">${refreshBtn}</button>
</div>
<div class="c2-session-tasks-rows">
${tasks.map(t => {
@@ -3395,11 +3471,11 @@
const isPending = status === 'queued' || status === 'sent' || status === 'running';
const timeStr = formatTime(t.completedAt || t.createdAt);
return `
<div class="c2-session-task-row ${isPending ? 'is-pending' : ''}" data-status="${escapeHtml(status)}">
<div class="c2-session-task-row ${isPending ? 'is-pending' : ''}" data-status="${escapeAttr(status)}">
<div class="c2-session-task-row__main">
<span class="c2-task-status-dot ${escapeHtml(status)}" title="${escapeHtml(taskStatusLabel(status))}"></span>
<span class="c2-task-status-dot ${escapeAttr(status)}" title="${escapeAttr(taskStatusLabel(status))}"></span>
<span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span>
<div class="c2-session-task-row__cmd" title="${escapeHtml(cmd || '')}">
<div class="c2-session-task-row__cmd" title="${escapeAttr(cmd || '')}">
${cmdShort
? `<code class="c2-session-task-command">${escapeHtml(cmdShort)}</code>`
: `<span class="c2-session-task-command c2-session-task-command--muted">—</span>`}
@@ -3408,8 +3484,8 @@
<div class="c2-session-task-row__meta">
<span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span>
<span class="c2-session-task-duration">${formatDuration(t.durationMs)}</span>
<span class="c2-session-task-time" title="${escapeHtml(timeStr)}">${escapeHtml(formatRelativeTime(t.completedAt || t.createdAt) || timeStr)}</span>
<button type="button" class="btn-secondary btn-small c2-session-task-view" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
<span class="c2-session-task-time" title="${escapeAttr(timeStr)}">${escapeHtml(formatRelativeTime(t.completedAt || t.createdAt) || timeStr)}</span>
<button type="button" class="btn-secondary btn-small c2-session-task-view" data-c2-task-action="view" data-task-id="${escapeAttr(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
</div>
</div>`;
}).join('')}
@@ -3446,7 +3522,7 @@
<thead>
<tr>
<th class="c2-tasks-table-col-check">
<label class="c2-task-check-label" title="${escapeHtml(c2t('c2.tasks.selectAll'))}">
<label class="c2-task-check-label" title="${escapeAttr(c2t('c2.tasks.selectAll'))}">
<input type="checkbox" id="c2-tasks-select-all" onchange="C2.onTasksSelectAll(this.checked)">
</label>
</th>
@@ -3461,10 +3537,11 @@
</tr>
</thead>
<tbody>
${C2.tasks.map(t => {
const rawId = t.id || '';
const tid = escapeHtml(rawId);
const status = String(t.status || '');
${C2.tasks.map(t => {
const rawId = t.id || '';
const tid = escapeHtml(rawId);
const tidAttr = escapeAttr(rawId);
const status = String(t.status || '');
const rowStatus = escapeHtml(status || 'queued');
const typeCat = taskTypeCategory(t.taskType);
const sessionFull = t.sessionId ? String(t.sessionId) : '';
@@ -3478,25 +3555,25 @@
const cmdEsc = escapeHtml(cmd);
const canCancel = status === 'queued' || status === 'sent';
return `
<tr class="c2-tasks-row c2-tasks-row--${rowStatus}" data-task-id="${tid}" onclick="C2.viewTask('${tid}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();C2.viewTask('${tid}')}" role="button" tabindex="0">
<td class="c2-tasks-table-col-check" onclick="event.stopPropagation();">
<tr class="c2-tasks-row c2-tasks-row--${rowStatus}" data-task-id="${tidAttr}" data-c2-action="task-view" data-c2-id="${tidAttr}" role="button" tabindex="0">
<td class="c2-tasks-table-col-check" data-c2-stop-action="1">
<label class="c2-task-check-label">
<input type="checkbox" class="c2-task-row-check" data-id="${tid}" onchange="C2.syncTasksToolbar()">
<input type="checkbox" class="c2-task-row-check" data-id="${tidAttr}" onchange="C2.syncTasksToolbar()">
</label>
</td>
<td class="c2-tasks-col-time">${escapeHtml(formatTime(t.createdAt))}</td>
<td><span class="c2-status-badge ${escapeHtml(status)}">${escapeHtml(taskStatusLabel(status))}</span></td>
<td><span class="c2-task-type-badge c2-task-type-badge--${typeCat}">${escapeHtml(t.taskType || '-')}</span></td>
<td class="c2-tasks-col-command" title="${cmdEsc}">${cmdEsc || dash}</td>
<td class="c2-tasks-col-mono" title="${escapeHtml(sessionFull)}">${sessionShort || dash}</td>
<td class="c2-tasks-col-mono" title="${tid}">${taskShort || dash}</td>
<td class="c2-tasks-col-command" title="${escapeAttr(cmd)}">${cmdEsc || dash}</td>
<td class="c2-tasks-col-mono" title="${escapeAttr(sessionFull)}">${sessionShort || dash}</td>
<td class="c2-tasks-col-mono" title="${tidAttr}">${taskShort || dash}</td>
<td class="c2-tasks-col-duration">${formatDuration(t.durationMs)}</td>
<td class="c2-tasks-table-col-actions" onclick="event.stopPropagation();">
<td class="c2-tasks-table-col-actions" data-c2-stop-action="1">
<div class="c2-tasks-row-actions">
${canCancel
? `<button type="button" class="c2-tasks-cancel-btn" data-c2-task-action="cancel" data-task-id="${tid}" title="${cancelTitle}" aria-label="${cancelTitle}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''}
<button type="button" class="c2-tasks-delete-btn" data-require-permission="c2:delete" data-c2-task-action="delete" data-task-id="${tid}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
? `<button type="button" class="c2-tasks-cancel-btn" data-c2-task-action="cancel" data-task-id="${tidAttr}" title="${cancelTitle}" aria-label="${cancelTitle}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
: ''}
<button type="button" class="c2-tasks-delete-btn" data-require-permission="c2:delete" data-c2-task-action="delete" data-task-id="${tidAttr}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
</div>
</td>
</tr>`;
@@ -3717,7 +3794,7 @@
C2.renderPayloadPage = function() {
const optionsHtml = C2.listeners.length > 0
? C2.listeners.map(l =>
`<option value="${l.id}">${escapeHtml(l.name)} (${l.type} ${l.bindHost}:${l.bindPort})</option>`
`<option value="${escapeAttr(l.id)}">${escapeHtml(l.name)} (${escapeHtml(l.type)} ${escapeHtml(l.bindHost)}:${escapeHtml(l.bindPort)})</option>`
).join('')
: '<option value="">' + escapeHtml(c2t('c2.payloads.noListenersOption')) + '</option>';
@@ -3734,7 +3811,7 @@
let buildOptionsHtml;
if (listeners.length > 0) {
buildOptionsHtml = listeners.map(l =>
`<option value="${l.id}">${escapeHtml(l.name)} (${l.type} ${l.bindHost}:${l.bindPort})</option>`
`<option value="${escapeAttr(l.id)}">${escapeHtml(l.name)} (${escapeHtml(l.type)} ${escapeHtml(l.bindHost)}:${escapeHtml(l.bindPort)})</option>`
).join('');
} else {
buildOptionsHtml = '<option value="">' + escapeHtml(c2t('c2.payloads.noListenersOption')) + '</option>';
@@ -3831,7 +3908,7 @@
<div> ${escapeHtml(c2t('c2.payloads.buildSuccessTitle'))}</div>
<div>${escapeHtml(c2t('c2.payloads.buildMetaOsArch', { os: data.payload?.os, arch: data.payload?.arch }))}</div>
<div>${escapeHtml(c2t('c2.payloads.buildSize', { bytes: data.payload?.size_bytes }))}</div>
<button onclick="window.__c2DownloadPayload('${data.payload?.download_path?.split('/').pop()}')"
<button type="button" data-c2-action="payload-download" data-c2-id="${escapeAttr(data.payload?.download_path?.split('/').pop() || '')}"
class="btn-primary" style="margin-top:8px;display:inline-block;cursor:pointer;">${escapeHtml(c2t('c2.payloads.download'))}</button>
</div>
`;
@@ -4213,7 +4290,7 @@
<thead>
<tr>
<th class="c2-events-table-col-check">
<label class="c2-event-check-label" title="${escapeHtml(c2t('c2.events.selectAll'))}">
<label class="c2-event-check-label" title="${escapeAttr(c2t('c2.events.selectAll'))}">
<input type="checkbox" id="c2-events-select-all" onchange="C2.onEventsSelectAll(this.checked)">
</label>
</th>
@@ -4227,8 +4304,10 @@
</tr>
</thead>
<tbody>
${C2.events.map(e => {
const eid = escapeHtml(e.id || '');
${C2.events.map(e => {
const rawId = e.id || '';
const eid = escapeHtml(rawId);
const eidAttr = escapeAttr(rawId);
const levelCls = eventLevelBadgeClass(e.level);
const catCls = eventCategoryBadgeClass(e.category);
const sessionShort = e.sessionId ? escapeHtml(String(e.sessionId).substring(0, 10)) + (String(e.sessionId).length > 10 ? '\u2026' : '') : '';
@@ -4236,20 +4315,20 @@
const msg = escapeHtml(e.message || '');
const rowLevel = escapeHtml(e.level || 'info');
return `
<tr class="c2-events-row c2-events-row--${rowLevel}" data-event-id="${eid}" onclick="C2.viewEvent('${eid}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();C2.viewEvent('${eid}')}" role="button" tabindex="0">
<td class="c2-events-table-col-check" onclick="event.stopPropagation();">
<tr class="c2-events-row c2-events-row--${rowLevel}" data-event-id="${eidAttr}" data-c2-action="event-view" data-c2-id="${eidAttr}" role="button" tabindex="0">
<td class="c2-events-table-col-check" data-c2-stop-action="1">
<label class="c2-event-check-label">
<input type="checkbox" class="c2-event-check" data-id="${eid}" onchange="C2.syncEventsToolbar()">
<input type="checkbox" class="c2-event-check" data-id="${eidAttr}" onchange="C2.syncEventsToolbar()">
</label>
</td>
<td class="c2-events-col-time">${escapeHtml(formatTime(e.createdAt))}</td>
<td><span class="c2-event-level-badge ${levelCls}">${escapeHtml(eventLevelLabel(e.level))}</span></td>
<td><span class="${catCls}">${escapeHtml(eventCategoryLabel(e.category))}</span></td>
<td class="c2-events-col-message" title="${msg}">${msg || dash}</td>
<td class="c2-events-col-mono" title="${escapeHtml(e.sessionId || '')}">${sessionShort || dash}</td>
<td class="c2-events-col-mono" title="${escapeHtml(e.taskId || '')}">${taskShort || dash}</td>
<td class="c2-events-table-col-actions" onclick="event.stopPropagation();">
<button type="button" class="c2-events-delete-btn" data-require-permission="c2:delete" onclick="C2.deleteEventById('${eid}')" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
<td class="c2-events-col-message" title="${escapeAttr(e.message || '')}">${msg || dash}</td>
<td class="c2-events-col-mono" title="${escapeAttr(e.sessionId || '')}">${sessionShort || dash}</td>
<td class="c2-events-col-mono" title="${escapeAttr(e.taskId || '')}">${taskShort || dash}</td>
<td class="c2-events-table-col-actions" data-c2-stop-action="1">
<button type="button" class="c2-events-delete-btn" data-require-permission="c2:delete" data-c2-action="event-delete" data-c2-id="${eidAttr}" title="${delTitle}" aria-label="${delTitle}">${deleteIcon}</button>
</td>
</tr>`;
}).join('')}
@@ -4364,7 +4443,7 @@
<div class="c2-profile-card">
<div class="c2-profile-header">
<h4>${escapeHtml(p.name)}</h4>
<button class="btn-danger btn-sm" data-require-permission="c2:delete" onclick="C2.deleteProfile('${p.id}')">${escapeHtml(c2t('common.delete'))}</button>
<button class="btn-danger btn-sm" data-require-permission="c2:delete" data-c2-action="profile-delete" data-c2-id="${escapeAttr(p.id)}">${escapeHtml(c2t('common.delete'))}</button>
</div>
<div class="c2-profile-info">
<div><strong>UA:</strong> ${escapeHtml(p.userAgent || defVal)}</div>
@@ -4389,7 +4468,7 @@
<div class="c2-modal-body">
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.profiles.profileNameLabel'))}</label>
<input type="text" id="c2-profile-name" class="form-control" placeholder="${escapeHtml(c2t('c2.profiles.placeholderProfileName'))}">
<input type="text" id="c2-profile-name" class="form-control" placeholder="${escapeAttr(c2t('c2.profiles.placeholderProfileName'))}">
</div>
<div class="c2-form-group">
<label>${escapeHtml(c2t('c2.profiles.userAgent'))}</label>
+51
View File
@@ -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/);
});
+7 -3
View File
@@ -13,6 +13,10 @@ let chatFilesPage = 1;
let chatFilesPageSize = 20;
let chatFilesSearchDebounceTimer = null;
function chatFilesEscapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
const CHAT_FILES_GROUP_STORAGE_KEY = 'csai_chat_files_group_by';
const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path';
const CHAT_FILES_PAGE_SIZE_STORAGE_KEY = 'csai_chat_files_page_size';
@@ -1077,7 +1081,7 @@ function renderChatFilesTable() {
<td>${escapeHtml(f.date || '—')}</td>
<td class="chat-files-cell-conv"><code title="${convTitleEsc}">${convEsc}</code></td>
<td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td>
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}${sourceBadge}</td>
<td class="chat-files-cell-name" title="${chatFilesEscapeAttr(pathForTitle)}">${nameEsc}${sourceBadge}</td>
<td>${formatChatFileBytes(f.size || 0)}</td>
<td>${escapeHtml(dt)}</td>
<td class="chat-files-actions">
@@ -1178,7 +1182,7 @@ function renderChatFilesTable() {
? `<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>`
: '';
return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)">
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${escapeHtml(folderTitle)}">
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${chatFilesEscapeAttr(folderTitle)}">
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(folderDisplay.text)}</span></span>
</td>
<td class="chat-files-tree-muted"></td>
@@ -1228,7 +1232,7 @@ function renderChatFilesTable() {
const menuHtml = menuParts.join('');
return `<tr class="chat-files-tr-file">
<td class="chat-files-tree-name-cell" title="${escapeHtml(pathForTitle)}">
<td class="chat-files-tree-name-cell" title="${chatFilesEscapeAttr(pathForTitle)}">
<span class="chat-files-tree-name-inner">${svgFile}<span class="chat-files-tree-name-text">${nameEsc}${sourceBadge}</span></span>
</td>
<td>${formatChatFileBytes(f.size || 0)}</td>
@@ -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);
});
+316
View File
@@ -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);
+74
View File
@@ -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/);
});
+398
View File
@@ -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
View File
@@ -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;
+1715 -467
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
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 projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
const template = 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);
}
test('全局置顶检查接口结果并即时通知项目文件夹', () => {
const source = functionSource(chat, 'pinConversation', 'showMoveToGroupSubmenu');
assert.match(source, /assertConversationActionResponse\(updateResponse, '更新置顶状态失败'\)/);
assert.match(source, /notifyConversationPinnedChanged\(convId, newPinned\)/);
assert.match(source, /loadConversationsWithGroups\(\)/);
});
test('项目文件夹内置顶对话优先排序并显示图钉', () => {
const sortSource = functionSource(projects, 'sortProjectFolderConversations', 'updateChatProjectConversationPinnedState');
const itemSource = functionSource(projects, 'appendChatProjectConversationItem', 'selectChatProjectConversationItem');
assert.match(sortSource, /Number\(!!b\?\.pinned\) - Number\(!!a\?\.pinned\)/);
assert.match(itemSource, /if \(conversation\.pinned\)/);
assert.match(itemSource, /project-conversation-pinned/);
});
test('删除事件立即移除项目缓存并触发权威刷新', () => {
const removeSource = functionSource(projects, 'removeChatProjectConversation', 'refreshChatProjectFoldersAfterAction');
assert.match(removeSource, /chatProjectFolderContext\.conversations = chatProjectFolderContext\.conversations\.filter/);
assert.match(projects, /document\.addEventListener\('conversation-deleted',[\s\S]{0,300}removeChatProjectConversation\(conversationId\)[\s\S]{0,180}refreshChatProjectFoldersAfterAction\(\)/);
assert.match(chat, /document\.dispatchEvent\(new CustomEvent\('conversation-deleted'/);
});
test('较旧的项目文件夹请求不能覆盖较新的操作结果', () => {
const source = functionSource(projects, 'loadChatProjectFolderContext', 'getProjectConversationSortTime');
assert.match(source, /const loadSeq = \+\+chatProjectFolderContextLoadSeq/);
assert.match(source, /if \(loadSeq !== chatProjectFolderContextLoadSeq\) return false/);
});
test('项目文件夹菜单可以置顶并立即更新排序', () => {
const toggleSource = functionSource(projects, 'toggleProjectPinnedFromListMenu', 'initProjectListActionMenu');
const folderSource = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
assert.match(template, /onclick="toggleProjectPinnedFromListMenu\(\)"/);
assert.match(toggleSource, /JSON\.stringify\(\{ pinned: nextPinned \}\)/);
assert.match(toggleSource, /updateCachedProjectPinnedState\(projectId, nextPinned\)/);
assert.match(folderSource, /if \(!isUnassigned && project\.pinned\)/);
assert.match(folderSource, /project-folder-pinned/);
assert.match(projects, /\[\.\.\.pinnedProjects, unassignedProject, \.\.\.regularProjects\]/);
});
test('对话侧栏不再显示对话分组区域', () => {
assert.doesNotMatch(template, /class="conversation-groups-section"/);
assert.doesNotMatch(template, /id="conversation-groups-list"/);
});
test('删除对话分组检查接口结果并先清理本地状态', () => {
const deleteSource = functionSource(chat, 'deleteConversationGroupById', 'deleteGroup');
const contextSource = functionSource(chat, 'deleteGroupFromContext', 'closeGroupContextMenu');
assert.match(deleteSource, /assertConversationActionResponse\(deleteResponse, '删除分组失败'\)/);
assert.match(deleteSource, /removeConversationGroupFromLocalState\(groupId\)/);
assert.match(deleteSource, /if \(currentGroupId === groupId\) exitGroupDetail\(\)/);
assert.match(contextSource, /deleteConversationGroupById\(groupId, \{ closeContextMenu: true \}\)/);
});
+328
View File
@@ -0,0 +1,328 @@
const fs = require('node:fs');
const vm = require('node:vm');
const test = require('node:test');
const assert = require('node:assert/strict');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const chatScroll = fs.readFileSync('web/static/js/chat-scroll.js', 'utf8');
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
const template = fs.readFileSync('web/templates/index.html', 'utf8');
const handler = fs.readFileSync('internal/handler/hitl.go', '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.match(template, /id="chat-hitl-approval-dock"/);
assert.match(template, /id="hitl-timeout-select"/);
assert.match(template, /option value="300" selected/);
assert.match(chat, /DEFAULT_HITL_TIMEOUT_SECONDS = 300/);
assert.match(chat, /timeoutSeconds: normalizeHitlTimeoutForChat/);
assert.match(chat, /body\.hitl = \{[\s\S]*?timeoutSeconds: normalizeHitlTimeoutForChat\(hitlCfg\.timeoutSeconds/);
});
test('输入框可直接保存系统模型和系统推理强度且审批模型只出现在审计 Agent 入口', () => {
assert.match(chat, /function currentSystemModelLabel\(\)/);
assert.match(chat, /chatDefaultAIChannel \? chatAIChannels\[chatDefaultAIChannel\]/);
assert.match(chat, /function currentHitlAuditModelLabel\(\)/);
assert.match(chat, /const label = currentSystemModelLabel\(\)/);
assert.doesNotMatch(chat, /const label = data\.model \|\| currentChatModelLabel\(\)/);
assert.match(chat, /const approvalModel = auditAgent \? currentHitlAuditModelLabel\(\) : ''/);
assert.match(chat, /hitlAuditModel\.model\.trim\(\)/);
assert.match(template, /id="chat-model-shortcut"[^>]+onclick="openChatSystemModelPicker\(event\)"/);
assert.match(template, /id="chat-system-model-menu"[^>]+hidden/);
assert.doesNotMatch(template, /id="chat-reasoning-shortcut"/);
assert.match(template, /openChatSystemModelView\('model', event\)[\s\S]{0,1200}openChatSystemModelView\('effort', event\)/);
assert.match(chat, /function renderChatReasoningEffortOptions\(\)/);
assert.match(chat, /function currentSystemReasoningEffort\(\)[\s\S]{0,500}reasoning\.effort/);
assert.match(chat, /case 'low': return 'low'[\s\S]{0,300}case 'max': return 'max'/);
assert.match(chat, /chatTranslate\('chat\.reasoningEffortUnset', '不指定'\)/);
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,2400}reasoning: \{ \.\.\.\(state\.channel\.reasoning \|\| \{\}\), effort: chosen \}/);
assert.match(chat, /function selectChatReasoningEffort\(effort\)[\s\S]{0,4200}body: JSON\.stringify\(\{ ai: state\.ai \}\)[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/);
assert.match(chat, /function openChatSystemModelPicker\(event\)[\s\S]{0,4200}apiFetch\('\/api\/config\/list-models'/);
assert.match(chat, /function selectChatSystemModel\(model\)[\s\S]{0,2600}method: 'PUT'[\s\S]{0,900}apiFetch\('\/api\/config\/apply'/);
assert.match(chat, /body: JSON\.stringify\(\{ ai: state\.ai \}\)/);
assert.equal(zh.chat.modelSettingsAria, '选择模型与推理强度');
assert.equal(en.chat.modelSettingsAria, 'Choose model and reasoning effort');
});
test('审批请求按浏览器、命令、文件和通用工具动态描述', () => {
assert.match(monitor, /function hitlApprovalTemplate/);
assert.match(monitor, /hitlApprovalTranslate\(key, fallback\)/);
assert.match(monitor, /replaceAll\('\{\{' \+ name \+ '\}\}'/);
assert.match(monitor, /function describeHitlApprovalRequest/);
assert.match(monitor, /requestVisitUrl/);
assert.match(monitor, /requestCommand/);
assert.match(monitor, /requestFile/);
assert.match(monitor, /requestGeneric/);
assert.match(monitor, /let displayTool = rawToolName/);
assert.doesNotMatch(monitor, /displayTool = 'Browser'/);
assert.doesNotMatch(monitor, /displayTool = hitlApprovalTranslate\('hitl\.toolTerminal'/);
assert.doesNotMatch(monitor, /displayTool = hitlApprovalTranslate\('hitl\.toolFiles'/);
});
test('Agent 审查不进入人工审批弹窗、倒计时和项目计数', () => {
const logsHandler = fs.readFileSync('internal/handler/hitl_logs.go', 'utf8');
const hitlPage = fs.readFileSync('web/static/js/hitl.js', 'utf8');
assert.match(handler, /CreatePendingInterrupt\([\s\S]{0,260}reviewer string/);
assert.match(handler, /reviewer != "audit_agent"[\s\S]{0,120}m\.pending\[id\] = p/);
assert.match(logsHandler, /ADD COLUMN reviewer TEXT NOT NULL DEFAULT 'human'/);
assert.match(logsHandler, /status = 'pending' AND COALESCE\(reviewer,'human'\) = 'human'/);
assert.match(monitor, /function isAgentReviewedHitl\(data\)/);
assert.match(monitor, /if \(!data\.resolved && !isAgentReviewedHitl\(data\)\)/);
assert.match(monitor, /if \(!isAgentReviewedHitl\(data\)\) \{[\s\S]{0,240}bindHitlApprovalCountdown/);
assert.match(monitor, /if \(isAgentReviewedHitl\(data\)\) return false/);
assert.match(projects, /filter\(isHumanProjectPendingApproval\)/);
assert.match(projects, /if \(!isHumanProjectPendingApproval\(details\)\) return/);
assert.match(hitlPage, /const items = rawItems\.filter/);
});
test('人工批准不要求输入备注,审查编辑仅发送真正修改过的参数', () => {
assert.match(monitor, /if \(!approveBtn \|\| !rejectBtn \|\| !statusEl\) return/);
assert.doesNotMatch(monitor, /!commentInput \|\| !statusEl/);
assert.match(monitor, /JSON\.stringify\(editedArgs\) === JSON\.stringify\(originalArgs\)/);
assert.match(monitor, /editedArgs = null/);
});
test('长历史对话的回到最新按钮不会把滚动点击穿透到审批操作', () => {
assert.match(chatScroll, /function isolateReturnLatestPointerEvent\(event\)/);
assert.match(chatScroll, /returnLatestButton\.addEventListener\('pointerdown', isolateReturnLatestPointerEvent\)/);
assert.match(chatScroll, /function onReturnLatestClick\(event\)[\s\S]{0,260}event\.preventDefault\(\)[\s\S]{0,180}event\.stopPropagation\(\)/);
assert.match(monitor, /const bindExplicitHitlAction = function \(button, decision\)/);
assert.match(monitor, /button\.addEventListener\('pointerdown'[\s\S]{0,900}pointerClick && !explicitlyPressed/);
assert.match(monitor, /bindExplicitHitlAction\(approveBtn, 'approve'\)/);
assert.match(monitor, /bindExplicitHitlAction\(rejectBtn, 'reject'\)/);
});
test('轮次导航使用连续大热区并允许鼠标平滑进入 Codex 风格预览卡', () => {
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,260}gap: 0;/);
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,420}overflow-x: hidden;/);
assert.match(styles, /\.chat-turn-rail-markers \{[\s\S]{0,520}touch-action: pan-y;/);
assert.match(styles, /\.chat-turn-rail-marker \{[\s\S]{0,260}width: 36px;[\s\S]{0,160}height: 11px;/);
assert.match(styles, /\.chat-turn-rail-marker::before \{[\s\S]{0,420}width: 12px;[\s\S]{0,120}height: 3px;/);
assert.match(styles, /\.chat-turn-rail-marker:hover::before \{[\s\S]{0,100}width: 22px;/);
assert.match(styles, /\.chat-turn-rail-preview \{[\s\S]{0,520}pointer-events: auto;/);
assert.match(chatScroll, /function scheduleHideTurnPreview\(\)/);
assert.match(chatScroll, /window\.setTimeout\(hideTurnPreview, 160\)/);
assert.match(chatScroll, /turnPreview\.addEventListener\('mouseenter'/);
assert.match(chatScroll, /marker\.addEventListener\('mouseleave', scheduleHideTurnPreview\)/);
});
test('倒计时由服务端时间驱动,到期时只锁定界面并等待服务端拒绝', () => {
assert.match(handler, /payload\["hitlApproval"\]/);
assert.match(handler, /"expiresAt":\s+approvalExpiresAt/);
assert.match(handler, /status = "timeout"/);
assert.match(handler, /decidedBy = "system"/);
assert.match(monitor, /function bindHitlApprovalCountdown/);
assert.match(monitor, /setInterval\(update, 250\)/);
assert.match(monitor, /expiredAutoRejected/);
assert.doesNotMatch(monitor, /remaining <= 0[\s\S]{0,240}submitHitlDecisionWithPayload/);
});
test('项目对话列表能同时显示等待批准与运行状态', () => {
assert.match(projects, /pendingApprovalByConversation: new Map/);
assert.match(projects, /statusKinds\.push\('approval'\)/);
assert.match(projects, /statusKinds\.push\('running'\)/);
assert.match(projects, /window\.setProjectConversationApprovalStatus/);
assert.match(projects, /api\/hitl\/pending\?page=1&pageSize=200/);
assert.match(projects, /function bindProjectApprovalProgress/);
assert.match(projects, /project-approval-progress-value/);
assert.match(projects, /PROJECT_APPROVAL_TICK_INTERVAL_MS = 1000/);
assert.match(projects, /function registerProjectApprovalTicker/);
assert.match(monitor, /function renderDirectHitlSidebarApproval/);
assert.match(monitor, /hitlSidebarApprovalSyncTimer = window\.setInterval/);
});
test('项目文件夹汇总始终为绿色且只有具体对话按剩余时间变色', () => {
assert.match(projects, /waitingApprovalCount/);
assert.match(projects, /aggregate: true, count: folderApprovals\.length/);
assert.match(projects, /project-task-status--approval-summary', 'is-urgency-normal'/);
assert.match(projects, /status\.dataset\.approvalUrgency = 'normal'/);
assert.match(projects, /if \(isApprovalSummary\)[\s\S]{0,520}else \{[\s\S]{0,160}bindProjectApprovalUrgency\(status, details, label\)/);
assert.doesNotMatch(projects, /currentExpiry < earliestExpiry/);
assert.match(projects, /PROJECT_APPROVAL_URGENCY_CLASSES/);
assert.match(projects, /remaining <= 60 \* 1000/);
assert.match(projects, /remaining <= 3 \* 60 \* 1000/);
assert.doesNotMatch(projects, /remaining <= 5 \* 60 \* 1000/);
assert.match(projects, /project-task-status--approval-summary/);
assert.equal(zh.hitl.waitingApprovalCount, '等待批准 {{count}}');
assert.equal(zh.hitl.approvalUrgencyMoreThanThree, '最早审批将在 3 分钟后到期');
assert.equal(typeof en.hitl.waitingApprovalCount, 'string');
const urgencyFunctionSource = projects.match(
/function projectApprovalUrgencyLevel\(remainingMilliseconds, hasDeadline\) \{[\s\S]*?\n\}/
);
assert.ok(urgencyFunctionSource, '应提供可测试的审批紧急程度函数');
const urgencyLevel = vm.runInNewContext(`(${urgencyFunctionSource[0]})`);
assert.equal(urgencyLevel(6 * 60 * 1000, true), 'normal');
assert.equal(urgencyLevel(4 * 60 * 1000, true), 'normal');
assert.equal(urgencyLevel(3 * 60 * 1000 + 1, true), 'normal');
assert.equal(urgencyLevel(3 * 60 * 1000, true), 'warning');
assert.equal(urgencyLevel(2 * 60 * 1000, true), 'warning');
assert.equal(urgencyLevel(30 * 1000, true), 'critical');
assert.equal(urgencyLevel(0, false), 'normal');
});
test('切换对话后主按钮只读取当前可见对话的运行状态', () => {
assert.match(chat, /function getVisibleChatConversationId\(\)/);
assert.match(chat, /function shouldTreatLiveChatTaskAsCurrent\(/);
assert.match(chat, /function isLiveChatTaskVisible\(/);
assert.match(chat, /if \(visibleConversationId\) return visibleConversationId/);
assert.match(chat, /isConversationTaskRunning\(visibleConversationId\)/);
assert.doesNotMatch(
chat,
/function getCurrentChatTaskConversationId\(\) \{[\s\S]{0,220}if \(live && live\.active && live\.conversationId\) \{[\s\S]{0,100}return String\(live\.conversationId\)/
);
const visibilityFunctionSource = chat.match(
/function shouldTreatLiveChatTaskAsCurrent\(liveConversationId, visibleConversationId, hasVisibleProgress\) \{[\s\S]*?\n\}/
);
assert.ok(visibilityFunctionSource, '应提供可测试的当前任务隔离函数');
const isCurrent = vm.runInNewContext(`(${visibilityFunctionSource[0]})`);
assert.equal(isCurrent('running-conversation', '', true), false);
assert.equal(isCurrent('running-conversation', 'new-conversation', true), false);
assert.equal(isCurrent('running-conversation', 'running-conversation', false), true);
assert.equal(isCurrent('', '', true), true);
assert.equal(isCurrent('', '', false), false);
});
test('无项目使用独立虚拟文件夹且顶部新任务继承当前项目', () => {
assert.match(projects, /CHAT_UNASSIGNED_PROJECT_FOLDER_ID/);
assert.match(projects, /_isUnassigned: true/);
assert.match(projects, /\[\.\.\.pinnedProjects, unassignedProject, \.\.\.regularProjects\]/);
assert.match(projects, /window\.startNewConversation\(\{ projectId: isUnassigned \? '' : project\.id \}\)/);
assert.match(chat, /Object\.prototype\.hasOwnProperty\.call\(options, 'projectId'\)/);
assert.match(chat, /typeof resolveChatProjectSelection === 'function'/);
assert.match(chat, /String\(inheritedProjectId \|\| ''\)\.trim\(\)/);
assert.match(chat, /typeof setActiveProjectId === 'function'\) setActiveProjectId\(requestedProjectId\)/);
assert.equal(zh.chat.newUnassignedConversation, '新建无项目对话');
assert.equal(typeof en.chat.newUnassignedConversation, 'string');
});
test('单个对话的审批徽标随倒计时同步切换紧急颜色', () => {
assert.match(projects, /bindProjectApprovalProgress\(status, details\);\s*bindProjectApprovalUrgency\(status, details, label\);/);
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /\.project-task-status--approval\.is-urgency-critical/);
});
test('项目状态刷新复用单一计时器且切换对话不重复请求完整项目上下文', () => {
assert.match(projects, /const projectApprovalTickerEntries = new Set\(\)/);
assert.match(projects, /if \(!changed && !approvalChanged\) return/);
assert.match(projects, /options\.reloadFolders !== false/);
assert.match(chat, /refreshChatProjectSelector\(\{ reloadFolders: false, renderFolders: false \}\)/);
assert.match(projects, /function selectChatProjectConversationItem/);
assert.match(projects, /options\.renderFolders !== false/);
assert.match(projects, /projectConversationPreviewSuppressedUntil = Date\.now\(\) \+ 700/);
assert.match(projects, /project-task-status-group--folder/);
assert.doesNotMatch(fs.readFileSync('web/static/css/style.css', 'utf8'), /project-task-status-group--folder \.project-task-status--running/);
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /\.active-tasks-bar \{[\s\S]*?padding: 13px 24px 14px;/);
});
test('运行中对话切换会取消旧事件流并仅恢复最新一页过程详情', () => {
assert.match(chat, /window\.cancelRunningTaskEventStream\(conversationId\)/);
assert.match(monitor, /function cancelRunningTaskEventStream/);
assert.match(monitor, /abortController\.abort\(\)/);
assert.match(monitor, /signal: abortController\.signal/);
assert.match(monitor, /initialLatest: true/);
assert.match(monitor, /autoLoadAll: false/);
});
test('多对话并发时释放隐藏主流且旧请求不能覆盖新对话状态', () => {
assert.match(chat, /function ownsLiveChatStream\(liveStream\)/);
assert.match(chat, /function clearLiveChatStreamIfOwned\(liveStream\)/);
assert.match(chat, /function detachLiveChatStreamForNavigation\(nextConversationId, force = false\)/);
assert.match(chat, /liveStream\.detached = true;[\s\S]{0,240}controller\.abort\(\)/);
assert.match(chat, /const requestAbortController = new AbortController\(\)/);
assert.match(chat, /signal: requestAbortController\.signal/);
assert.match(chat, /if \(!ownsLiveChatStream\(liveStreamState\) \|\| liveStreamState\.detached\)/);
assert.match(chat, /const clearedOwnedStream = clearLiveChatStreamIfOwned\(liveStreamState\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\(conversationId\)/);
assert.match(chat, /detachLiveChatStreamForNavigation\('', true\)/);
assert.match(chat, /window\.clearChatHitlApprovalDock\(\)/);
assert.match(monitor, /if \(conversationId && conversationId !== currentId\) return false/);
assert.match(monitor, /function scrollProcessDetailsToLatest\(assistantMessageId, smooth = true\)/);
assert.match(monitor, /timeline\.scrollTop = targetTop/);
assert.match(chat, /let loadConversationAbortController = null/);
assert.match(chat, /cancelPendingConversationLoad\(\);[\s\S]{0,220}const conversationLoadController = new AbortController\(\)/);
assert.match(chat, /signal: conversationLoadController\.signal/);
assert.match(template, /monitor\.js\?v=20260813-9/);
assert.match(template, /chat-scroll\.js\?v=20260813-6/);
assert.match(template, /chat\.js\?v=20260813-4/);
assert.match(template, /style\.css\?v=20260813-6/);
});
test('输入区 Agent 审查文字保留足够行高且不会裁切字形', () => {
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?display: block/);
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?padding-block: 1px/);
assert.match(styles, /\.chat-hitl-shortcut > span\s*\{[\s\S]*?line-height: 1\.4/);
});
test('任务结束后对话内审批按钮会变灰并禁止继续操作', () => {
assert.match(monitor, /ready: false/);
assert.match(monitor, /function setHitlApprovalTaskAvailability/);
assert.match(monitor, /conversationExecutionTracker\.ready && !conversationExecutionTracker\.isRunning\(id\)/);
assert.match(monitor, /hitlPendingInterruptTracker\.ready/);
assert.match(monitor, /!hitlPendingInterruptTracker\.has\(interruptId\)/);
assert.match(monitor, /button\.disabled = true/);
assert.match(monitor, /function setHitlApprovalInterruptedVisualState/);
assert.match(monitor, /stopHitlApprovalCountdown\(panel\)/);
assert.match(monitor, /removeAttribute\('data-hitl-expires-at'\)/);
assert.match(monitor, /hitl\.interruptedApprovalCancelled/);
assert.match(monitor, /reconcileHitlApprovalStateWithActiveTasks\(normalizedTasks\)/);
assert.match(monitor, /syncHitlApprovalTaskAvailability\(\)/);
assert.match(fs.readFileSync('web/static/css/style.css', 'utf8'), /hitl-approval-task-closed/);
assert.equal(zh.hitl.taskClosedApprovalUnavailable, '任务已结束,审批不可用');
assert.equal(zh.hitl.interruptedApprovalCancelled, '任务已中断,审批已取消');
assert.equal(typeof en.hitl.taskClosedApprovalUnavailable, 'string');
assert.equal(typeof en.hitl.interruptedApprovalCancelled, 'string');
});
test('项目树只保留当前进程仍在运行任务的审批状态', () => {
assert.match(projects, /chatProjectFolderContext\.runningIds\.has\(conversationId\)/);
assert.match(projects, /pendingApprovalByConversation\.delete\(conversationId\)/);
assert.match(monitor, /conversationExecutionTracker\.ready && !conversationExecutionTracker\.isRunning\(conversationId\)/);
});
test('审批状态主动轮询并在服务不可用时立即关闭旧审批', () => {
assert.match(monitor, /ACTIVE_TASK_REFRESH_INTERVAL = 2000/);
assert.match(monitor, /apiFetch\('\/api\/hitl\/pending\?page=1&pageSize=200'\)/);
assert.match(monitor, /function reconcilePendingHitlState\(rawItems\)/);
assert.match(monitor, /renderChatHitlApprovalDock\(currentPending\)/);
assert.match(monitor, /restoreHitlInlineForConversation\(currentId\)/);
assert.match(monitor, /case 'conversation':[\s\S]{0,1800}window\.refreshChatProjectFolders\(\)/);
assert.match(monitor, /renderActiveTasks\(\[\]\);[\s\S]{0,260}hitlPendingInterruptTracker\.update\(\[\]\)/);
assert.match(projects, /function syncProjectConversationApprovalStatuses\(items\)/);
assert.match(projects, /window\.syncProjectConversationApprovalStatuses/);
assert.match(template, /projects\.js\?v=20260812-6/);
});
test('旧会话首次升级到五分钟默认审批时限,仍允许用户之后主动选择不限时', () => {
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX/);
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /shouldMigrateLegacyHitlTimeout/);
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /timeoutSeconds: 300/);
assert.match(fs.readFileSync('web/static/js/hitl.js', 'utf8'), /markLegacyHitlTimeoutMigrated/);
});
test('审批体验文案具有完整中英文资源', () => {
const hitlKeys = [
'waitingApprovalShort',
'requestVisitUrl',
'requestCommand',
'viewRequestDetails',
'timeoutAutoReject',
'expiredRejected',
];
const chatKeys = [
'hitlTimeoutLabel',
'hitlTimeoutFiveMinutes',
'hitlTimeoutUnlimited',
'hitlTimeoutHint',
];
hitlKeys.forEach((key) => {
assert.equal(typeof zh.hitl[key], 'string');
assert.equal(typeof en.hitl[key], 'string');
});
chatKeys.forEach((key) => {
assert.equal(typeof zh.chat[key], 'string');
assert.equal(typeof en.chat[key], 'string');
});
});
@@ -0,0 +1,45 @@
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 hitl = fs.readFileSync('web/static/js/hitl.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);
}
test('已有会话缺少本地配置时不会继承其他会话的最近审批设置', () => {
const source = functionSource(chat, 'getHitlConfigForConversation', 'setHitlReviewerUI');
const existingConversationBranch = source.slice(source.indexOf('const key = getHitlStorageKeyByConversation(cid)'));
assert.doesNotMatch(existingConversationBranch, /getHitlLastGlobalConfig/);
assert.match(existingConversationBranch, /if \(!raw\) \{\s*return fallback;/);
assert.match(existingConversationBranch, /catch \(e\) \{\s*return fallback;/);
});
test('服务端默认审批人只更新默认值,不覆盖最近会话选择', () => {
const source = functionSource(hitl, 'applyHitlDefaultReviewerFromServer', 'fetchHitlDefaultReviewer');
assert.match(source, /window\.csaiHitlDefaultReviewer = v/);
assert.doesNotMatch(source, /saveHitlLastGlobalConfig/);
});
test('恢复会话审批配置时保留该会话自己的审批人', () => {
const source = functionSource(hitl, 'syncHitlConfigFromServer', 'syncHitlConfigToServerByCurrentConversation');
assert.match(source, /const localReviewer = hitlReviewerNormalize\(local && local\.reviewer\)/);
assert.match(source, /merged = \{[\s\S]*?reviewer: localReviewer/);
assert.match(source, /saveHitlConversationConfig\(conversationId, \{[\s\S]*?reviewer: localReviewer/);
assert.doesNotMatch(source, /getHitlLastGlobalConfig/);
});
test('异步同步只能刷新仍处于当前会话的审批界面', () => {
const source = functionSource(hitl, 'syncHitlConfigFromServer', 'syncHitlConfigToServerByCurrentConversation');
assert.match(source, /getCurrentConversationIdForHitl\(\) === conversationId[\s\S]*?window\.applyHitlConfigToUI\(normalizedCfg\)/);
});
+48 -38
View File
@@ -98,6 +98,7 @@ function hitlT(key, fallback, params) {
const HITL_LOGS_PAGE_SIZE_KEY = 'cyberstrike_hitl_logs_page_size';
const HITL_PENDING_PAGE_SIZE_KEY = 'cyberstrike_hitl_pending_page_size';
const HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX = 'cyberstrike-hitl-timeout-default-v1:';
const HITL_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
function hitlPaginationT(key, opts, fallback) {
@@ -212,6 +213,21 @@ function normalizeHitlTimeoutSeconds(v, fallback) {
return 0;
}
function shouldMigrateLegacyHitlTimeout(conversationId, timeoutSeconds) {
if (!conversationId || normalizeHitlTimeoutSeconds(timeoutSeconds, 0) > 0) return false;
try {
return localStorage.getItem(HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX + conversationId) !== '1';
} catch (e) {
return false;
}
}
function markLegacyHitlTimeoutMigrated(conversationId) {
try {
localStorage.setItem(HITL_TIMEOUT_DEFAULT_MIGRATION_PREFIX + conversationId, '1');
} catch (e) { /* ignore */ }
}
function getCurrentConversationIdForHitl() {
if (typeof window.currentConversationId === 'string' && window.currentConversationId) {
return window.currentConversationId;
@@ -241,16 +257,6 @@ function applyHitlDefaultReviewerFromServer(reviewer) {
if (typeof window !== 'undefined') {
window.csaiHitlDefaultReviewer = v;
}
if (typeof window.saveHitlLastGlobalConfig === 'function' && typeof window.getHitlLastGlobalConfig === 'function') {
const gl = window.getHitlLastGlobalConfig();
const base = gl && typeof gl === 'object'
? gl
: { mode: 'off', sensitiveTools: '', updatedAt: '' };
window.saveHitlLastGlobalConfig(Object.assign({}, base, {
reviewer: v,
updatedAt: new Date().toISOString()
}));
}
return v;
}
@@ -352,9 +358,9 @@ function showHitlPageWhitelistFeedback(text, isError) {
el.className = 'hitl-apply-feedback' + (isError ? ' hitl-apply-feedback--error' : '');
}
function syncHitlSidebarWhitelistDisplay(toolsStr) {
const sidebarEl = document.getElementById('hitl-sensitive-tools');
if (sidebarEl) sidebarEl.value = toolsStr;
function syncHitlSidebarWhitelistDisplay(_toolsStr) {
// The chat field is conversation-scoped. Updating the global allowlist page
// must not replace it with a merged global + conversation display value.
}
async function fetchHitlGlobalToolWhitelist() {
@@ -534,45 +540,41 @@ async function syncHitlConfigFromServer(conversationId) {
const local = readHitlLocalStorageConv(conversationId);
const localMode = local && local.mode ? hitlModeNormalize(local.mode) : 'off';
if (localMode !== 'off') {
const localReviewer = hitlReviewerNormalize(local && local.reviewer);
let localToolsStr = typeof local.sensitiveTools === 'string' ? local.sensitiveTools : '';
localToolsStr = strip(globalWL, localToolsStr);
merged = {
enabled: true,
mode: localMode,
reviewer: localReviewer,
sensitiveTools: localToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
timeoutSeconds: normalizeHitlTimeoutSeconds(
local && local.timeoutSeconds,
normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
)
};
saveHitlConversationConfig(conversationId, {
mode: localMode,
reviewer: localReviewer,
sensitiveTools: localToolsStr,
enabled: true,
timeoutSeconds: merged.timeoutSeconds
}).catch(function (err) {
console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err);
});
} else {
const gl = typeof window.getHitlLastGlobalConfig === 'function' ? window.getHitlLastGlobalConfig() : null;
const glMode = gl && gl.mode ? hitlModeNormalize(gl.mode) : 'off';
if (glMode !== 'off') {
let glToolsStr = typeof gl.sensitiveTools === 'string' ? gl.sensitiveTools : '';
glToolsStr = strip(globalWL, glToolsStr);
merged = {
enabled: true,
mode: glMode,
sensitiveTools: glToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
};
saveHitlConversationConfig(conversationId, {
mode: glMode,
sensitiveTools: glToolsStr,
enabled: true,
timeoutSeconds: merged.timeoutSeconds
}).catch(function (err) {
console.warn('HITL 会话配置同步到服务器失败(将仅保留本地 UI):', err);
});
}
}
}
if (shouldMigrateLegacyHitlTimeout(conversationId, merged.timeoutSeconds)) {
merged = Object.assign({}, merged, { timeoutSeconds: 300 });
try {
await saveHitlConversationConfig(conversationId, merged);
markLegacyHitlTimeoutMigrated(conversationId);
} catch (err) {
console.warn('HITL 旧会话等待时限迁移失败,将在下次加载时重试:', err);
}
} else if (normalizeHitlTimeoutSeconds(merged.timeoutSeconds, 0) > 0) {
markLegacyHitlTimeoutMigrated(conversationId);
}
const uiMode = hitlEffectiveEnabled(merged) ? hitlModeNormalize(merged.mode) : 'off';
const rawArr = Array.isArray(merged.sensitiveTools)
? merged.sensitiveTools
@@ -590,7 +592,10 @@ async function syncHitlConfigFromServer(conversationId) {
localStorage.setItem('chat_hitl_config_' + conversationId, JSON.stringify(normalizedCfg));
} catch (e) {}
}
if (typeof window.applyHitlConfigToUI === 'function') {
if (
getCurrentConversationIdForHitl() === conversationId &&
typeof window.applyHitlConfigToUI === 'function'
) {
window.applyHitlConfigToUI(normalizedCfg);
}
reconcileHitlUiState();
@@ -835,7 +840,11 @@ async function refreshHitlPending() {
throw new Error('request failed');
}
const data = await resp.json();
const items = Array.isArray(data.items) ? data.items : [];
const rawItems = Array.isArray(data.items) ? data.items : [];
const items = rawItems.filter(function (item) {
return hitlReviewerNormalize(item && (item.reviewer || item.decidedBy || item.decided_by)) !== 'audit_agent' &&
String(item && item.status || '').trim().toLowerCase() !== 'audit_running';
});
let workflowRuns = [];
try {
const wfResp = await hitlApiFetch('/api/workflows/runs/pending', { credentials: 'same-origin' });
@@ -856,7 +865,8 @@ async function refreshHitlPending() {
return conv.indexOf(searchQ) >= 0 || wfId.indexOf(searchQ) >= 0 || runId.indexOf(searchQ) >= 0 || label.indexOf(searchQ) >= 0;
});
}
hitlPendingTotal = (typeof data.total === 'number' ? data.total : items.length) + workflowRuns.length;
const hiddenAgentItems = rawItems.length - items.length;
hitlPendingTotal = Math.max(0, (typeof data.total === 'number' ? data.total : rawItems.length) - hiddenAgentItems) + workflowRuns.length;
const maxPage = Math.max(1, Math.ceil(hitlPendingTotal / hitlPendingPageSize));
if (hitlPendingPage > maxPage) {
hitlPendingPage = maxPage;
+7 -3
View File
@@ -166,6 +166,10 @@ if (typeof escapeHtml === 'undefined') {
}
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function getFofaFormElements() {
return {
query: document.getElementById('fofa-query'),
@@ -1115,11 +1119,11 @@ function renderFofaResults(payload) {
if (f === 'host') {
const href = normalizeHttpLink(text);
if (href) {
const safeHref = escapeHtml(href);
return `<td class="info-collect-cell" data-field="${escapeHtml(f)}" data-full="${escapeHtml(text)}" title="${escapeHtml(text)}"><a class="info-collect-link" href="${safeHref}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation();">${escapeHtml(text)}</a></td>`;
const safeHref = escapeAttr(href);
return `<td class="info-collect-cell" data-field="${escapeAttr(f)}" data-full="${escapeAttr(text)}" title="${escapeAttr(text)}"><a class="info-collect-link" href="${safeHref}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation();">${escapeHtml(text)}</a></td>`;
}
}
return `<td class="info-collect-cell" data-field="${escapeHtml(f)}" data-full="${escapeHtml(text)}" title="${escapeHtml(text)}"><span class="info-collect-cell-text">${escapeHtml(text)}</span></td>`;
return `<td class="info-collect-cell" data-field="${escapeAttr(f)}" data-full="${escapeAttr(text)}" title="${escapeAttr(text)}"><span class="info-collect-cell-text">${escapeHtml(text)}</span></td>`;
}).join('');
const actionHtml = `
@@ -0,0 +1,31 @@
const fs = require('node:fs');
const test = require('node:test');
const assert = require('node:assert/strict');
const monitor = fs.readFileSync('web/static/js/monitor.js', 'utf8');
const styles = fs.readFileSync('web/static/css/style.css', '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);
}
test('主代理迭代节点获得可访问的分割线语义', () => {
const source = functionSource(monitor, 'addTimelineItem', 'loadActiveTasks');
assert.match(source, /if \(type === 'iteration'\)/);
assert.match(source, /if \(scope !== 'sub'\)/);
assert.match(source, /classList\.add\('timeline-iteration-divider'\)/);
assert.match(source, /setAttribute\('role', 'separator'\)/);
assert.match(source, /setAttribute\('aria-label', String\(options\.title \|\| ''\)\)/);
});
test('迭代分割线只在主对话时间线中使用轻量渐变横线', () => {
assert.match(styles, /\.timeline-item-iteration\.timeline-iteration-divider::after/);
assert.match(styles, /linear-gradient\(/);
assert.match(styles, /color-mix\(in srgb, var\(--border-color\) 88%, transparent\)/);
assert.match(styles, /@media \(max-width: 768px\)/);
});
+21 -10
View File
@@ -194,7 +194,7 @@ function renderKnowledgeItemsByCategories(categoriesWithItems) {
const categoryCount = categoryData.itemCount || categoryItems.length;
html += `
<div class="knowledge-category-section" data-category="${escapeHtml(category)}">
<div class="knowledge-category-section" data-category="${escapeAttr(category)}">
<div class="knowledge-category-header">
<div class="knowledge-category-info">
<h3 class="knowledge-category-title">${escapeHtml(category)}</h3>
@@ -245,7 +245,7 @@ function renderKnowledgeItems(items) {
const categoryCount = categoryItems.length;
html += `
<div class="knowledge-category-section" data-category="${escapeHtml(category)}">
<div class="knowledge-category-section" data-category="${escapeAttr(category)}">
<div class="knowledge-category-header">
<div class="knowledge-category-info">
<h3 class="knowledge-category-title">${escapeHtml(category)}</h3>
@@ -347,10 +347,10 @@ function renderKnowledgeItemCard(item) {
}
return `
<div class="knowledge-item-card" data-id="${item.id}" data-category="${escapeHtml(item.category)}">
<div class="knowledge-item-card" data-id="${escapeAttr(item.id)}" data-category="${escapeAttr(item.category)}">
<div class="knowledge-item-card-header">
<div class="knowledge-item-card-title-row">
<h4 class="knowledge-item-card-title" title="${escapeHtml(item.title)}">${escapeHtml(item.title)}</h4>
<h4 class="knowledge-item-card-title" title="${escapeAttr(item.title)}">${escapeHtml(item.title)}</h4>
<div class="knowledge-item-card-actions">
<button class="knowledge-item-action-btn" data-require-permission="knowledge:write" onclick="editKnowledgeItem('${item.id}')" title="编辑">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -1435,13 +1435,13 @@ function renderRetrievalLogs(logs) {
${log.conversationId ? `
<div class="retrieval-log-detail-item">
<span class="detail-label">${_t('retrievalLogs.conversationId')}</span>
<code class="detail-value" title="${_t('retrievalLogs.clickToCopy')}" data-copy-title-copied="${_t('common.copied')}" data-copy-title-click="${_t('retrievalLogs.clickToCopy')}" onclick="var t=this; navigator.clipboard.writeText('${escapeHtml(log.conversationId)}').then(function(){ t.title=t.getAttribute('data-copy-title-copied')||'Copied!'; setTimeout(function(){ t.title=t.getAttribute('data-copy-title-click')||'Click to copy'; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.conversationId)}</code>
<code class="detail-value" title="${escapeAttr(_t('retrievalLogs.clickToCopy'))}" data-copy-title-copied="${escapeAttr(_t('common.copied'))}" data-copy-title-click="${escapeAttr(_t('retrievalLogs.clickToCopy'))}" onclick="var t=this; navigator.clipboard.writeText(${escapeJsStringAttr(log.conversationId)}).then(function(){ t.title=t.getAttribute('data-copy-title-copied')||'Copied!'; setTimeout(function(){ t.title=t.getAttribute('data-copy-title-click')||'Click to copy'; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.conversationId)}</code>
</div>
` : ''}
${log.messageId ? `
<div class="retrieval-log-detail-item">
<span class="detail-label">${_t('retrievalLogs.messageId')}</span>
<code class="detail-value" title="${_t('retrievalLogs.clickToCopy')}" data-copy-title-copied="${_t('common.copied')}" data-copy-title-click="${_t('retrievalLogs.clickToCopy')}" onclick="var el=this; navigator.clipboard.writeText('${escapeHtml(log.messageId)}').then(function(){ el.title=el.getAttribute('data-copy-title-copied')||el.title; setTimeout(function(){ el.title=el.getAttribute('data-copy-title-click')||el.title; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.messageId)}</code>
<code class="detail-value" title="${escapeAttr(_t('retrievalLogs.clickToCopy'))}" data-copy-title-copied="${escapeAttr(_t('common.copied'))}" data-copy-title-click="${escapeAttr(_t('retrievalLogs.clickToCopy'))}" onclick="var el=this; navigator.clipboard.writeText(${escapeJsStringAttr(log.messageId)}).then(function(){ el.title=el.getAttribute('data-copy-title-copied')||el.title; setTimeout(function(){ el.title=el.getAttribute('data-copy-title-click')||el.title; }, 2000); });" style="cursor: pointer;">${escapeHtml(log.messageId)}</code>
</div>
` : ''}
<div class="retrieval-log-detail-item">
@@ -1470,7 +1470,7 @@ function renderRetrievalLogs(logs) {
</svg>
${_t('retrievalLogs.viewDetails')}
</button>
<button class="btn-secondary btn-sm retrieval-log-delete-btn" onclick="deleteRetrievalLog('${escapeHtml(log.id)}', ${index})" style="margin-top: 12px; margin-left: 8px; display: inline-flex; align-items: center; gap: 4px; color: var(--error-color, #dc3545); border-color: var(--error-color, #dc3545);" onmouseover="this.style.backgroundColor='rgba(220, 53, 69, 0.1)'; this.style.color='#dc3545';" onmouseout="this.style.backgroundColor=''; this.style.color='var(--error-color, #dc3545)';" title="${_t('common.delete')}">
<button class="btn-secondary btn-sm retrieval-log-delete-btn" onclick="deleteRetrievalLog(${escapeJsStringAttr(log.id)}, ${index})" style="margin-top: 12px; margin-left: 8px; display: inline-flex; align-items: center; gap: 4px; color: var(--error-color, #dc3545); border-color: var(--error-color, #dc3545);" onmouseover="this.style.backgroundColor='rgba(220, 53, 69, 0.1)'; this.style.color='#dc3545';" onmouseout="this.style.backgroundColor=''; this.style.color='var(--error-color, #dc3545)';" title="${escapeAttr(_t('common.delete'))}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
@@ -1910,7 +1910,7 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.conversationId')}</div>
<code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;"
onclick="navigator.clipboard.writeText('${escapeHtml(log.conversationId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
onclick="navigator.clipboard.writeText(${escapeJsStringAttr(log.conversationId)}); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
title="点击复制">${escapeHtml(log.conversationId)}</code>
</div>
` : ''}
@@ -1918,7 +1918,7 @@ function showRetrievalLogDetailsModal(log, retrievedItems) {
<div style="padding: 12px; background: var(--bg-secondary); border-radius: 6px;">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px;">${_t('retrievalLogs.messageId')}</div>
<code style="font-size: 0.8125rem; color: var(--text-primary); word-break: break-all; cursor: pointer;"
onclick="navigator.clipboard.writeText('${escapeHtml(log.messageId)}'); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
onclick="navigator.clipboard.writeText(${escapeJsStringAttr(log.messageId)}); this.title='已复制!'; setTimeout(() => this.title='点击复制', 2000);"
title="点击复制">${escapeHtml(log.messageId)}</code>
</div>
` : ''}
@@ -2006,6 +2006,18 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
function formatTime(timeStr) {
if (!timeStr) return '';
@@ -2319,4 +2331,3 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
});
+1762 -262
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
const fs = require('node:fs');
const test = require('node:test');
const assert = require('node:assert/strict');
const projects = fs.readFileSync('web/static/js/projects.js', 'utf8');
const styles = fs.readFileSync('web/static/css/style.css', 'utf8');
const chat = fs.readFileSync('web/static/js/chat.js', 'utf8');
const html = fs.readFileSync('web/templates/index.html', 'utf8');
const rbac = fs.readFileSync('web/static/js/rbac-guards.js', 'utf8');
const zh = fs.readFileSync('web/static/i18n/zh-CN.json', 'utf8');
const en = fs.readFileSync('web/static/i18n/en-US.json', '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);
}
test('无项目文件夹与普通项目共用悬浮和键盘聚焦预览', () => {
const source = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
assert.match(source, /row\.addEventListener\('mouseenter', \(\) => scheduleShowProjectFolderPreview/);
assert.match(source, /button\.addEventListener\('focus', \(\) => scheduleShowProjectFolderPreview/);
assert.doesNotMatch(
source,
/if \(!isUnassigned\) \{\s*row\.addEventListener\('mouseenter', \(\) => scheduleShowProjectFolderPreview/
);
});
test('无项目预览隐藏测试范围和编辑入口', () => {
const source = functionSource(projects, 'showProjectFolderPreview', 'scheduleShowProjectFolderPreview');
assert.match(source, /preview\.classList\.toggle\('is-unassigned', isUnassigned\)/);
assert.match(source, /scopeRow\.hidden = isUnassigned \|\| !scope/);
assert.match(source, /editButton\.hidden = isUnassigned/);
assert.match(styles, /\.project-folder-preview\.is-unassigned \.project-folder-preview-edit\s*\{\s*display: none !important;/);
assert.match(styles, /\.project-folder-preview\.is-unassigned \.project-folder-preview-details\s*\{\s*border-bottom: 0;/);
});
test('项目标题提供受权限保护的新建项目入口', () => {
const source = functionSource(projects, 'showNewProjectModalFromChatSidebar', 'saveProjectModal');
assert.match(html, /class="add-group-btn project-folders-add-btn"[\s\S]*?onclick="showNewProjectModalFromChatSidebar\(\)"/);
assert.match(chat, /projectHeader\.querySelector\('\.project-folders-add-btn'\)/);
assert.match(source, /window\._projectModalFromChat = false/);
assert.match(source, /window\._projectModalFromChatSidebar = true/);
assert.match(rbac, /showNewProjectModalFromChatSidebar: 'project:write'/);
});
test('对话项目归属尚未加载时不会误展开无项目', () => {
const resolver = functionSource(projects, 'resolveChatProjectFolderSelection', 'renderChatProjectFolders');
const render = functionSource(projects, 'renderChatProjectFolders', 'refreshChatProjectFolders');
assert.match(resolver, /if \(!chatProjectFolderContext\.ready\) return null/);
assert.match(resolver, /if \(!conversation\) return null/);
assert.match(resolver, /conversation\.projectId \|\| conversation\.project_id \|\| ''/);
assert.match(render, /const selectedId = resolveChatProjectFolderSelection\(\)/);
assert.match(render, /selectedId !== null && chatProjectFolderLastSelectionId !== selectedId/);
});
test('项目按展开状态切换 Codex 风格的打开和关闭文件夹', () => {
const icon = functionSource(projects, 'projectFolderIconMarkup', 'clampProjectPreviewText');
const folder = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
assert.match(icon, /const path = isExpanded/);
assert.match(icon, /M3\.5 18V6\.5/);
assert.match(icon, /M3\.5 7a2 2 0 0 1 2-2h4l2 2/);
assert.match(folder, /icon\.className = 'project-folder-icon';/);
assert.match(folder, /icon\.innerHTML = projectFolderIconMarkup\(isExpanded\);/);
});
test('项目名仅在界面按 12 个 Unicode 字符省略并保留完整悬浮信息', () => {
const formatterSource = functionSource(chat, 'formatProjectNameForDisplay', 'applyProjectNameDisplay');
const formatter = new Function(
'PROJECT_NAME_DISPLAY_MAX_CHARACTERS',
`${formatterSource}; return formatProjectNameForDisplay;`
)(12);
const folder = functionSource(projects, 'appendChatProjectFolderItem', 'appendChatProjectConversationItem');
const picker = functionSource(projects, 'appendChatProjectPanelItem', 'appendChatProjectPanelMessage');
const button = functionSource(projects, 'updateChatProjectButtonLabel', 'renderChatProjectPanel');
assert.equal(formatter('十二字符以内'), '十二字符以内');
assert.equal(formatter('这是一个非常非常长的项目名称'), '这是一个非常非常长的项目…');
assert.equal(formatter('😀😀😀😀😀😀😀😀😀😀😀😀😀'), '😀😀😀😀😀😀😀😀😀😀😀😀…');
assert.match(chat, /const PROJECT_NAME_DISPLAY_MAX_CHARACTERS = 12/);
assert.match(folder, /applyProjectNameDisplay\(title, project\.name/);
assert.match(projects, /applyProjectNameDisplay\(titleEl, text\)/);
assert.match(picker, /title="\$\{escapeAttr\(fullName\)\}"/);
assert.match(picker, /setAttribute\('aria-label', fullName\)/);
assert.match(button, /applyProjectNameDisplay/);
assert.match(styles, /\.project-selector-wrapper \.role-selector-text\s*\{[\s\S]*?max-width: 13em/);
});
test('项目文件夹首批显示 6 个并通过加载更多按批追加', () => {
const loadMore = functionSource(projects, 'loadMoreChatProjectFolders', 'renderChatProjectFolders');
const render = functionSource(projects, 'renderChatProjectFolders', 'refreshChatProjectFolders');
const search = functionSource(projects, 'handleProjectFolderSearch', 'clearProjectFolderSearch');
assert.match(projects, /const CHAT_PROJECT_FOLDER_PAGE_SIZE = 6/);
assert.match(loadMore, /chatProjectFolderVisibleCount \+= CHAT_PROJECT_FOLDER_PAGE_SIZE/);
assert.match(render, /const visibleFolders = folders\.slice\(0, chatProjectFolderVisibleCount\)/);
assert.match(render, /appendChatProjectFoldersLoadMore\(list, folders\.length - visibleFolders\.length\)/);
assert.match(render, /chatProjectFolderVisibleCount = selectedIndex \+ 1/);
assert.match(search, /renderChatProjectFolders\(projectsCacheAll\)/);
assert.match(styles, /\.project-folders-load-more\s*\{/);
assert.match(zh, /"projectFoldersLoadMoreRemaining": "加载更多,剩余 \{\{count\}\} 个项目"/);
assert.match(en, /"projectFoldersLoadMoreRemaining": "Load more, \{\{count\}\} projects remaining"/);
});
test('对话悬浮预览显示本地年月日时分', () => {
const age = functionSource(projects, 'formatProjectConversationPreviewAge', 'getProjectConversationModeLabel');
assert.match(age, /date\.getFullYear\(\)/);
assert.match(age, /date\.getMonth\(\) \+ 1/);
assert.match(age, /date\.getDate\(\)/);
assert.match(age, /date\.getHours\(\)/);
assert.match(age, /date\.getMinutes\(\)/);
assert.match(age, /chat\.conversationPreviewDateTime/);
assert.doesNotMatch(age, /elapsedMs|conversationPreviewDays|conversationPreviewHours/);
assert.match(zh, /"conversationPreviewDateTime": "\{\{year\}\}年\{\{month\}\}月\{\{day\}\}日 \{\{hour\}\}:\{\{minute\}\}"/);
assert.match(en, /"conversationPreviewDateTime": "\{\{year\}\}-\{\{month\}\}-\{\{day\}\} \{\{hour\}\}:\{\{minute\}\}"/);
});
+1272 -16
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -42,6 +42,7 @@
// 项目
showNewProjectModal: 'project:write',
showNewProjectModalFromChat: 'project:write',
showNewProjectModalFromChatSidebar: 'project:write',
showNewProjectModalFromWebshellAi: 'project:write',
showEditProjectModal: 'project:write',
saveProjectModal: 'project:write',
+22 -10
View File
@@ -552,6 +552,18 @@ function escapeHtml(text) {
return div.innerHTML;
}
function escapeJsString(text) {
return JSON.stringify(String(text == null ? '' : text));
}
function escapeAttr(text) {
return escapeHtml(text).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeJsStringAttr(text) {
return escapeAttr(escapeJsString(text));
}
// 刷新角色列表
async function refreshRoles() {
await loadRoles();
@@ -651,8 +663,8 @@ function renderRolesList() {
<span class="role-card-tools-value">${toolsDisplay}</span>
</div>
<div class="role-card-actions">
<button class="btn-secondary btn-small" onclick="editRole('${escapeHtml(role.name)}')">${_t('common.edit')}</button>
${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole('${escapeHtml(role.name)}')">${_t('common.delete')}</button>` : ''}
<button class="btn-secondary btn-small" onclick="editRole(${escapeJsStringAttr(role.name)})">${_t('common.edit')}</button>
${role.name !== '默认' ? `<button class="btn-secondary btn-small btn-danger" onclick="deleteRole(${escapeJsStringAttr(role.name)})">${_t('common.delete')}</button>` : ''}
</div>
</div>
`;
@@ -924,7 +936,7 @@ function renderRoleToolsList() {
return;
}
const chkTitle = escapeHtml(_t('roleModal.checkboxLinkTitle'));
const chkTitle = escapeAttr(_t('roleModal.checkboxLinkTitle'));
allRoleTools.forEach(tool => {
const toolKey = getToolKey(tool);
@@ -948,19 +960,19 @@ function renderRoleToolsList() {
const externalMcpName = toolState.external_mcp || tool.external_mcp || '';
const badgeText = externalMcpName ? `外部 (${escapeHtml(externalMcpName)})` : '外部';
const badgeTitle = externalMcpName ? `外部MCP工具 - 来源:${escapeHtml(externalMcpName)}` : '外部MCP工具';
externalBadge = `<span class="external-tool-badge" title="${badgeTitle}">${badgeText}</span>`;
externalBadge = `<span class="external-tool-badge" title="${escapeAttr(badgeTitle)}">${badgeText}</span>`;
}
let mcpDisabledBadge = '';
if (tool.enabled === false) {
mcpDisabledBadge = `<span class="role-tool-mcp-disabled-badge" title="${escapeHtml(_t('roleModal.mcpDisabledBadgeTitle'))}">${escapeHtml(_t('roleModal.mcpDisabledBadge'))}</span>`;
}
// 生成唯一的checkbox id
const checkboxId = `role-tool-${escapeHtml(toolKey).replace(/::/g, '--')}`;
const checkboxId = `role-tool-${escapeAttr(toolKey).replace(/::/g, '--')}`;
toolItem.innerHTML = `
<input type="checkbox" id="${checkboxId}" ${toolState.enabled ? 'checked' : ''}
title="${chkTitle}" aria-label="${chkTitle}"
onchange="handleRoleToolCheckboxChange('${escapeHtml(toolKey)}', this.checked)" />
onchange="handleRoleToolCheckboxChange(${escapeJsStringAttr(toolKey)}, this.checked)" />
<div class="role-tool-item-info">
<div class="role-tool-item-name">
${escapeHtml(tool.name)}
@@ -1011,11 +1023,11 @@ function renderRoleToolsPagination() {
</select>
</div>
<div class="pagination-controls">
<button class="btn-secondary" onclick="loadRoleTools(1, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.firstPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.prevPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(1, ${escapeJsStringAttr(roleToolsSearchKeyword)})" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.firstPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${page - 1}, ${escapeJsStringAttr(roleToolsSearchKeyword)})" ${page === 1 || navDisabled ? 'disabled' : ''}>${_t('roleModal.prevPage')}</button>
<span class="pagination-page">${_t('roleModal.pageOf', { page: page, total: totalPages })}</span>
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, '${escapeHtml(roleToolsSearchKeyword)}')" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${page + 1}, ${escapeJsStringAttr(roleToolsSearchKeyword)})" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.nextPage')}</button>
<button class="btn-secondary" onclick="loadRoleTools(${totalPages}, ${escapeJsStringAttr(roleToolsSearchKeyword)})" ${page === totalPages || navDisabled ? 'disabled' : ''}>${_t('roleModal.lastPage')}</button>
</div>
`;
+29 -2
View File
@@ -17,6 +17,27 @@ function buildHashForPage(pageId) {
}
let chatConversationFromHashSeq = 0;
function setChatConversationRestorePending(conversationId, pending) {
const container = document.querySelector('.chat-container');
if (!container) return;
const id = String(conversationId || '').trim();
if (pending && id) {
container.classList.add('is-conversation-restoring');
container.dataset.restoringConversationId = id;
container.setAttribute('aria-busy', 'true');
return;
}
container.classList.remove('is-conversation-restoring');
delete container.dataset.restoringConversationId;
container.removeAttribute('aria-busy');
}
function finishChatConversationRestore(conversationId) {
setChatConversationRestorePending(conversationId, false);
}
window.finishChatConversationRestore = finishChatConversationRestore;
function scheduleChatConversationFromHash(delayMs) {
const hash = window.location.hash.slice(1);
const hashParts = hash.split('?');
@@ -35,6 +56,8 @@ function scheduleChatConversationFromHash(delayMs) {
if (!conversationId) {
return;
}
// 同一事件循环内先遮住默认新对话状态,避免网络请求返回前闪出“无项目”。
setChatConversationRestorePending(conversationId, true);
const token = ++chatConversationFromHashSeq;
setTimeout(() => {
if (token !== chatConversationFromHashSeq) {
@@ -84,7 +107,7 @@ function initRouter() {
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(500);
scheduleChatConversationFromHash(0);
}
return;
}
@@ -98,6 +121,9 @@ function initRouter() {
function switchPage(pageId) {
const targetPage = document.getElementById(`page-${pageId}`);
if (!targetPage) return;
if (pageId !== 'chat') {
setChatConversationRestorePending('', false);
}
// 导航点击会修改 hash,随后浏览器还会触发 hashchange。
// 同一页面已经激活时不再重复初始化,避免接口重复请求和页面二次重绘。
@@ -563,6 +589,7 @@ async function initPage(pageId) {
// 页面加载完成后初始化路由
document.addEventListener('DOMContentLoaded', function() {
initRouter();
document.documentElement.classList.remove('initial-route-pending');
initSidebarState();
// 监听hash变化
@@ -576,7 +603,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (pageId && ['dashboard', 'chat', 'hitl', 'asset-overview', 'asset-library', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
switchPage(pageId);
if (pageId === 'chat') {
scheduleChatConversationFromHash(200);
scheduleChatConversationFromHash(0);
}
}
});

Some files were not shown because too many files have changed in this diff Show More