mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 08:37:41 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d1f5d2886 | ||
|
|
0f92817261 | ||
|
|
2ec5953416 | ||
|
|
b4fe2e795e | ||
|
|
904d860797 | ||
|
|
dc08199af6 | ||
|
|
84e99220ff | ||
|
|
86f1d10a8b | ||
|
|
5c643a1606 | ||
|
|
b9854192c6 | ||
|
|
5a5762e1d1 | ||
|
|
8882d70393 | ||
|
|
5747ebc612 | ||
|
|
a6b3773f00 | ||
|
|
c2b950ad53 | ||
|
|
0283fff743 | ||
|
|
018835d6b8 | ||
|
|
4b7df4e0f3 | ||
|
|
0324b41a01 | ||
|
|
4a19620137 | ||
|
|
f8110413c0 | ||
|
|
59dc7cf858 | ||
|
|
52595e07e5 | ||
|
|
e0965594bb |
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.7.10"
|
version: "v1.7.9"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
|
|||||||
@@ -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.
|
The engine executes the workflow as a **directed graph**, starting from the **Start** node and following edges to downstream nodes.
|
||||||
|
|
||||||
@@ -116,7 +148,7 @@ Agent B still receives Agent A’s output even when a condition node lies betwee
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Template syntax
|
## 5. Template syntax
|
||||||
|
|
||||||
### 4.1 Basic format
|
### 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
|
### 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.
|
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
|
### 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
|
### 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
|
### 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:
|
On save, the system checks:
|
||||||
|
|
||||||
@@ -510,7 +542,7 @@ On save, the system checks:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Troubleshooting
|
## 12. Troubleshooting
|
||||||
|
|
||||||
| Symptom | Likely cause | Fix |
|
| 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.
|
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.
|
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 |
|
| Module | Path |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
## 核心概念与编排
|
## 核心概念与编排
|
||||||
|
|
||||||
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
- [架构说明](architecture.md) · [安全模型](security-model.md) · [RBAC](rbac.md)
|
||||||
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md)
|
- [Agent 与角色](agent-and-role-guide.md) · [Skills](skills-guide.md) · [Eino 多代理](MULTI_AGENT_EINO.md) · [Agent 最终回复治理](agent-finalization-best-practices.md)
|
||||||
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
- [工作流](workflow-graph.md) · [工具执行治理](tool-execution-governance.md) · [人机协同最佳实践](hitl-best-practices.md)
|
||||||
|
|
||||||
## 功能指南
|
## 功能指南
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# Agent 最终回复治理最佳实践
|
||||||
|
|
||||||
|
[返回中文文档](README.md)
|
||||||
|
|
||||||
|
调研日期:2026-07-28
|
||||||
|
|
||||||
|
本文聚焦一个具体问题:Agent 在工具调用、推理、计划或子代理协作尚未真正完成时,输出了一段“像结论”的自然语言,前端或编排层把它当作最终回复展示。结论先说清楚:成熟 Agent 系统不会用“最近一段 assistant 文本”判断任务完成,而是用运行时状态、工具状态、验证结果和显式终态事件共同决定是否 final。
|
||||||
|
|
||||||
|
## 一、核心结论
|
||||||
|
|
||||||
|
1. **最终回复是运行时事件,不是自然语言内容。**
|
||||||
|
“已拿到”“下一步”“Huge breakthrough”这类文本只能作为候选观察或进展,不能作为完成信号。
|
||||||
|
|
||||||
|
2. **过程面和交付面必须隔离。**
|
||||||
|
`thinking`、`reasoning_chain`、`planning`、`response_delta`、子代理回复、工具输出都属于过程面;只有通过 final gate 的 `response` / `final` 事件才能写入主消息气泡和 `messages.content`。
|
||||||
|
|
||||||
|
3. **复杂任务需要 verifier,而不是更长 prompt。**
|
||||||
|
Prompt 可以提醒模型谨慎,但最终完成必须由代码层判断:是否仍有待执行工具、后台 execution、未完成计划步骤、未验证证据、未记录事实/漏洞、未清理或未说明不可清理。
|
||||||
|
|
||||||
|
4. **不同 agent 模式不同,但 final 治理原则一致。**
|
||||||
|
单代理、Deep、Plan-Execute、Supervisor 都需要 final gate。区别只是 gate 的证据来源不同:单代理看工具轨迹,Deep 还要看子代理结果,Plan-Execute 要看 Replanner 的终止判断,Supervisor 要看 `exit` 与 supervisor 汇总。
|
||||||
|
|
||||||
|
## 二、成熟 Agent 的公开做法
|
||||||
|
|
||||||
|
| 系统 | 公开做法 | 对 final 治理的启发 |
|
||||||
|
|---|---|---|
|
||||||
|
| Codex | OpenAI 的 Codex prompting guide 建议不要在 prompt 中强行要求 upfront plan、preamble 或 status updates,因为这可能导致 rollout 未完成就停止。 | 不要把“模型自己说的阶段性计划/状态”当完成依据;agent harness 应负责执行循环和收尾。 |
|
||||||
|
| Claude Code | Claude Code 提供 `PreToolUse`、`PostToolUse`、`Stop` 等 hooks;`PostToolUse` 明确发生在工具成功执行之后。 | 生命周期事件比自然语言可靠。验证、审计、阻断应挂在确定的阶段边界上。 |
|
||||||
|
| Claude Code Subagents | 子代理有独立上下文、自定义系统提示、特定工具权限和独立权限;子代理适合隔离大量检索/日志/文件读取。 | 子代理输出是证据材料,不是主任务最终结论;主代理必须汇总、验收、再 final。 |
|
||||||
|
| Claude Code Plan Mode | Plan mode 先读文件并产出计划,获得批准前不编辑。 | 计划与执行是不同状态;计划完成不等于任务完成。 |
|
||||||
|
| Cursor Plan Mode | Cursor Plan Mode 会研究代码库、询问澄清问题、生成可审查计划,并等待用户确认后再构建。 | UI 层把 plan/review/build 拆开,用户不会把计划误认为最终交付。 |
|
||||||
|
| OpenCode | OpenCode 把 Build、Plan、Review、Debug、Docs 等 agent 分成不同工具权限与用途,Plan agent 只分析规划不做修改。 | 用 agent 能力边界降低误触发:能规划的 agent 不等于能执行完成。 |
|
||||||
|
| Eino ADK | Eino ADK 提供事件驱动输出、Runner 回调、中断、checkpoint,以及 Supervisor、Plan-Execute 等协作原语。Plan-Execute 由 Planner、Executor、Replanner 协作。 | 当前项目选型方向正确;需要把事件驱动能力进一步固化为 finalization contract。 |
|
||||||
|
|
||||||
|
主要参考:
|
||||||
|
|
||||||
|
- OpenAI Codex Prompting Guide: https://developers.openai.com/cookbook/examples/gpt-5/codex_prompting_guide
|
||||||
|
- Claude Code Hooks: https://docs.anthropic.com/en/docs/claude-code/hooks
|
||||||
|
- Claude Code Subagents: https://docs.anthropic.com/en/docs/claude-code/sub-agents
|
||||||
|
- Claude Code Common Workflows: https://docs.anthropic.com/en/docs/claude-code/common-workflows
|
||||||
|
- Cursor Agent Best Practices: https://cursor.com/blog/agent-best-practices
|
||||||
|
- OpenCode Agents: https://opencode.ai/docs/agents/
|
||||||
|
- CloudWeGo Eino ADK: https://www.cloudwego.io/docs/eino/core_modules/eino_adk/
|
||||||
|
- CloudWeGo Eino ADK Patterns: https://www.cloudwego.io/docs/eino/overview/eino_adk0_1/
|
||||||
|
|
||||||
|
## 三、通用最佳实践
|
||||||
|
|
||||||
|
### 1. 建立 Finalization Contract
|
||||||
|
|
||||||
|
所有执行入口统一产出一个结构化收尾对象,只有它允许触发最终回复。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type FinalizationDecision struct {
|
||||||
|
Status string // in_progress | completed | blocked | failed | cancelled
|
||||||
|
Finalizable bool
|
||||||
|
CompletionReason string // verified | user_cancelled | timeout | blocked | failed
|
||||||
|
FinalText string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingToolRuns []string
|
||||||
|
PendingPlanSteps []string
|
||||||
|
PendingApprovals []string
|
||||||
|
MissingChecks []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
硬规则:
|
||||||
|
|
||||||
|
- `Finalizable=false` 时禁止发送 `response` 终态事件。
|
||||||
|
- `Status=in_progress` 时只能发 `progress`、`planning`、`tool_*`、`reasoning_chain` 等过程事件。
|
||||||
|
- `FinalText` 不能为空,但非空不代表可以 final。
|
||||||
|
- `PendingToolRuns`、`PendingPlanSteps`、`PendingApprovals` 任一非空时不能 `completed`。
|
||||||
|
- `EvidenceVerified=false` 时不能把候选输出写成已验证结论。
|
||||||
|
|
||||||
|
### 2. 固定 SSE 事件语义
|
||||||
|
|
||||||
|
推荐事件分层:
|
||||||
|
|
||||||
|
| 事件 | 展示位置 | 可否写 `messages.content` | 说明 |
|
||||||
|
|---|---|---:|---|
|
||||||
|
| `progress` | 任务状态/时间线 | 否 | 简短进度 |
|
||||||
|
| `planning` | 执行详情 | 否 | 主代理计划、阶段性判断 |
|
||||||
|
| `reasoning_chain` / `thinking` | 执行详情 | 否 | 推理/思考摘要 |
|
||||||
|
| `tool_call` / `tool_result` | 执行详情 | 否 | 工具事件 |
|
||||||
|
| `eino_agent_reply` | 执行详情 | 否 | 子代理返回材料 |
|
||||||
|
| `finalization_check` | 执行详情 | 否 | verifier 结果 |
|
||||||
|
| `finalization_auto_continue` | 执行详情 | 否 | verifier 触发的工程续跑,`contextInjection=false` |
|
||||||
|
| `response` | 主消息气泡 | 是 | 只能在 `data.finalized=true` 时使用 |
|
||||||
|
| `done` | 关闭流 | 否 | 仅表示流结束,不表示任务成功 |
|
||||||
|
| `error` / `cancelled` | 主消息气泡或系统提示 | 是,终态失败类 | 必须带原因 |
|
||||||
|
|
||||||
|
### 3. 把“最终候选”与“最终回复”分开
|
||||||
|
|
||||||
|
模型可以输出候选结论,但候选结论必须先进入 `final_candidate` 或 `planning`,再由 verifier 决定是否提升:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assistant text
|
||||||
|
-> candidate
|
||||||
|
-> finalization gate
|
||||||
|
-> response(finalized=true)
|
||||||
|
```
|
||||||
|
|
||||||
|
不要这样做:
|
||||||
|
|
||||||
|
```text
|
||||||
|
assistant text
|
||||||
|
-> response
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Stop-time Verification
|
||||||
|
|
||||||
|
借鉴 Claude Code hook 思路,在 agent run 停止时做一次确定性检查:
|
||||||
|
|
||||||
|
- 所有工具调用都有对应 tool result。
|
||||||
|
- 后台 execution 都处于 terminal 状态,或被明确登记为仍在运行且任务状态为 `in_progress` / `blocked`。
|
||||||
|
- Plan-Execute 没有未执行的 required step。
|
||||||
|
- Supervisor 没有未汇总的子代理结果。
|
||||||
|
- 在 evidence-required 策略下,至少存在可查询到的 completed 工具执行证据。
|
||||||
|
|
||||||
|
### 5. 子代理输出只作证据
|
||||||
|
|
||||||
|
子代理返回不能直接成为用户最终回复。主代理必须完成:
|
||||||
|
|
||||||
|
- 去重和冲突合并。
|
||||||
|
- 证据强度排序。
|
||||||
|
- 不确定性标注。
|
||||||
|
- 范围边界确认。
|
||||||
|
- 用户可读交付。
|
||||||
|
|
||||||
|
### 6. Prompt 只做软约束,代码做硬约束
|
||||||
|
|
||||||
|
Prompt 中可以写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Interim observations must be marked as progress, not final.
|
||||||
|
Do not produce a final answer until verification is complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
但真正决定 final 的必须是后端字段和状态机。否则模型只要生成一段像最终结论的自然语言,UI 仍可能误判。
|
||||||
|
|
||||||
|
## 四、CyberStrikeAI 当前落地状态
|
||||||
|
|
||||||
|
当前项目已经具备一套显式 final gate:
|
||||||
|
|
||||||
|
- [internal/agentfinalizer/decision.go](../../internal/agentfinalizer/decision.go) 是唯一的最终回复决策契约。
|
||||||
|
- [internal/handler/finalization_helpers.go](../../internal/handler/finalization_helpers.go) 负责把决策结果写入 `process_details`,并且只有 `Finalizable=true` 时才调用 `UpdateAssistantMessageFinalize`。
|
||||||
|
- [internal/handler/eino_single_agent.go](../../internal/handler/eino_single_agent.go)、[internal/handler/multi_agent.go](../../internal/handler/multi_agent.go)、[internal/handler/workflow_integration.go](../../internal/handler/workflow_integration.go)、[internal/handler/batch_queue_executor.go](../../internal/handler/batch_queue_executor.go) 均已在收尾处接入 finalizer。
|
||||||
|
- [web/static/js/monitor.js](../../web/static/js/monitor.js) 只把 `data.finalized === true` 的 `response` 当最终回复;未最终化文本会显示为最终回复检查未通过。
|
||||||
|
- [web/static/js/webshell.js](../../web/static/js/webshell.js) 将流式正文标记为候选输出,只有 `response(finalized=true)` 才切换为完成态。
|
||||||
|
- [internal/agentfinalizer/decision_test.go](../../internal/agentfinalizer/decision_test.go) 覆盖 pending tool、HITL、空输出、证据策略要求但缺执行证据、失败证据不能支撑最终化、完成态证据可 final 等回归场景。
|
||||||
|
- [internal/handler/finalization_auto_continue.go](../../internal/handler/finalization_auto_continue.go) 在缺 completed 执行证据时最多自动续跑 2 段;续跑只恢复已有模型轨迹,不向 agent 注入新的 user/system 文案。
|
||||||
|
|
||||||
|
当前契约的核心规则:
|
||||||
|
|
||||||
|
1. **模型自然语言只是 candidate。**
|
||||||
|
`RunResult.Response` 不能直接升级为最终回复,必须经过 `agentfinalizer.Decide`。
|
||||||
|
|
||||||
|
2. **所有 `response` 事件必须携带终态字段。**
|
||||||
|
至少包含 `finalized`、`finalizable`、`status`、`completionReason`、`evidenceVerified`、`evidenceRefs`、`pendingExecutionIds`、`missingChecks`。
|
||||||
|
|
||||||
|
3. **未完成工具会阻断 final。**
|
||||||
|
`queued/running` 工具执行仍存在时,决策结果为 `in_progress/pending_tool_executions`。
|
||||||
|
|
||||||
|
4. **执行证据必须由结构化策略声明。**
|
||||||
|
后端不从用户自然语言、助手回复或 agent mode 名称中推断执行意图。聊天请求通过 `finalization.requireExecutionEvidence` 显式声明;WebShell、Workflow、批量、机器人等执行入口由调用点显式传入 policy。policy 要求证据时,至少需要一个可查询到的 `completed` 工具执行记录;只有 failed/cancelled 记录不能支撑最终化。
|
||||||
|
|
||||||
|
5. **缺执行证据先工程续跑,再阻断。**
|
||||||
|
Eino 单代理和 Eino 多代理主链路在 `missing_execution_evidence` 时会先通过已有 trace 自动续跑,不注入额外上下文;达到续跑上限后仍缺证据才写入 blocked。
|
||||||
|
|
||||||
|
6. **HITL 和空输出不会 final。**
|
||||||
|
workflow 等待人工确认、空 assistant 文本、Eino 空输出占位均会写入阻断文案,而不是成功总结。
|
||||||
|
|
||||||
|
## 五、贴合当前项目的推荐架构
|
||||||
|
|
||||||
|
当前采用的链路是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Agent / Eino ADK events
|
||||||
|
-> event normalizer
|
||||||
|
-> process_details
|
||||||
|
-> finalization verifier
|
||||||
|
-> response(finalized=true)
|
||||||
|
-> messages.content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. 后端统一 Finalizer
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 接收 `RunResult` / 候选文本、`mcpExecutionIds`、会话与助手消息 ID、HITL 状态、编排模式。
|
||||||
|
- 通过数据库查询工具执行状态,识别 pending、completed、failed、cancelled 等证据状态。
|
||||||
|
- 返回 `FinalizationDecision`。
|
||||||
|
- 不调用高风险工具,只做状态和证据检查。
|
||||||
|
|
||||||
|
### 2. RunResult 终态字段
|
||||||
|
|
||||||
|
[internal/multiagent/runner.go](../../internal/multiagent/runner.go) 已扩展终态字段:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type RunResult struct {
|
||||||
|
Response string
|
||||||
|
MCPExecutionIDs []string
|
||||||
|
LastAgentTraceInput string
|
||||||
|
LastAgentTraceOutput string
|
||||||
|
|
||||||
|
Finalized bool
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingExecutionIDs []string
|
||||||
|
MissingChecks []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 发送 `response` 的条件
|
||||||
|
|
||||||
|
在单代理、多代理、工作流、批处理收尾处统一执行:
|
||||||
|
|
||||||
|
```go
|
||||||
|
decision := h.finalizeAgentRunForDelivery(...)
|
||||||
|
if !decision.Finalizable {
|
||||||
|
sendEvent("finalization_check", "任务尚未达到最终回复条件", decision)
|
||||||
|
sendEvent("response", finalizationBlockedMessage(decision), finalizationResponsePayload(decision, extra))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent("response", decision.FinalText, finalizationResponsePayload(decision, extra))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 前端只信 `finalized=true`
|
||||||
|
|
||||||
|
在 [web/static/js/monitor.js](../../web/static/js/monitor.js) 的 `case 'response'` 中执行硬判断:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const responseFinalized = isFinalizedResponseData(responseData);
|
||||||
|
const bubbleText = responseFinalized
|
||||||
|
? resolvedResponseText
|
||||||
|
: (event.message || '任务尚未达到最终回复条件,暂不生成成功结论。');
|
||||||
|
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||||
|
```
|
||||||
|
|
||||||
|
WebShell 侧同理:`response_delta` 可以用于实时预览,但 UI 文案应标记为“执行中输出”,只有最终 `response(finalized=true)` 才显示为完成态。
|
||||||
|
|
||||||
|
### 5. 各模式 final gate
|
||||||
|
|
||||||
|
| 模式 | 谁可以产出最终候选 | 谁决定 final | 必须检查 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Eino 单代理 | 单代理最后助手文本 | Finalizer | 无 pending tool、证据引用完整、任务状态 terminal |
|
||||||
|
| Deep | 主代理汇总文本 | Finalizer | 子代理结果已汇总;子代理文本不能直接 final;工具状态 terminal |
|
||||||
|
| Plan-Execute | Replanner 结束后的汇总文本 | Replanner + Finalizer | Executor 单步输出不能 final;计划步骤完成或明确 blocked |
|
||||||
|
| Supervisor | Supervisor 的 `exit` / 汇总文本 | Supervisor + Finalizer | transfer 已返回;无未处理专家结果;最终由 supervisor 统一口径 |
|
||||||
|
|
||||||
|
### 6. 安全测试场景的证据 gate
|
||||||
|
|
||||||
|
安全测试、WebShell、批量验证、Workflow 和多代理执行等 evidence-required 场景,最终回复必须至少满足:
|
||||||
|
|
||||||
|
- 有明确目标和授权范围标识。
|
||||||
|
- 有可复核证据引用,例如工具 execution id、请求/响应摘要、截图路径、命令输出摘要、事实/漏洞记录 ID。
|
||||||
|
- 有身份或影响验证结果,而不是只凭 marker 文本判断。
|
||||||
|
- 已记录到项目黑板或漏洞库,或明确说明未绑定项目导致无法记录。
|
||||||
|
- 高风险动作已清理、回滚、取消,或明确说明未执行清理的原因。
|
||||||
|
- 仍在运行的扫描/命令/WebShell/C2 任务不能被隐式当作完成。
|
||||||
|
|
||||||
|
注意:这里的 gate 是治理规则,不要求最终报告暴露敏感利用细节;可以只给证据摘要和内部引用。
|
||||||
|
|
||||||
|
## 六、落地状态与后续增强
|
||||||
|
|
||||||
|
### P0:先修“误 final”(已落地)
|
||||||
|
|
||||||
|
1. 已引入 `FinalizationDecision`。
|
||||||
|
2. 主要 agent SSE `response` 事件已携带 `data.finalized/finalizable/status/completionReason` 等字段。
|
||||||
|
3. 前端 `monitor.js` 和 `webshell.js` 已按 `finalized=true` 区分候选输出和最终回复。
|
||||||
|
4. `RunResult.Response` 仍保留兼容字段名,但语义已由 finalizer 统一提升;后续可再拆成 `CandidateResponse` / `FinalResponse`,减少误用空间。
|
||||||
|
5. Plan-Execute / Deep / Supervisor / Eino Single 等模式均通过统一 handler 收尾 gate。
|
||||||
|
|
||||||
|
### P1:补证据链(部分落地)
|
||||||
|
|
||||||
|
1. 已用 `mcp_execution:<id>` 作为基础 evidence refs。
|
||||||
|
2. `finalization_check` 事件已展示 pending execution 与 missing checks。
|
||||||
|
3. 执行入口已启用显式 execution evidence policy;Eino 主链路在 policy 要求证据且缺少 completed 工具证据时先无注入续跑,达到上限后才阻断 final。
|
||||||
|
4. 后续建议:为 `record_vulnerability`、`upsert_project_fact`、项目黑板记录建立更细粒度 evidence refs。
|
||||||
|
5. 后续建议:最终报告模板固定包含“结论、证据、风险/不确定性、后续动作”。
|
||||||
|
|
||||||
|
### P2:体验和观测(后续增强)
|
||||||
|
|
||||||
|
1. 在任务卡片展示 `in_progress / verifying / finalizing / completed / blocked`。
|
||||||
|
2. 为 finalizer 加日志和指标:误拦截率、缺失证据类型、pending tool 数量。
|
||||||
|
3. 支持“继续验证”按钮,从 `FinalizationDecision.MissingChecks` 自动生成下一轮输入。
|
||||||
|
|
||||||
|
## 七、验收测试建议
|
||||||
|
|
||||||
|
至少加入这些回归测试:
|
||||||
|
|
||||||
|
1. **推理文本不 final**
|
||||||
|
模拟 `reasoning_chain` 里出现看似完成的候选结论,但本轮没有 completed 工具执行证据;预期主消息气泡不显示成功结论,只显示执行中或阻断态。
|
||||||
|
|
||||||
|
2. **主代理阶段性输出不 final**
|
||||||
|
模拟 `response_start/delta` 输出“下一步继续验证”;预期只进入 timeline `planning`。
|
||||||
|
|
||||||
|
3. **未完成后台工具不 final**
|
||||||
|
工具返回 `execution_id` 且状态 `running`;即使模型给出总结,也只能 `in_progress`。
|
||||||
|
|
||||||
|
4. **Plan-Execute Executor 输出不 final**
|
||||||
|
Executor 输出“突破成功”,但 Replanner 未结束;预期不触发 `messages.content` finalize。
|
||||||
|
|
||||||
|
5. **Supervisor 子代理输出不 final**
|
||||||
|
子代理返回确定结论,Supervisor 未 `exit`;预期只进入 `eino_agent_reply`。
|
||||||
|
|
||||||
|
6. **最终事件必须带 finalized**
|
||||||
|
前端收到旧格式 `response` 无 `finalized=true`;预期候选内容只进入详情/警告,主消息显示阻断态,不创建成功最终气泡。
|
||||||
|
|
||||||
|
7. **失败和取消可终态**
|
||||||
|
`error` / `cancelled` 仍可更新助手消息,但 `completionReason` 必须是 `failed` / `user_cancelled`,不能伪装为成功完成。
|
||||||
|
|
||||||
|
## 八、推荐默认策略
|
||||||
|
|
||||||
|
对 CyberStrikeAI,建议默认策略是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
eino_single:轻量任务可用,但 final gate 必须开启
|
||||||
|
deep:复杂安全测试默认推荐
|
||||||
|
plan_execute:目标明确、需要严格“规划-执行-重规划”的任务推荐
|
||||||
|
supervisor:多专家路由任务使用,不作为默认泛化模式
|
||||||
|
```
|
||||||
|
|
||||||
|
最终治理一句话:
|
||||||
|
|
||||||
|
```text
|
||||||
|
messages.content 只能来自 FinalizationDecision.FinalText;
|
||||||
|
process_details 可以展示所有过程;
|
||||||
|
前端只能把 response(finalized=true) 当最终回复。
|
||||||
|
```
|
||||||
@@ -34,7 +34,39 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、执行模型(先理解再配置)
|
## 三、自然语言生成草稿
|
||||||
|
|
||||||
|
工作流页面提供 **用自然语言创建** 入口。用户描述一句安全作业目标后,平台会生成一个可编辑草稿,并返回结构化审计结果:
|
||||||
|
|
||||||
|
- 只生成草稿,不会自动保存、试运行或真实执行工具。
|
||||||
|
- 服务端接口为 `POST /api/workflows/generate-draft`,权限为 `workflow:write`。
|
||||||
|
- 生成结果包含 `graph`、`meta`、`capabilities`、`audit` 和 `stats`,前端应用到画布后仍走现有保存校验。
|
||||||
|
- 高风险语义(执行脚本、隔离、封禁、删除、利用等)会标记 `high_risk`,并默认插入 HITL 审批或 `requires_human_confirmation`。
|
||||||
|
- 工具能力按已有工具列表匹配;未匹配到时降级为 Agent 草稿,并在 `audit.missing_fields` / `audit.assumptions` 中提示需要补配置。
|
||||||
|
- 如果服务端生成不可用,前端会使用本地确定性兜底生成器,继续给出可编辑草稿和风险提示。
|
||||||
|
|
||||||
|
示例请求:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/workflows/generate-draft
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"prompt": "对目标资产做端口扫描,如果发现高危端口就创建漏洞任务并通知负责人审批,最后输出报告",
|
||||||
|
"options": {
|
||||||
|
"include_objective": true,
|
||||||
|
"allow_schedule": false,
|
||||||
|
"allow_high_risk": false
|
||||||
|
},
|
||||||
|
"available_tools": [
|
||||||
|
{ "key": "nmap", "name": "nmap", "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、执行模型(先理解再配置)
|
||||||
|
|
||||||
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
工作流按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
||||||
|
|
||||||
@@ -116,7 +148,7 @@ outputs["你填的变量名"] = 节点输出内容
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 四、模板语法
|
## 五、模板语法
|
||||||
|
|
||||||
### 4.1 基本格式
|
### 4.1 基本格式
|
||||||
|
|
||||||
@@ -198,7 +230,7 @@ jq({{outputs.scan}}, ".severity") == "high"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、节点类型与配置
|
## 六、节点类型与配置
|
||||||
|
|
||||||
### 5.1 开始(start)
|
### 5.1 开始(start)
|
||||||
|
|
||||||
@@ -318,7 +350,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、连线配置
|
## 七、连线配置
|
||||||
|
|
||||||
选中 **连线** 后,右侧可配置 **连线条件**。
|
选中 **连线** 后,右侧可配置 **连线条件**。
|
||||||
|
|
||||||
@@ -335,7 +367,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、完整示例:跨条件节点传递 Agent 输出
|
## 八、完整示例:跨条件节点传递 Agent 输出
|
||||||
|
|
||||||
### 7.1 流程结构
|
### 7.1 流程结构
|
||||||
|
|
||||||
@@ -384,7 +416,7 @@ HITL 等待信息会记录:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、绑定角色并运行
|
## 九、绑定角色并运行
|
||||||
|
|
||||||
### 8.1 在角色管理中绑定
|
### 8.1 在角色管理中绑定
|
||||||
|
|
||||||
@@ -414,7 +446,7 @@ workflow_policy: auto
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、调试、试运行与复盘
|
## 十、调试、试运行与复盘
|
||||||
|
|
||||||
### 9.1 安全试运行(dry-run)
|
### 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`。
|
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result`、`parsed_targets`,避免都叫 `agent_result`。
|
||||||
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
|
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
|
||||||
@@ -539,7 +571,7 @@ token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十三、相关代码位置(开发者参考)
|
## 十四、相关代码位置(开发者参考)
|
||||||
|
|
||||||
| 模块 | 路径 |
|
| 模块 | 路径 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,266 @@
|
|||||||
|
package agentfinalizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusCompleted = "completed"
|
||||||
|
StatusInProgress = "in_progress"
|
||||||
|
StatusBlocked = "blocked"
|
||||||
|
StatusFailed = "failed"
|
||||||
|
StatusCancelled = "cancelled"
|
||||||
|
StatusAwaitingHITL = "awaiting_hitl"
|
||||||
|
|
||||||
|
ReasonVerified = "verified"
|
||||||
|
ReasonPendingTools = "pending_tool_executions"
|
||||||
|
ReasonEmptyResponse = "empty_response"
|
||||||
|
ReasonAwaitingHITL = "awaiting_hitl"
|
||||||
|
ReasonFailed = "failed"
|
||||||
|
ReasonCancelled = "cancelled"
|
||||||
|
ReasonMissingEvidence = "missing_execution_evidence"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decision is the single contract that may promote an agent run to a final
|
||||||
|
// user-facing answer. Natural-language assistant text is only a candidate until
|
||||||
|
// this object says Finalizable.
|
||||||
|
type Decision struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Finalizable bool `json:"finalizable"`
|
||||||
|
Finalized bool `json:"finalized"`
|
||||||
|
CompletionReason string `json:"completionReason"`
|
||||||
|
FinalText string `json:"finalText,omitempty"`
|
||||||
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
|
PendingToolRuns []string `json:"pendingToolRuns,omitempty"`
|
||||||
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
|
AgentMode string `json:"agentMode,omitempty"`
|
||||||
|
ConversationID string `json:"conversationId,omitempty"`
|
||||||
|
AssistantMessageID string `json:"messageId,omitempty"`
|
||||||
|
CandidateResponseLen int `json:"candidateResponseLen,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
Response string
|
||||||
|
MCPExecutionIDs []string
|
||||||
|
ConversationID string
|
||||||
|
AssistantMessageID string
|
||||||
|
AgentMode string
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
AwaitingHITL bool
|
||||||
|
RequireExecutionEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func FromRunResult(db *database.DB, result *multiagent.RunResult, in Input) Decision {
|
||||||
|
if result != nil {
|
||||||
|
if strings.TrimSpace(in.Response) == "" {
|
||||||
|
in.Response = result.Response
|
||||||
|
}
|
||||||
|
if len(in.MCPExecutionIDs) == 0 {
|
||||||
|
in.MCPExecutionIDs = result.MCPExecutionIDs
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Status) == "" {
|
||||||
|
in.Status = result.Status
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.CompletionReason) == "" {
|
||||||
|
in.CompletionReason = result.CompletionReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d := Decide(db, in)
|
||||||
|
if result != nil {
|
||||||
|
result.Finalized = d.Finalized
|
||||||
|
result.Status = d.Status
|
||||||
|
result.CompletionReason = d.CompletionReason
|
||||||
|
result.EvidenceVerified = d.EvidenceVerified
|
||||||
|
result.EvidenceRefs = append([]string(nil), d.EvidenceRefs...)
|
||||||
|
result.PendingExecutionIDs = append([]string(nil), d.PendingExecutionIDs...)
|
||||||
|
result.MissingChecks = append([]string(nil), d.MissingChecks...)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func Decide(db *database.DB, in Input) Decision {
|
||||||
|
text := strings.TrimSpace(in.Response)
|
||||||
|
status := strings.TrimSpace(in.Status)
|
||||||
|
if status == "" {
|
||||||
|
status = StatusCompleted
|
||||||
|
}
|
||||||
|
reason := strings.TrimSpace(in.CompletionReason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = ReasonVerified
|
||||||
|
}
|
||||||
|
d := Decision{
|
||||||
|
Status: status,
|
||||||
|
CompletionReason: reason,
|
||||||
|
FinalText: text,
|
||||||
|
EvidenceVerified: true,
|
||||||
|
EvidenceRefs: evidenceRefs(in.MCPExecutionIDs),
|
||||||
|
AgentMode: strings.TrimSpace(in.AgentMode),
|
||||||
|
ConversationID: strings.TrimSpace(in.ConversationID),
|
||||||
|
AssistantMessageID: strings.TrimSpace(in.AssistantMessageID),
|
||||||
|
CandidateResponseLen: len([]rune(text)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.AwaitingHITL {
|
||||||
|
d.Status = StatusAwaitingHITL
|
||||||
|
d.CompletionReason = ReasonAwaitingHITL
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "workflow is awaiting HITL approval")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
if isEmptyCandidate(text) {
|
||||||
|
d.Status = StatusBlocked
|
||||||
|
d.CompletionReason = ReasonEmptyResponse
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "assistant final text is empty or only an empty-response placeholder")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case StatusInProgress, StatusBlocked, StatusFailed, StatusCancelled, StatusAwaitingHITL:
|
||||||
|
d.Status = status
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
if d.CompletionReason == ReasonVerified {
|
||||||
|
d.CompletionReason = status
|
||||||
|
}
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "agent run status is "+status)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
pending := pendingExecutions(db, in.MCPExecutionIDs)
|
||||||
|
if len(pending) > 0 {
|
||||||
|
d.Status = StatusInProgress
|
||||||
|
d.CompletionReason = ReasonPendingTools
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.PendingExecutionIDs = pending
|
||||||
|
d.PendingToolRuns = append([]string(nil), pending...)
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "tool execution still queued or running")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
if in.RequireExecutionEvidence && !hasCompletedEvidence(db, in.MCPExecutionIDs) {
|
||||||
|
d.Status = StatusBlocked
|
||||||
|
d.CompletionReason = ReasonMissingEvidence
|
||||||
|
d.EvidenceVerified = false
|
||||||
|
d.MissingChecks = append(d.MissingChecks, "execution evidence is required but no completed tool execution was recorded")
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Finalizable = true
|
||||||
|
d.Finalized = true
|
||||||
|
d.Status = StatusCompleted
|
||||||
|
if d.CompletionReason == "" {
|
||||||
|
d.CompletionReason = ReasonVerified
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResponsePayload(d Decision, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"finalized": d.Finalized,
|
||||||
|
"finalizable": d.Finalizable,
|
||||||
|
"status": d.Status,
|
||||||
|
"completionReason": d.CompletionReason,
|
||||||
|
"evidenceVerified": d.EvidenceVerified,
|
||||||
|
"evidenceRefs": d.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": d.PendingExecutionIDs,
|
||||||
|
"pendingToolRuns": d.PendingToolRuns,
|
||||||
|
"missingChecks": d.MissingChecks,
|
||||||
|
}
|
||||||
|
if d.ConversationID != "" {
|
||||||
|
out["conversationId"] = d.ConversationID
|
||||||
|
}
|
||||||
|
if d.AssistantMessageID != "" {
|
||||||
|
out["messageId"] = d.AssistantMessageID
|
||||||
|
}
|
||||||
|
if d.AgentMode != "" {
|
||||||
|
out["agentMode"] = d.AgentMode
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEmptyCandidate(s string) bool {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.Contains(s, "no assistant text was captured") ||
|
||||||
|
strings.Contains(s, "未捕获到助手文本输出")
|
||||||
|
}
|
||||||
|
|
||||||
|
func evidenceRefs(ids []string) []string {
|
||||||
|
out := make([]string, 0, len(ids))
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, "mcp_execution:"+id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingExecutions(db *database.DB, ids []string) []string {
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0)
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
exec, err := db.GetToolExecution(id)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(exec.Status) {
|
||||||
|
case mcp.ToolExecutionStatusQueued, mcp.ToolExecutionStatusRunning:
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCompletedEvidence(db *database.DB, ids []string) bool {
|
||||||
|
if db == nil || len(ids) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
exec, err := db.GetToolExecution(id)
|
||||||
|
if err != nil || exec == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(exec.Status) == mcp.ToolExecutionStatusCompleted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package agentfinalizer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
"cyberstrike-ai/internal/mcp"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDecisionTestDB(t *testing.T) *database.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.NewDB(filepath.Join(t.TempDir(), "finalizer.db"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDecisionTestExecution(t *testing.T, db *database.DB, id, status string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||||
|
ID: id,
|
||||||
|
ToolName: "test::tool",
|
||||||
|
Arguments: map[string]interface{}{"input": id},
|
||||||
|
Status: status,
|
||||||
|
StartTime: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SaveToolExecution(%s): %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksPendingToolExecutions(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-queued", mcp.ToolExecutionStatusQueued)
|
||||||
|
saveDecisionTestExecution(t, db, "run-running", mcp.ToolExecutionStatusRunning)
|
||||||
|
saveDecisionTestExecution(t, db, "run-completed", mcp.ToolExecutionStatusCompleted)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "工具还没全部结束时,这只是一段候选输出。",
|
||||||
|
MCPExecutionIDs: []string{"run-queued", "run-running", "run-completed"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("pending tools should not be finalizable: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusInProgress || d.CompletionReason != ReasonPendingTools {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusInProgress, ReasonPendingTools)
|
||||||
|
}
|
||||||
|
if got, want := len(d.PendingExecutionIDs), 2; got != want {
|
||||||
|
t.Fatalf("pending execution count = %d, want %d (%v)", got, want, d.PendingExecutionIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksAwaitingHITLAndEmptyCandidate(t *testing.T) {
|
||||||
|
hitl := Decide(nil, Input{Response: "等待人工审批", AwaitingHITL: true})
|
||||||
|
if hitl.Finalizable || hitl.Status != StatusAwaitingHITL || hitl.CompletionReason != ReasonAwaitingHITL {
|
||||||
|
t.Fatalf("HITL decision mismatch: %+v", hitl)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty := Decide(nil, Input{Response: "⚠️ Eino 执行完成,但未捕获到助手文本输出。"})
|
||||||
|
if empty.Finalizable || empty.Status != StatusBlocked || empty.CompletionReason != ReasonEmptyResponse {
|
||||||
|
t.Fatalf("empty candidate decision mismatch: %+v", empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksWhenExecutionEvidenceIsRequiredButMissing(t *testing.T) {
|
||||||
|
d := Decide(nil, Input{
|
||||||
|
Response: "任务已处理完成。",
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("missing required execution evidence should not finalize: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||||
|
}
|
||||||
|
if d.EvidenceVerified {
|
||||||
|
t.Fatalf("missing required execution evidence should be marked unverified: %+v", d)
|
||||||
|
}
|
||||||
|
if len(d.MissingChecks) == 0 {
|
||||||
|
t.Fatalf("missing checks should explain the evidence gap: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideBlocksWhenOnlyFailedEvidenceIsRecorded(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-failed", mcp.ToolExecutionStatusFailed)
|
||||||
|
saveDecisionTestExecution(t, db, "run-cancelled", mcp.ToolExecutionStatusCancelled)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "任务已处理完成。",
|
||||||
|
MCPExecutionIDs: []string{"run-failed", "run-cancelled"},
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
t.Fatalf("failed evidence should not satisfy required execution evidence: %+v", d)
|
||||||
|
}
|
||||||
|
if d.Status != StatusBlocked || d.CompletionReason != ReasonMissingEvidence {
|
||||||
|
t.Fatalf("status/reason = %s/%s, want %s/%s", d.Status, d.CompletionReason, StatusBlocked, ReasonMissingEvidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideFinalizesCompletedEvidence(t *testing.T) {
|
||||||
|
db := newDecisionTestDB(t)
|
||||||
|
saveDecisionTestExecution(t, db, "run-ok", mcp.ToolExecutionStatusCompleted)
|
||||||
|
|
||||||
|
d := Decide(db, Input{
|
||||||
|
Response: "任务已处理完成,见工具执行记录。",
|
||||||
|
MCPExecutionIDs: []string{"run-ok"},
|
||||||
|
RequireExecutionEvidence: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||||
|
t.Fatalf("completed execution should finalize: %+v", d)
|
||||||
|
}
|
||||||
|
if !d.EvidenceVerified || len(d.EvidenceRefs) != 1 {
|
||||||
|
t.Fatalf("evidence refs mismatch: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAllowsInformationalAnswerWhenExecutionEvidenceIsNotRequired(t *testing.T) {
|
||||||
|
d := Decide(nil, Input{Response: "这是一个概念解释,不需要执行工具。"})
|
||||||
|
if !d.Finalizable || !d.Finalized || d.Status != StatusCompleted {
|
||||||
|
t.Fatalf("informational response should finalize when execution evidence is not required: %+v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1362,6 +1362,7 @@ func setupRoutes(
|
|||||||
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
||||||
protected.POST("/workflows/validate", workflowHandler.Validate)
|
protected.POST("/workflows/validate", workflowHandler.Validate)
|
||||||
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
||||||
|
protected.POST("/workflows/generate-draft", workflowHandler.GenerateDraft)
|
||||||
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
|
protected.GET("/workflows/:id/package", workflowHandler.ExportPackage)
|
||||||
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
|
protected.POST("/workflow-package-inspections", workflowHandler.CreatePackageInspection)
|
||||||
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
|
protected.GET("/workflow-package-inspections/:inspectionId", workflowHandler.GetPackageInspection)
|
||||||
|
|||||||
+150
-8
@@ -13,6 +13,7 @@ import (
|
|||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ type Asset struct {
|
|||||||
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
|
LastScanTaskID string `json:"last_scan_task_id,omitempty"`
|
||||||
VulnerabilityCount int `json:"vulnerability_count"`
|
VulnerabilityCount int `json:"vulnerability_count"`
|
||||||
RiskLevel string `json:"risk_level"`
|
RiskLevel string `json:"risk_level"`
|
||||||
|
RiskScore int `json:"-"`
|
||||||
OwnerUserID string `json:"-"`
|
OwnerUserID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,15 +445,15 @@ func assetWhere(filter AssetListFilter, access RBACListAccess) (string, []interf
|
|||||||
args = append(args, *filter.Port)
|
args = append(args, *filter.Port)
|
||||||
}
|
}
|
||||||
if filter.RiskLevel != "" {
|
if filter.RiskLevel != "" {
|
||||||
query += " AND " + assetRiskLevelExpr + " = ?"
|
query += " AND " + assetRiskLevelCachedExpr + " = ?"
|
||||||
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
|
args = append(args, strings.ToLower(strings.TrimSpace(filter.RiskLevel)))
|
||||||
}
|
}
|
||||||
if filter.MinVulnerabilities != nil {
|
if filter.MinVulnerabilities != nil {
|
||||||
query += " AND " + assetVulnerabilityCountExpr + " >= ?"
|
query += " AND " + assetVulnerabilityCountCachedExpr + " >= ?"
|
||||||
args = append(args, *filter.MinVulnerabilities)
|
args = append(args, *filter.MinVulnerabilities)
|
||||||
}
|
}
|
||||||
if filter.MaxVulnerabilities != nil {
|
if filter.MaxVulnerabilities != nil {
|
||||||
query += " AND " + assetVulnerabilityCountExpr + " <= ?"
|
query += " AND " + assetVulnerabilityCountCachedExpr + " <= ?"
|
||||||
args = append(args, *filter.MaxVulnerabilities)
|
args = append(args, *filter.MaxVulnerabilities)
|
||||||
}
|
}
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
@@ -573,20 +575,24 @@ const assetVulnerabilityMatchExpr = `(
|
|||||||
|
|
||||||
const assetVulnerabilityCountExpr = `(SELECT COUNT(DISTINCT v.id) FROM vulnerabilities v WHERE ` + 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)
|
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
|
FROM vulnerabilities v
|
||||||
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
|
WHERE LOWER(COALESCE(v.status,'open')) NOT IN ('fixed','false_positive','ignored') AND ` + assetVulnerabilityMatchExpr + `
|
||||||
),0)`
|
),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)`
|
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,
|
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.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,
|
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,''),
|
` + 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.
|
// 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.
|
// 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 {
|
if n == 0 {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +638,9 @@ func (db *DB) CompleteAssetScan(id, conversationID string, access RBACListAccess
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return sql.ErrNoRows
|
return sql.ErrNoRows
|
||||||
}
|
}
|
||||||
|
if err := db.RefreshAssetRiskCache(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +650,136 @@ func (db *DB) BatchTaskBelongsToQueue(taskID, queueID string) bool {
|
|||||||
return err == nil && count > 0
|
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) {
|
func (db *DB) ListAssets(limit, offset int, filter AssetListFilter, access RBACListAccess) ([]*Asset, int, error) {
|
||||||
if limit < 1 {
|
if limit < 1 {
|
||||||
limit = 20
|
limit = 20
|
||||||
@@ -726,9 +868,9 @@ func assetOrderBy(sortBy, sortOrder string) string {
|
|||||||
case "port":
|
case "port":
|
||||||
expression = "assets.port"
|
expression = "assets.port"
|
||||||
case "vulnerability_count":
|
case "vulnerability_count":
|
||||||
expression = assetVulnerabilityCountExpr
|
expression = assetVulnerabilityCountCachedExpr
|
||||||
case "risk_level":
|
case "risk_level":
|
||||||
expression = assetRiskScoreExpr
|
expression = assetRiskScoreCachedExpr
|
||||||
default:
|
default:
|
||||||
expression = "assets.last_seen_at"
|
expression = "assets.last_seen_at"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,7 +383,12 @@ func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
|
|||||||
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
|
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
|
||||||
t.Fatalf("unexpected scan metadata: %#v", linked)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Conversation struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
ProjectID string `json:"projectId,omitempty"`
|
ProjectID string `json:"projectId,omitempty"`
|
||||||
RoleName string `json:"roleName,omitempty"`
|
RoleName string `json:"roleName,omitempty"`
|
||||||
|
AgentMode string `json:"agentMode,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
@@ -59,29 +60,30 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
roleName := normalizeConversationRoleName(meta.RoleName)
|
roleName := normalizeConversationRoleName(meta.RoleName)
|
||||||
|
agentMode := normalizeConversationAgentMode(meta.AgentMode)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
wsID := strings.TrimSpace(webshellConnectionID)
|
wsID := strings.TrimSpace(webshellConnectionID)
|
||||||
switch {
|
switch {
|
||||||
case wsID != "" && projectID != "":
|
case wsID != "" && projectID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, wsID, projectID, roleName,
|
id, title, now, now, wsID, projectID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
case wsID != "":
|
case wsID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, wsID, roleName,
|
id, title, now, now, wsID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
case projectID != "":
|
case projectID != "":
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, projectID, roleName,
|
id, title, now, now, projectID, roleName, agentMode,
|
||||||
)
|
)
|
||||||
default:
|
default:
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
"INSERT INTO conversations (id, title, created_at, updated_at, role_name) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO conversations (id, title, created_at, updated_at, role_name, agent_mode) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
id, title, now, now, roleName,
|
id, title, now, now, roleName, agentMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,6 +95,7 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
|||||||
Title: title,
|
Title: title,
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
RoleName: roleName,
|
RoleName: roleName,
|
||||||
|
AgentMode: agentMode,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
@@ -240,10 +243,11 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
|
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
|
var agentMode sql.NullString
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
|
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||||
id,
|
id,
|
||||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("对话不存在")
|
return nil, fmt.Errorf("对话不存在")
|
||||||
@@ -256,6 +260,9 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
@@ -330,10 +337,11 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
|
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
|
var agentMode sql.NullString
|
||||||
err := db.QueryRow(
|
err := db.QueryRow(
|
||||||
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
|
"SELECT id, title, pinned, created_at, updated_at, project_id, role_name, agent_mode FROM conversations WHERE id = ?",
|
||||||
id,
|
id,
|
||||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, fmt.Errorf("对话不存在")
|
return nil, fmt.Errorf("对话不存在")
|
||||||
@@ -346,6 +354,9 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
|
|
||||||
// 尝试多种时间格式解析
|
// 尝试多种时间格式解析
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
@@ -384,6 +395,17 @@ func normalizeConversationRoleName(roleName string) string {
|
|||||||
return roleName
|
return roleName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeConversationAgentMode(agentMode string) string {
|
||||||
|
agentMode = strings.ToLower(strings.TrimSpace(agentMode))
|
||||||
|
agentMode = strings.ReplaceAll(agentMode, "-", "_")
|
||||||
|
switch agentMode {
|
||||||
|
case "deep", "plan_execute", "supervisor":
|
||||||
|
return agentMode
|
||||||
|
default:
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
func (db *DB) SetConversationRoleName(id, roleName string) error {
|
||||||
roleName = normalizeConversationRoleName(roleName)
|
roleName = normalizeConversationRoleName(roleName)
|
||||||
_, err := db.Exec(
|
_, err := db.Exec(
|
||||||
@@ -396,6 +418,18 @@ func (db *DB) SetConversationRoleName(id, roleName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) SetConversationAgentMode(id, agentMode string) error {
|
||||||
|
agentMode = normalizeConversationAgentMode(agentMode)
|
||||||
|
_, err := db.Exec(
|
||||||
|
"UPDATE conversations SET agent_mode = ? WHERE id = ?",
|
||||||
|
agentMode, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("更新对话模式失败: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func conversationProjectIDColumn(alias string) string {
|
func conversationProjectIDColumn(alias string) string {
|
||||||
if alias != "" {
|
if alias != "" {
|
||||||
return alias + ".project_id"
|
return alias + ".project_id"
|
||||||
@@ -520,7 +554,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
|||||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||||
FROM conversations c`+where+`
|
FROM conversations c`+where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -536,7 +570,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
|||||||
}
|
}
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
args...,
|
args...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -564,7 +598,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
|||||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode
|
||||||
FROM conversations c`+where+`
|
FROM conversations c`+where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`, args...)
|
LIMIT ? OFFSET ?`, args...)
|
||||||
@@ -579,7 +613,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
|||||||
}
|
}
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name, agent_mode FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
|
||||||
args...)
|
args...)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -597,7 +631,8 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
|||||||
var pinned int
|
var pinned int
|
||||||
var projectID sql.NullString
|
var projectID sql.NullString
|
||||||
var roleName sql.NullString
|
var roleName sql.NullString
|
||||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName); err != nil {
|
var agentMode sql.NullString
|
||||||
|
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName, &agentMode); err != nil {
|
||||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||||
}
|
}
|
||||||
if projectID.Valid {
|
if projectID.Valid {
|
||||||
@@ -606,6 +641,9 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
|||||||
if roleName.Valid {
|
if roleName.Valid {
|
||||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||||
}
|
}
|
||||||
|
if agentMode.Valid {
|
||||||
|
conv.AgentMode = normalizeConversationAgentMode(agentMode.String)
|
||||||
|
}
|
||||||
var err1, err2 error
|
var err1, err2 error
|
||||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||||
if err1 != nil {
|
if err1 != nil {
|
||||||
@@ -665,7 +703,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
|||||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||||
where+`
|
where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -689,7 +727,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
|
|||||||
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name, c.agent_mode `+
|
||||||
where+`
|
where+`
|
||||||
`+orderClause+`
|
`+orderClause+`
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
@@ -1386,6 +1424,7 @@ type ProcessDetailsSummary struct {
|
|||||||
|
|
||||||
type ProcessDetailsToolExecution struct {
|
type ProcessDetailsToolExecution struct {
|
||||||
ProcessDetailID string `json:"processDetailId,omitempty"`
|
ProcessDetailID string `json:"processDetailId,omitempty"`
|
||||||
|
ResultDetailID string `json:"resultDetailId,omitempty"`
|
||||||
ToolName string `json:"toolName,omitempty"`
|
ToolName string `json:"toolName,omitempty"`
|
||||||
ToolCallID string `json:"toolCallId,omitempty"`
|
ToolCallID string `json:"toolCallId,omitempty"`
|
||||||
ExecutionID string `json:"executionId,omitempty"`
|
ExecutionID string `json:"executionId,omitempty"`
|
||||||
@@ -1514,6 +1553,7 @@ func (db *DB) GetProcessDetailsSummary(messageID string) (*ProcessDetailsSummary
|
|||||||
if toolCallID != "" {
|
if toolCallID != "" {
|
||||||
lastMatchedToolIndexByCallID[toolCallID] = idx
|
lastMatchedToolIndexByCallID[toolCallID] = idx
|
||||||
}
|
}
|
||||||
|
summary.ToolExecutions[idx].ResultDetailID = strings.TrimSpace(detailID)
|
||||||
if summary.ToolExecutions[idx].ToolName == "" {
|
if summary.ToolExecutions[idx].ToolName == "" {
|
||||||
summary.ToolExecutions[idx].ToolName = toolName
|
summary.ToolExecutions[idx].ToolName = toolName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ type ConversationCreateMeta struct {
|
|||||||
WebShellConnectionID string
|
WebShellConnectionID string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
RoleName string
|
RoleName string
|
||||||
|
AgentMode string
|
||||||
ClientIP string
|
ClientIP string
|
||||||
SessionHint string
|
SessionHint string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func (db *DB) initTables() error {
|
|||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
role_name TEXT NOT NULL DEFAULT '默认',
|
role_name TEXT NOT NULL DEFAULT '默认',
|
||||||
|
agent_mode TEXT NOT NULL DEFAULT 'eino_single',
|
||||||
last_react_input TEXT,
|
last_react_input TEXT,
|
||||||
last_react_output TEXT
|
last_react_output TEXT
|
||||||
);`
|
);`
|
||||||
@@ -420,6 +421,7 @@ func (db *DB) initTables() error {
|
|||||||
responsible_person TEXT NOT NULL DEFAULT '', department TEXT NOT NULL DEFAULT '', business_system TEXT NOT NULL DEFAULT '',
|
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 '',
|
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',
|
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,
|
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,
|
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
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE SET NULL
|
||||||
@@ -744,6 +746,9 @@ func (db *DB) initTables() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
CREATE INDEX IF NOT EXISTS idx_assets_status ON assets(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_assets_owner ON assets(owner_user_id);
|
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_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_status ON projects(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
|
CREATE INDEX IF NOT EXISTS idx_projects_updated_at ON projects(updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||||
@@ -976,7 +981,6 @@ func (db *DB) initTables() error {
|
|||||||
if _, err := db.Exec(createIndexes); err != nil {
|
if _, err := db.Exec(createIndexes); err != nil {
|
||||||
return fmt.Errorf("创建索引失败: %w", err)
|
return fmt.Errorf("创建索引失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.logger.Debug("数据库表初始化完成")
|
db.logger.Debug("数据库表初始化完成")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1026,6 +1030,9 @@ func (db *DB) migrateAssetsTable() error {
|
|||||||
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
|
{"business_system", "ALTER TABLE assets ADD COLUMN business_system TEXT NOT NULL DEFAULT ''"},
|
||||||
{"environment", "ALTER TABLE assets ADD COLUMN environment 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 ''"},
|
{"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 {
|
for _, column := range columns {
|
||||||
var count int
|
var count int
|
||||||
@@ -1174,6 +1181,21 @@ func (db *DB) migrateConversationsTable() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 agent_mode 字段是否存在(对话绑定的执行模式,用于历史任务切换时恢复对话模式)
|
||||||
|
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='agent_mode'").Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); addErr != nil {
|
||||||
|
errMsg := strings.ToLower(addErr.Error())
|
||||||
|
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(addErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if count == 0 {
|
||||||
|
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN agent_mode TEXT NOT NULL DEFAULT 'eino_single'"); err != nil {
|
||||||
|
db.logger.Warn("添加agent_mode字段失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
|||||||
{"toolName": "http-framework-test", "success": true},
|
{"toolName": "http-framework-test", "success": true},
|
||||||
{"toolName": "http-framework-test", "success": true},
|
{"toolName": "http-framework-test", "success": true},
|
||||||
}
|
}
|
||||||
|
var resultIDs []string
|
||||||
for _, result := range results {
|
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)
|
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||||
}
|
}
|
||||||
|
resultIDs = append(resultIDs, resultID)
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||||
@@ -39,6 +42,9 @@ func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
|||||||
if execution.Status != "completed" {
|
if execution.Status != "completed" {
|
||||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
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] {
|
for i, execution := range summary.ToolExecutions[2:4] {
|
||||||
if execution.Status != "result_missing" {
|
if execution.Status != "result_missing" {
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建漏洞失败: %w", err)
|
return nil, fmt.Errorf("创建漏洞失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(vuln.ConversationID)
|
||||||
return vuln, nil
|
return vuln, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,6 +300,8 @@ func (db *DB) CountVulnerabilitiesForAccess(filter VulnerabilityListFilter, acce
|
|||||||
// UpdateVulnerability 更新漏洞
|
// UpdateVulnerability 更新漏洞
|
||||||
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
||||||
vuln.UpdatedAt = time.Now()
|
vuln.UpdatedAt = time.Now()
|
||||||
|
var oldConversationID string
|
||||||
|
_ = db.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&oldConversationID)
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE vulnerabilities
|
UPDATE vulnerabilities
|
||||||
@@ -318,6 +321,7 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
|||||||
return fmt.Errorf("更新漏洞失败: %w", err)
|
return fmt.Errorf("更新漏洞失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(oldConversationID, vuln.ConversationID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +341,10 @@ func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFil
|
|||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
where, args = filter.appendWhere(where, args)
|
where, args = filter.appendWhere(where, args)
|
||||||
where, args = appendVulnerabilityAccessFilter(where, args, access)
|
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
|
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
|
||||||
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return 0, fmt.Errorf("提交事务失败: %w", err)
|
return 0, fmt.Errorf("提交事务失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(affectedConversations...)
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +375,8 @@ func (db *DB) DeleteVulnerability(id string) error {
|
|||||||
return fmt.Errorf("开启事务失败: %w", err)
|
return fmt.Errorf("开启事务失败: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
var conversationID string
|
||||||
|
_ = tx.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&conversationID)
|
||||||
|
|
||||||
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
|
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
|
||||||
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
|
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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("提交事务失败: %w", err)
|
return fmt.Errorf("提交事务失败: %w", err)
|
||||||
}
|
}
|
||||||
|
db.refreshAssetRiskCacheForConversationsBestEffort(conversationID)
|
||||||
return nil
|
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 一致)
|
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
|
||||||
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
|
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
|
||||||
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
|
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
|
||||||
|
|||||||
@@ -333,6 +333,12 @@ type ChatReasoningRequest struct {
|
|||||||
Effort string `json:"effort,omitempty"`
|
Effort string `json:"effort,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ChatFinalizationRequest is a caller-provided delivery policy. The server does
|
||||||
|
// not infer execution intent from natural-language user text.
|
||||||
|
type ChatFinalizationRequest struct {
|
||||||
|
RequireExecutionEvidence *bool `json:"requireExecutionEvidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChatRequest 聊天请求
|
// ChatRequest 聊天请求
|
||||||
type ChatRequest struct {
|
type ChatRequest struct {
|
||||||
Message string `json:"message" binding:"required"`
|
Message string `json:"message" binding:"required"`
|
||||||
@@ -344,6 +350,7 @@ type ChatRequest struct {
|
|||||||
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
AIChannelID string `json:"aiChannelId,omitempty"` // 会话级 AI 通道;空则使用 ai.default_channel
|
||||||
Hitl *HITLRequest `json:"hitl,omitempty"`
|
Hitl *HITLRequest `json:"hitl,omitempty"`
|
||||||
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
Reasoning *ChatReasoningRequest `json:"reasoning,omitempty"`
|
||||||
|
Finalization ChatFinalizationRequest `json:"finalization,omitempty"`
|
||||||
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
// Orchestration 仅对 /api/multi-agent、/api/multi-agent/stream:deep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。
|
||||||
Orchestration string `json:"orchestration,omitempty"`
|
Orchestration string `json:"orchestration,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -672,6 +679,14 @@ type ChatResponse struct {
|
|||||||
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
MCPExecutionIDs []string `json:"mcpExecutionIds,omitempty"` // 本次对话中执行的MCP调用ID列表
|
||||||
ConversationID string `json:"conversationId"` // 对话ID
|
ConversationID string `json:"conversationId"` // 对话ID
|
||||||
Time time.Time `json:"time"`
|
Time time.Time `json:"time"`
|
||||||
|
Finalizable bool `json:"finalizable"`
|
||||||
|
Finalized bool `json:"finalized"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
CompletionReason string `json:"completionReason,omitempty"`
|
||||||
|
EvidenceVerified bool `json:"evidenceVerified"`
|
||||||
|
EvidenceRefs []string `json:"evidenceRefs,omitempty"`
|
||||||
|
PendingExecutionIDs []string `json:"pendingExecutionIds,omitempty"`
|
||||||
|
MissingChecks []string `json:"missingChecks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMessageID, conversationID string, resultMA *multiagent.RunResult, errMA error) (string, string, error) {
|
||||||
@@ -687,19 +702,20 @@ func (h *AgentHandler) finalizeRobotAgentError(ctx context.Context, assistantMes
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
func (h *AgentHandler) finalizeRobotAgentSuccess(assistantMessageID, conversationID string, resultMA *multiagent.RunResult) (string, string, error) {
|
||||||
if assistantMessageID != "" {
|
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "robot", resultMA, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput), true)
|
||||||
if errU := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resultMA.Response, resultMA.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(resultMA.LastAgentTraceInput)); errU != nil {
|
responseText := decision.FinalText
|
||||||
h.logger.Warn("机器人:更新助手消息失败", zap.Error(errU))
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
}
|
}
|
||||||
} else {
|
if assistantMessageID == "" {
|
||||||
if _, err := h.db.AddMessage(conversationID, "assistant", resultMA.Response, resultMA.MCPExecutionIDs); err != nil {
|
if _, err := h.db.AddMessage(conversationID, "assistant", responseText, resultMA.MCPExecutionIDs); err != nil {
|
||||||
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
h.logger.Warn("机器人:保存助手消息失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
if resultMA.LastAgentTraceInput != "" || resultMA.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(conversationID, resultMA.LastAgentTraceInput, resultMA.LastAgentTraceOutput)
|
||||||
}
|
}
|
||||||
return resultMA.Response, conversationID, nil
|
return responseText, conversationID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
func (h *AgentHandler) runRobotEinoSingleWithRetry(
|
||||||
@@ -830,6 +846,9 @@ func (h *AgentHandler) ProcessMessageForRobot(ctx context.Context, platform stri
|
|||||||
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
progressCallback := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
||||||
|
|
||||||
robotMode := config.NormalizeAgentMode(agentMode)
|
robotMode := config.NormalizeAgentMode(agentMode)
|
||||||
|
if err := h.db.SetConversationAgentMode(conversationID, robotMode); err != nil {
|
||||||
|
h.logger.Warn("机器人:更新对话模式失败", zap.String("conversationId", conversationID), zap.String("agentMode", robotMode), zap.Error(err))
|
||||||
|
}
|
||||||
switch robotMode {
|
switch robotMode {
|
||||||
case "eino_single":
|
case "eino_single":
|
||||||
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
return h.runRobotEinoSingleWithRetry(taskCtx, conversationID, finalMessage, agentHistoryMessages, roleTools, progressCallback, assistantMessageID, &taskStatus)
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
useBatchMulti = true
|
useBatchMulti = true
|
||||||
batchOrch = "deep"
|
batchOrch = "deep"
|
||||||
}
|
}
|
||||||
|
if useBatchMulti {
|
||||||
|
_ = h.db.SetConversationAgentMode(conversationID, batchOrch)
|
||||||
|
} else {
|
||||||
|
_ = h.db.SetConversationAgentMode(conversationID, "eino_single")
|
||||||
|
}
|
||||||
|
|
||||||
var resultMA *multiagent.RunResult
|
var resultMA *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
@@ -268,19 +273,38 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
|
|
||||||
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
h.logger.Info("批量任务执行成功", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID))
|
||||||
|
|
||||||
resText := resultMA.Response
|
|
||||||
mcpIDs := resultMA.MCPExecutionIDs
|
mcpIDs := resultMA.MCPExecutionIDs
|
||||||
lastIn := resultMA.LastAgentTraceInput
|
lastIn := resultMA.LastAgentTraceInput
|
||||||
lastOut := resultMA.LastAgentTraceOutput
|
lastOut := resultMA.LastAgentTraceOutput
|
||||||
|
reasoningContent := multiagent.AggregatedReasoningFromTraceJSON(lastIn)
|
||||||
|
agentMode := "batch_eino_single"
|
||||||
|
if useBatchMulti {
|
||||||
|
agentMode = "batch_eino_" + batchOrch
|
||||||
|
}
|
||||||
|
decision := h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, resultMA, mcpIDs, reasoningContent, true)
|
||||||
|
resText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
resText = finalizationBlockedMessage(decision)
|
||||||
|
finishStatus = decision.Status
|
||||||
|
sendEvent("finalization_check", resText, decision)
|
||||||
|
}
|
||||||
|
sendEvent("response", resText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"messageId": assistantMessageID,
|
||||||
|
"agentMode": agentMode,
|
||||||
|
"mcpExecutionIds": mcpIDs,
|
||||||
|
"batchQueueId": queueID,
|
||||||
|
"batchTaskId": task.ID,
|
||||||
|
"batchTaskStatus": map[bool]string{true: string(BatchTaskStatusCompleted), false: string(BatchTaskStatusFailed)}[decision.Finalizable],
|
||||||
|
"candidatePreview": safeTruncateString(resultMA.Response, 500),
|
||||||
|
}))
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if assistantMessageID == "" {
|
||||||
if updateErr := h.db.UpdateAssistantMessageFinalize(assistantMessageID, resText, mcpIDs, multiagent.AggregatedReasoningFromTraceJSON(lastIn)); updateErr != nil {
|
_, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs)
|
||||||
h.logger.Warn("更新助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.Error(updateErr))
|
} else if !decision.Finalizable {
|
||||||
if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
err = nil
|
||||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
|
||||||
}
|
}
|
||||||
}
|
if err != nil {
|
||||||
} else if _, err = h.db.AddMessage(conversationID, "assistant", resText, mcpIDs); err != nil {
|
|
||||||
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
h.logger.Error("保存助手消息失败", zap.String("queueId", queueID), zap.String("taskId", task.ID), zap.String("conversationId", conversationID), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +314,10 @@ func (h *AgentHandler) executeOneBatchSubTask(queueID string, queue *BatchTaskQu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !decision.Finalizable {
|
||||||
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusFailed, resText, finalizationCheckMessage(decision), conversationID)
|
||||||
|
return
|
||||||
|
}
|
||||||
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
h.batchTaskManager.UpdateTaskStatusWithConversationID(queueID, task.ID, BatchTaskStatusCompleted, resText, "", conversationID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,7 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
|||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
}
|
}
|
||||||
|
|
||||||
inject := multiagent.FormatEmptyResponseContinueUserMessage()
|
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, "")
|
||||||
h.applyEinoTraceResumeSegment(conversationID, result, curHistory, curFinalMessage, inject)
|
|
||||||
if progressCallback != nil {
|
if progressCallback != nil {
|
||||||
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
progressCallback("eino_empty_response_continue", "已恢复上下文,正在续跑…", map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
@@ -77,6 +76,7 @@ func (h *AgentHandler) tryContinueOnEinoEmptyResponse(
|
|||||||
"attempt": *attempt,
|
"attempt": *attempt,
|
||||||
"maxAttempts": maxAttempts,
|
"maxAttempts": maxAttempts,
|
||||||
"contextSource": "empty_response_continue",
|
"contextSource": "empty_response_continue",
|
||||||
|
"contextInjection": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
@@ -189,6 +190,8 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -258,6 +261,13 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -358,9 +368,10 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if decision.CompletionReason == "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, "eino_single", result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
}
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, "eino_single", cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -368,12 +379,19 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
}
|
||||||
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
})
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +447,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
result, runErr = multiagent.RunEinoSingleChatModelAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -446,28 +467,46 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(prep.ConversationID),
|
h.agentSessionContextBlock(prep.ConversationID),
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr != nil {
|
||||||
break
|
|
||||||
}
|
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": runErr.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
if prep.AssistantMessageID != "" {
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, "eino_single", result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, "eino_single", result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
_ = h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": result.Response,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "eino_single",
|
"agentMode": "eino_single",
|
||||||
|
"finalized": decision.Finalized,
|
||||||
|
"finalizable": decision.Finalizable,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agent"
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const finalizationAutoContinueMaxAttempts = 2
|
||||||
|
|
||||||
|
func shouldAutoContinueAfterFinalization(d agentfinalizer.Decision, attempt int) bool {
|
||||||
|
if d.Finalizable || d.Finalized {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attempt >= finalizationAutoContinueMaxAttempts {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return d.CompletionReason == agentfinalizer.ReasonMissingEvidence
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) tryAutoContinueAfterFinalization(
|
||||||
|
taskCtx context.Context,
|
||||||
|
conversationID string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
attempt *int,
|
||||||
|
curHistory *[]agent.ChatMessage,
|
||||||
|
curFinalMessage *string,
|
||||||
|
progressCallback func(eventType, message string, data interface{}),
|
||||||
|
) bool {
|
||||||
|
if !shouldAutoContinueAfterFinalization(decision, *attempt) || result == nil || !multiagent.HasEinoResumeTrace(result) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*attempt++
|
||||||
|
h.persistEinoAgentTraceForResume(conversationID, result)
|
||||||
|
if hist, err := h.loadHistoryFromAgentTrace(conversationID); err == nil && len(hist) > 0 {
|
||||||
|
*curHistory = hist
|
||||||
|
} else if h.logger != nil {
|
||||||
|
h.logger.Warn("finalization auto-continue could not restore trace",
|
||||||
|
zap.String("conversationId", conversationID),
|
||||||
|
zap.Error(err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Agent 无感续跑:不追加新的 user/system 文案,只使用上一段模型可见轨迹继续 Runner。
|
||||||
|
*curFinalMessage = ""
|
||||||
|
if progressCallback != nil {
|
||||||
|
progressCallback("finalization_auto_continue", "最终回复检查尚未收敛,正在基于已有轨迹继续执行…", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "finalizer",
|
||||||
|
"attempt": *attempt,
|
||||||
|
"maxAttempts": finalizationAutoContinueMaxAttempts,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"contextInjection": false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-taskCtx.Done():
|
||||||
|
return false
|
||||||
|
case <-time.After(finalizationAutoContinueBackoff(*attempt)):
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationAutoContinueBackoff(attempt int) time.Duration {
|
||||||
|
if attempt <= 1 {
|
||||||
|
return 500 * time.Millisecond
|
||||||
|
}
|
||||||
|
return time.Duration(attempt) * time.Second
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldAutoContinueAfterFinalization(t *testing.T) {
|
||||||
|
missingEvidence := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusBlocked,
|
||||||
|
CompletionReason: agentfinalizer.ReasonMissingEvidence,
|
||||||
|
}
|
||||||
|
if !shouldAutoContinueAfterFinalization(missingEvidence, 0) {
|
||||||
|
t.Fatal("missing execution evidence should trigger auto-continue")
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(missingEvidence, finalizationAutoContinueMaxAttempts) {
|
||||||
|
t.Fatal("auto-continue should stop at max attempts")
|
||||||
|
}
|
||||||
|
|
||||||
|
finalized := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusCompleted,
|
||||||
|
CompletionReason: agentfinalizer.ReasonVerified,
|
||||||
|
Finalizable: true,
|
||||||
|
Finalized: true,
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(finalized, 0) {
|
||||||
|
t.Fatal("finalized decision should not auto-continue")
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitingHITL := agentfinalizer.Decision{
|
||||||
|
Status: agentfinalizer.StatusAwaitingHITL,
|
||||||
|
CompletionReason: agentfinalizer.ReasonAwaitingHITL,
|
||||||
|
}
|
||||||
|
if shouldAutoContinueAfterFinalization(awaitingHITL, 0) {
|
||||||
|
t.Fatal("awaiting HITL should not auto-continue without approval")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestRequiresExecutionEvidenceUsesExplicitPolicyOnly(t *testing.T) {
|
||||||
|
if requestRequiresExecutionEvidence(nil) {
|
||||||
|
t.Fatal("nil request should not require execution evidence")
|
||||||
|
}
|
||||||
|
if requestRequiresExecutionEvidence(&ChatRequest{}) {
|
||||||
|
t.Fatal("missing finalization policy should not require execution evidence")
|
||||||
|
}
|
||||||
|
require := true
|
||||||
|
if !requestRequiresExecutionEvidence(&ChatRequest{
|
||||||
|
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||||
|
}) {
|
||||||
|
t.Fatal("explicit true policy should require execution evidence")
|
||||||
|
}
|
||||||
|
require = false
|
||||||
|
if requestRequiresExecutionEvidence(&ChatRequest{
|
||||||
|
Finalization: ChatFinalizationRequest{RequireExecutionEvidence: &require},
|
||||||
|
}) {
|
||||||
|
t.Fatal("explicit false policy should not require execution evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
|
"cyberstrike-ai/internal/multiagent"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeAgentRunForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return h.finalizeAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, mcpExecutionIDs, reasoningContent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeAgentRunForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
decision := agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, mcpExecutionIDs, reasoningContent, decision)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) decideAgentRunForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) decideAgentRunForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
result *multiagent.RunResult,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return agentfinalizer.FromRunResult(h.db, result, agentfinalizer.Input{
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
RequireExecutionEvidence: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) persistFinalizationDecision(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
reasoningContent string,
|
||||||
|
decision agentfinalizer.Decision,
|
||||||
|
) {
|
||||||
|
if assistantMessageID == "" || h.db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||||
|
if decision.Finalizable {
|
||||||
|
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||||
|
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeCandidateForDelivery(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
response string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
awaitingHITL bool,
|
||||||
|
reasoningContent string,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
return h.finalizeCandidateForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, response, mcpExecutionIDs, awaitingHITL, reasoningContent, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AgentHandler) finalizeCandidateForDeliveryWithPolicy(
|
||||||
|
conversationID string,
|
||||||
|
assistantMessageID string,
|
||||||
|
agentMode string,
|
||||||
|
response string,
|
||||||
|
mcpExecutionIDs []string,
|
||||||
|
awaitingHITL bool,
|
||||||
|
reasoningContent string,
|
||||||
|
requireExecutionEvidence bool,
|
||||||
|
) agentfinalizer.Decision {
|
||||||
|
decision := agentfinalizer.Decide(h.db, agentfinalizer.Input{
|
||||||
|
Response: response,
|
||||||
|
ConversationID: conversationID,
|
||||||
|
AssistantMessageID: assistantMessageID,
|
||||||
|
AgentMode: agentMode,
|
||||||
|
MCPExecutionIDs: mcpExecutionIDs,
|
||||||
|
AwaitingHITL: awaitingHITL,
|
||||||
|
RequireExecutionEvidence: requireExecutionEvidence,
|
||||||
|
})
|
||||||
|
if assistantMessageID == "" || h.db == nil {
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "finalization_check", finalizationCheckMessage(decision), decision)
|
||||||
|
if decision.Finalizable {
|
||||||
|
if err := h.db.UpdateAssistantMessageFinalize(assistantMessageID, decision.FinalText, mcpExecutionIDs, reasoningContent); err != nil && h.logger != nil {
|
||||||
|
h.logger.Warn("更新最终助手消息失败", zap.Error(err), zap.String("conversationId", conversationID), zap.String("agentMode", agentMode))
|
||||||
|
}
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", finalizationBlockedMessage(decision), time.Now(), assistantMessageID)
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationCheckMessage(d agentfinalizer.Decision) string {
|
||||||
|
if d.Finalizable {
|
||||||
|
return "最终回复检查通过。"
|
||||||
|
}
|
||||||
|
return finalizationBlockedMessage(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationBlockedMessage(d agentfinalizer.Decision) string {
|
||||||
|
parts := []string{"任务尚未达到最终回复条件,暂不生成成功结论。"}
|
||||||
|
if d.CompletionReason != "" {
|
||||||
|
parts = append(parts, "原因: "+d.CompletionReason)
|
||||||
|
}
|
||||||
|
if len(d.PendingExecutionIDs) > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("仍有 %d 个工具执行未结束: %s", len(d.PendingExecutionIDs), strings.Join(d.PendingExecutionIDs, ", ")))
|
||||||
|
}
|
||||||
|
if len(d.MissingChecks) > 0 {
|
||||||
|
parts = append(parts, "缺失检查: "+strings.Join(d.MissingChecks, "; "))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalizationResponsePayload(d agentfinalizer.Decision, extra map[string]interface{}) map[string]interface{} {
|
||||||
|
return agentfinalizer.ResponsePayload(d, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRequiresExecutionEvidence(req *ChatRequest) bool {
|
||||||
|
return req != nil && req.Finalization.RequireExecutionEvidence != nil && *req.Finalization.RequireExecutionEvidence
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ func (h *MonitorHandler) SetAgentHandler(ah *AgentHandler) {
|
|||||||
h.agentHandler = ah
|
h.agentHandler = ah
|
||||||
}
|
}
|
||||||
|
|
||||||
const monitorPageTopTools = 3
|
const monitorPageTopTools = 6
|
||||||
|
|
||||||
// MonitorStatsSummary 工具调用汇总
|
// MonitorStatsSummary 工具调用汇总
|
||||||
type MonitorStatsSummary struct {
|
type MonitorStatsSummary struct {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/agentfinalizer"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/multiagent"
|
"cyberstrike-ai/internal/multiagent"
|
||||||
@@ -197,6 +198,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
// 同一请求内分段续跑时,主代理 iteration 事件按偏移累计,避免 UI 出现「第3轮 → 第1轮」回跳。
|
||||||
var mainIterationOffset int
|
var mainIterationOffset int
|
||||||
var emptyResponseContinueAttempt int
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||||
|
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||||
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
|
}
|
||||||
|
agentMode := "eino_" + effectiveOrch
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
|
|
||||||
for {
|
for {
|
||||||
segmentMainIterationMax := 0
|
segmentMainIterationMax := 0
|
||||||
@@ -267,6 +275,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, conversationID, result, decision, &finalizationAutoContinueAttempt, &curHistory, &curFinalMessage, progressCallback) {
|
||||||
|
mainIterationOffset += segmentMainIterationMax
|
||||||
|
timeoutCancel()
|
||||||
|
baseCtx, cancelWithCause, taskCtx, timeoutCancel = h.rebindEinoRunningTask(taskCtx, conversationID, timeoutCancel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -367,9 +382,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
timeoutCancel()
|
timeoutCancel()
|
||||||
|
|
||||||
if assistantMessageID != "" {
|
if decision.CompletionReason == "" {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(assistantMessageID, result.Response, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
decision = h.decideAgentRunForDeliveryWithPolicy(conversationID, assistantMessageID, agentMode, result, cumulativeMCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
}
|
}
|
||||||
|
h.persistFinalizationDecision(conversationID, assistantMessageID, agentMode, cumulativeMCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(conversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -377,16 +393,19 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
responseText := decision.FinalText
|
||||||
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
if !decision.Finalizable {
|
||||||
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, map[string]interface{}{
|
sendEvent("response", responseText, finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
"mcpExecutionIds": cumulativeMCPExecutionIDs,
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"messageId": assistantMessageID,
|
"messageId": assistantMessageID,
|
||||||
"agentMode": "eino_" + effectiveOrch,
|
"agentMode": agentMode,
|
||||||
})
|
}))
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,6 +456,14 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
curMsg := prep.FinalMessage
|
curMsg := prep.FinalMessage
|
||||||
var result *multiagent.RunResult
|
var result *multiagent.RunResult
|
||||||
var runErr error
|
var runErr error
|
||||||
|
var emptyResponseContinueAttempt int
|
||||||
|
var finalizationAutoContinueAttempt int
|
||||||
|
effectiveOrch := config.NormalizeMultiAgentOrchestration(h.config.MultiAgent.Orchestration)
|
||||||
|
if o := strings.TrimSpace(req.Orchestration); o != "" {
|
||||||
|
effectiveOrch = config.NormalizeMultiAgentOrchestration(o)
|
||||||
|
}
|
||||||
|
agentMode := "eino_" + effectiveOrch
|
||||||
|
var decision agentfinalizer.Decision
|
||||||
for {
|
for {
|
||||||
result, runErr = multiagent.RunDeepAgent(
|
result, runErr = multiagent.RunDeepAgent(
|
||||||
taskCtx,
|
taskCtx,
|
||||||
@@ -456,9 +483,7 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
chatReasoningToClientIntent(req.Reasoning),
|
chatReasoningToClientIntent(req.Reasoning),
|
||||||
h.agentSessionContextBlock(prep.ConversationID),
|
h.agentSessionContextBlock(prep.ConversationID),
|
||||||
)
|
)
|
||||||
if runErr == nil {
|
if runErr != nil {
|
||||||
break
|
|
||||||
}
|
|
||||||
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
if shouldPersistEinoAgentTraceAfterRunError(baseCtx) {
|
||||||
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
h.persistEinoAgentTraceForResume(prep.ConversationID, result)
|
||||||
}
|
}
|
||||||
@@ -470,10 +495,18 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mw := &h.config.MultiAgent.EinoMiddleware
|
||||||
if prep.AssistantMessageID != "" {
|
if h.tryContinueOnEinoEmptyResponse(taskCtx, mw, prep.ConversationID, result, &emptyResponseContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput))
|
continue
|
||||||
}
|
}
|
||||||
|
decision = h.decideAgentRunForDeliveryWithPolicy(prep.ConversationID, prep.AssistantMessageID, agentMode, result, result.MCPExecutionIDs, requestRequiresExecutionEvidence(&req))
|
||||||
|
if h.tryAutoContinueAfterFinalization(taskCtx, prep.ConversationID, result, decision, &finalizationAutoContinueAttempt, &curHist, &curMsg, progressCallback) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
h.persistFinalizationDecision(prep.ConversationID, prep.AssistantMessageID, agentMode, result.MCPExecutionIDs, multiagent.AggregatedReasoningFromTraceJSON(result.LastAgentTraceInput), decision)
|
||||||
|
|
||||||
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
if result.LastAgentTraceInput != "" || result.LastAgentTraceOutput != "" {
|
||||||
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
if err := h.db.SaveAgentTrace(prep.ConversationID, result.LastAgentTraceInput, result.LastAgentTraceOutput); err != nil {
|
||||||
@@ -481,11 +514,23 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, ChatResponse{
|
c.JSON(http.StatusOK, ChatResponse{
|
||||||
Response: result.Response,
|
Response: responseText,
|
||||||
MCPExecutionIDs: result.MCPExecutionIDs,
|
MCPExecutionIDs: result.MCPExecutionIDs,
|
||||||
ConversationID: prep.ConversationID,
|
ConversationID: prep.ConversationID,
|
||||||
Time: time.Now(),
|
Time: time.Now(),
|
||||||
|
Finalizable: decision.Finalizable,
|
||||||
|
Finalized: decision.Finalized,
|
||||||
|
Status: decision.Status,
|
||||||
|
CompletionReason: decision.CompletionReason,
|
||||||
|
EvidenceVerified: decision.EvidenceVerified,
|
||||||
|
EvidenceRefs: decision.EvidenceRefs,
|
||||||
|
PendingExecutionIDs: decision.PendingExecutionIDs,
|
||||||
|
MissingChecks: decision.MissingChecks,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/audit"
|
"cyberstrike-ai/internal/audit"
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/database"
|
"cyberstrike-ai/internal/database"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/security"
|
"cyberstrike-ai/internal/security"
|
||||||
@@ -25,6 +26,13 @@ type multiAgentPrepared struct {
|
|||||||
UserMessageID string
|
UserMessageID string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func chatRequestAgentMode(req *ChatRequest, source string) string {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(source), "multi_agent") {
|
||||||
|
return config.NormalizeMultiAgentOrchestration(req.Orchestration)
|
||||||
|
}
|
||||||
|
return "eino_single"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context, source string) (*multiAgentPrepared, error) {
|
||||||
if len(req.Attachments) > maxAttachments {
|
if len(req.Attachments) > maxAttachments {
|
||||||
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
return nil, fmt.Errorf("附件最多 %d 个", maxAttachments)
|
||||||
@@ -57,6 +65,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
|||||||
meta := audit.ConversationCreateMetaFromGin(c, source)
|
meta := audit.ConversationCreateMetaFromGin(c, source)
|
||||||
meta.ProjectID = projectID
|
meta.ProjectID = projectID
|
||||||
meta.RoleName = req.Role
|
meta.RoleName = req.Role
|
||||||
|
meta.AgentMode = chatRequestAgentMode(req, source)
|
||||||
if webshellID != "" {
|
if webshellID != "" {
|
||||||
meta.Source = source + "_webshell"
|
meta.Source = source + "_webshell"
|
||||||
meta.WebShellConnectionID = webshellID
|
meta.WebShellConnectionID = webshellID
|
||||||
@@ -84,6 +93,9 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
|||||||
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
|
||||||
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
if err := h.db.SetConversationAgentMode(conversationID, chatRequestAgentMode(req, source)); err != nil {
|
||||||
|
h.logger.Warn("更新对话模式失败", zap.String("conversationId", conversationID), zap.String("source", source), zap.String("orchestration", req.Orchestration), zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalizationRequestSchema := map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "最终回复交付策略。后端不会从自然语言内容推断执行意图;执行入口应显式声明是否要求 completed 工具证据。",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"requireExecutionEvidence": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "为 true 时,缺少 completed 工具执行记录会触发无注入续跑或最终阻断;普通聊天可省略或设为 false。",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
spec := map[string]interface{}{
|
spec := map[string]interface{}{
|
||||||
"openapi": "3.0.0",
|
"openapi": "3.0.0",
|
||||||
"info": map[string]interface{}{
|
"info": map[string]interface{}{
|
||||||
@@ -85,6 +96,70 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"required": []string{"projectId"},
|
"required": []string{"projectId"},
|
||||||
},
|
},
|
||||||
|
"AgentChatResponse": map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Agent 非流式响应。response 只是交付文本;是否为成功最终回复必须以 finalized/finalizable/status 为准。",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"response": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "交付给用户的文本。finalized=false 时为阻断/未完成说明,不是成功结论。",
|
||||||
|
},
|
||||||
|
"conversationId": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "对话 ID",
|
||||||
|
},
|
||||||
|
"assistantMessageId": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "助手消息 ID(部分接口返回)",
|
||||||
|
},
|
||||||
|
"mcpExecutionIds": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "本轮关联的 MCP 工具执行 ID",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"agentMode": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "agent 模式,例如 eino_single、eino_deep、workflow",
|
||||||
|
},
|
||||||
|
"finalized": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否已经通过最终回复检查。只有 true 才能当成功最终回复。",
|
||||||
|
},
|
||||||
|
"finalizable": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "候选输出是否可提升为最终回复。",
|
||||||
|
},
|
||||||
|
"status": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "最终化状态",
|
||||||
|
"enum": []string{"completed", "in_progress", "blocked", "failed", "cancelled", "awaiting_hitl"},
|
||||||
|
},
|
||||||
|
"completionReason": map[string]interface{}{
|
||||||
|
"type": "string",
|
||||||
|
"description": "最终化或阻断原因,例如 verified、pending_tool_executions、missing_execution_evidence",
|
||||||
|
},
|
||||||
|
"evidenceVerified": map[string]interface{}{
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "证据是否满足最终化要求",
|
||||||
|
},
|
||||||
|
"evidenceRefs": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "证据引用,例如 mcp_execution:<id>",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"pendingExecutionIds": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "仍处于 queued/running 的工具执行 ID",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
"missingChecks": map[string]interface{}{
|
||||||
|
"type": "array",
|
||||||
|
"description": "未通过最终化检查的原因列表",
|
||||||
|
"items": map[string]interface{}{"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"response", "conversationId", "finalized", "finalizable", "status", "evidenceVerified"},
|
||||||
|
},
|
||||||
"Conversation": map[string]interface{}{
|
"Conversation": map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
@@ -1581,6 +1656,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
},
|
},
|
||||||
@@ -1588,7 +1664,14 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{"description": "成功,响应格式同 /api/eino-agent"},
|
"200": map[string]interface{}{
|
||||||
|
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
"500": map[string]interface{}{"description": "执行失败"},
|
"500": map[string]interface{}{"description": "执行失败"},
|
||||||
@@ -1599,7 +1682,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino ADK 单代理,SSE)",
|
||||||
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。**不依赖** `multi_agent.enabled`。",
|
"description": "向 AI 发送消息并获取流式回复(SSE)。由 Eino **单代理** ADK 执行;事件类型与多代理流式一致(含 `tool_call` / `response_delta` / `thinking` 等)。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。`data.finalized=false` 时 message 为未完成/阻断说明。**不依赖** `multi_agent.enabled`。",
|
||||||
"operationId": "sendMessageEinoSingleAgentStream",
|
"operationId": "sendMessageEinoSingleAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1612,6 +1695,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
},
|
},
|
||||||
"required": []string{"message"},
|
"required": []string{"message"},
|
||||||
},
|
},
|
||||||
@@ -1625,7 +1709,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"text/event-stream": map[string]interface{}{
|
"text/event-stream": map[string]interface{}{
|
||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SSE 流",
|
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1663,6 +1747,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
"description": "WebShell 连接 ID(可选,与 Eino 单/多代理流式行为一致)",
|
||||||
},
|
},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
"description": "Eino 预置编排:deep | plan_execute | supervisor;缺省 deep",
|
||||||
@@ -1676,7 +1761,12 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
},
|
},
|
||||||
"responses": map[string]interface{}{
|
"responses": map[string]interface{}{
|
||||||
"200": map[string]interface{}{
|
"200": map[string]interface{}{
|
||||||
"description": "成功,响应格式同 /api/eino-agent",
|
"description": "成功。只有 finalized=true 表示成功最终回复;finalized=false 时 response 为未完成/阻断说明。",
|
||||||
|
"content": map[string]interface{}{
|
||||||
|
"application/json": map[string]interface{}{
|
||||||
|
"schema": map[string]interface{}{"$ref": "#/components/schemas/AgentChatResponse"},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"400": map[string]interface{}{"description": "参数错误"},
|
"400": map[string]interface{}{"description": "参数错误"},
|
||||||
"401": map[string]interface{}{"description": "未授权"},
|
"401": map[string]interface{}{"description": "未授权"},
|
||||||
@@ -1689,7 +1779,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"post": map[string]interface{}{
|
"post": map[string]interface{}{
|
||||||
"tags": []string{"对话交互"},
|
"tags": []string{"对话交互"},
|
||||||
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
"summary": "发送消息并获取 AI 回复(Eino 多代理,SSE)",
|
||||||
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
"description": "与 `POST /api/eino-agent/stream` 类似;由 Eino 多代理执行。`orchestration` 指定 deep / plan_execute / supervisor,缺省 deep。`response_start` / `response_delta` 仅为候选/过程输出;只有 `type: response` 且 `data.finalized=true` 才表示成功最终回复。缺 completed 执行证据时可能先发送 `finalization_auto_continue`,表示服务端基于已有 trace 无注入续跑。**前提**:`multi_agent.enabled: true`;未启用时 SSE 内首条为 `type: error` 后接 `done`。支持 `webshellConnectionId`。",
|
||||||
"operationId": "sendMessageMultiAgentStream",
|
"operationId": "sendMessageMultiAgentStream",
|
||||||
"requestBody": map[string]interface{}{
|
"requestBody": map[string]interface{}{
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -1702,6 +1792,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"conversationId": map[string]interface{}{"type": "string"},
|
"conversationId": map[string]interface{}{"type": "string"},
|
||||||
"role": map[string]interface{}{"type": "string"},
|
"role": map[string]interface{}{"type": "string"},
|
||||||
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
"webshellConnectionId": map[string]interface{}{"type": "string"},
|
||||||
|
"finalization": finalizationRequestSchema,
|
||||||
"orchestration": map[string]interface{}{
|
"orchestration": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "deep | plan_execute | supervisor;缺省 deep",
|
"description": "deep | plan_execute | supervisor;缺省 deep",
|
||||||
@@ -1720,7 +1811,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
|||||||
"text/event-stream": map[string]interface{}{
|
"text/event-stream": map[string]interface{}{
|
||||||
"schema": map[string]interface{}{
|
"schema": map[string]interface{}{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "SSE 流",
|
"description": "SSE 流。终态 response 事件 data 包含 finalized、finalizable、status、completionReason、evidenceVerified、evidenceRefs、pendingExecutionIds、missingChecks;过程事件可能包含 finalization_auto_continue。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -47,6 +48,12 @@ type workflowDryRunRequest struct {
|
|||||||
Inputs map[string]interface{} `json:"inputs,omitempty"`
|
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) {
|
func (h *WorkflowHandler) List(c *gin.Context) {
|
||||||
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
|
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
|
||||||
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
|
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})
|
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) {
|
func (h *WorkflowHandler) Update(c *gin.Context) {
|
||||||
h.save(c, c.Param("id"))
|
h.save(c, c.Param("id"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,20 +152,37 @@ func (h *AgentHandler) runRoleWorkflowStreamIfBound(
|
|||||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if prep.AssistantMessageID != "" {
|
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
prep.ConversationID,
|
||||||
|
prep.AssistantMessageID,
|
||||||
|
"workflow",
|
||||||
|
result.Response,
|
||||||
|
nil,
|
||||||
|
result.AwaitingHITL,
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
|
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||||
|
sendEvent("finalization_check", responseText, decision)
|
||||||
}
|
}
|
||||||
payload := map[string]interface{}{
|
payload := finalizationResponsePayload(decision, map[string]interface{}{
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"messageId": prep.AssistantMessageID,
|
"messageId": prep.AssistantMessageID,
|
||||||
"agentMode": "workflow",
|
"agentMode": "workflow",
|
||||||
"workflowRunId": result.RunID,
|
"workflowRunId": result.RunID,
|
||||||
}
|
})
|
||||||
if result.AwaitingHITL {
|
if result.AwaitingHITL {
|
||||||
payload["workflowStatus"] = "awaiting_hitl"
|
payload["workflowStatus"] = "awaiting_hitl"
|
||||||
payload["awaitingHitl"] = true
|
payload["awaitingHitl"] = true
|
||||||
|
} else {
|
||||||
|
payload["workflowStatus"] = result.Status
|
||||||
|
payload["awaitingHitl"] = false
|
||||||
}
|
}
|
||||||
sendEvent("response", result.Response, payload)
|
sendEvent("response", responseText, payload)
|
||||||
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -251,17 +268,37 @@ func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatReque
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if prep.AssistantMessageID != "" {
|
decision := h.finalizeCandidateForDeliveryWithPolicy(
|
||||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
prep.ConversationID,
|
||||||
|
prep.AssistantMessageID,
|
||||||
|
"workflow",
|
||||||
|
result.Response,
|
||||||
|
nil,
|
||||||
|
result.AwaitingHITL,
|
||||||
|
"",
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
responseText := decision.FinalText
|
||||||
|
if !decision.Finalizable {
|
||||||
|
responseText = finalizationBlockedMessage(decision)
|
||||||
|
taskStatus = decision.Status
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"response": result.Response,
|
"response": responseText,
|
||||||
"conversationId": prep.ConversationID,
|
"conversationId": prep.ConversationID,
|
||||||
"assistantMessageId": prep.AssistantMessageID,
|
"assistantMessageId": prep.AssistantMessageID,
|
||||||
"agentMode": "workflow",
|
"agentMode": "workflow",
|
||||||
"workflowRunId": result.RunID,
|
"workflowRunId": result.RunID,
|
||||||
"workflowStatus": result.Status,
|
"workflowStatus": result.Status,
|
||||||
"awaitingHitl": result.AwaitingHITL,
|
"awaitingHitl": result.AwaitingHITL,
|
||||||
|
"finalized": decision.Finalized,
|
||||||
|
"finalizable": decision.Finalizable,
|
||||||
|
"status": decision.Status,
|
||||||
|
"completionReason": decision.CompletionReason,
|
||||||
|
"evidenceVerified": decision.EvidenceVerified,
|
||||||
|
"evidenceRefs": decision.EvidenceRefs,
|
||||||
|
"pendingExecutionIds": decision.PendingExecutionIDs,
|
||||||
|
"missingChecks": decision.MissingChecks,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -76,3 +77,26 @@ func TestWorkflowPackageHandlerInspectionAndCreateImport(t *testing.T) {
|
|||||||
t.Fatalf("saved=%#v", saved)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ type RunResult struct {
|
|||||||
MCPExecutionIDs []string
|
MCPExecutionIDs []string
|
||||||
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
|
LastAgentTraceInput string // 已序列化的消息带(JSON):原生循环或 Eino 均写入,供续跑/攻击链等恢复上下文
|
||||||
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
|
LastAgentTraceOutput string // 本轮助手侧对外展示文本(摘要或最终回复)
|
||||||
|
Finalized bool
|
||||||
|
Status string
|
||||||
|
CompletionReason string
|
||||||
|
EvidenceVerified bool
|
||||||
|
EvidenceRefs []string
|
||||||
|
PendingExecutionIDs []string
|
||||||
|
MissingChecks []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
|
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ func permissionForRequest(method, fullPath string) string {
|
|||||||
return "workflow:read"
|
return "workflow:read"
|
||||||
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
|
case strings.HasPrefix(path, "/workflow-package-inspections"), strings.HasPrefix(path, "/workflow-package-imports"):
|
||||||
return "workflow:write"
|
return "workflow:write"
|
||||||
|
case path == "/workflows/generate-draft":
|
||||||
|
return "workflow:write"
|
||||||
case strings.HasPrefix(path, "/workflows"):
|
case strings.HasPrefix(path, "/workflows"):
|
||||||
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
|
if path == "/workflows/validate" || path == "/workflows/dry-run" || strings.HasSuffix(path, "/resume") {
|
||||||
return "workflow:execute"
|
return "workflow:execute"
|
||||||
@@ -265,7 +267,7 @@ func isProcessGlobalMutationPath(path string) bool {
|
|||||||
}
|
}
|
||||||
if strings.HasPrefix(path, "/workflows") {
|
if strings.HasPrefix(path, "/workflows") {
|
||||||
// Workflow runs inherit conversation access; definitions are global.
|
// 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") {
|
if strings.HasPrefix(path, "/workflow-package-inspections") || strings.HasPrefix(path, "/workflow-package-imports") {
|
||||||
return true
|
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" {
|
if got := permissionForRequest(http.MethodPost, "/api/workflows/runs/run-1/resume"); got != "workflow:execute" {
|
||||||
t.Fatalf("resume permission = %q, want workflow:execute", got)
|
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" {
|
if got := permissionForRequest(http.MethodPut, "/api/workflows/workflow-1"); got != "workflow:write" {
|
||||||
t.Fatalf("definition permission = %q, want workflow:write", got)
|
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) {
|
func TestRBACDenyHookReceivesDeniedDecision(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,782 @@
|
|||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
"cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DraftTool struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftOptions struct {
|
||||||
|
IncludeObjective bool `json:"include_objective"`
|
||||||
|
AllowSchedule bool `json:"allow_schedule"`
|
||||||
|
AllowHighRisk bool `json:"allow_high_risk"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftRequest struct {
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Options DraftOptions `json:"options"`
|
||||||
|
AvailableTools []DraftTool `json:"available_tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftMeta struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftCapability struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
ToolName string `json:"tool_name,omitempty"`
|
||||||
|
ToolCandidates []string `json:"tool_candidates,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftAudit struct {
|
||||||
|
Savable bool `json:"savable"`
|
||||||
|
Validation []string `json:"validation,omitempty"`
|
||||||
|
MissingFields []string `json:"missing_fields,omitempty"`
|
||||||
|
RiskWarnings []string `json:"risk_warnings,omitempty"`
|
||||||
|
Assumptions []string `json:"assumptions,omitempty"`
|
||||||
|
HighRisk bool `json:"high_risk"`
|
||||||
|
NeedsHITL bool `json:"needs_hitl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftResult struct {
|
||||||
|
Graph *graphDef `json:"graph"`
|
||||||
|
Meta DraftMeta `json:"meta"`
|
||||||
|
Generator string `json:"generator"`
|
||||||
|
Audit DraftAudit `json:"audit"`
|
||||||
|
Capabilities []DraftCapability `json:"capabilities,omitempty"`
|
||||||
|
Stats map[string]int `json:"stats"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type llmDraftEnvelope struct {
|
||||||
|
Graph graphDef `json:"graph"`
|
||||||
|
Meta DraftMeta `json:"meta"`
|
||||||
|
Capabilities []DraftCapability `json:"capabilities,omitempty"`
|
||||||
|
Audit DraftAudit `json:"audit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type draftToolHint struct {
|
||||||
|
Label string
|
||||||
|
Keywords []string
|
||||||
|
Tools []string
|
||||||
|
}
|
||||||
|
|
||||||
|
var draftToolHints = []draftToolHint{
|
||||||
|
{Label: "子域名发现", Keywords: []string{"子域名", "subdomain", "subfinder", "amass"}, Tools: []string{"subfinder", "amass"}},
|
||||||
|
{Label: "端口扫描", Keywords: []string{"端口", "port", "nmap", "rustscan", "masscan"}, Tools: []string{"nmap", "rustscan", "masscan"}},
|
||||||
|
{Label: "漏洞扫描", Keywords: []string{"漏洞", "vuln", "漏洞扫描", "nuclei", "nikto", "zap"}, Tools: []string{"nuclei", "nikto", "zap"}},
|
||||||
|
{Label: "暴露面探测", Keywords: []string{"目录", "路径", "暴露页面", "dir", "ffuf", "gobuster", "feroxbuster"}, Tools: []string{"ffuf", "gobuster", "feroxbuster", "dirsearch"}},
|
||||||
|
{Label: "证书与域名线索收集", Keywords: []string{"证书", "certificate", "crt"}, Tools: []string{"subfinder"}},
|
||||||
|
{Label: "云配置审计", Keywords: []string{"云", "cloud", "配置审计", "prowler", "scout"}, Tools: []string{"prowler", "scout-suite"}},
|
||||||
|
{Label: "容器安全检查", Keywords: []string{"容器", "镜像", "k8s", "kubernetes", "trivy", "kube"}, Tools: []string{"trivy", "kube-bench", "kube-hunter"}},
|
||||||
|
{Label: "威胁情报收集", Keywords: []string{"情报", "威胁情报", "threat", "ioc", "virustotal", "shodan", "fofa"}, Tools: []string{"virustotal_search", "shodan_search", "fofa_search"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var highRiskDraftRE = regexp.MustCompile(`(?i)(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)`)
|
||||||
|
|
||||||
|
func GenerateDraftFromNaturalLanguage(ctx context.Context, req DraftRequest) (*DraftResult, error) {
|
||||||
|
prompt := strings.TrimSpace(req.Prompt)
|
||||||
|
if prompt == "" {
|
||||||
|
return nil, fmt.Errorf("工作流需求不能为空")
|
||||||
|
}
|
||||||
|
capabilities := detectDraftCapabilities(prompt, req.AvailableTools)
|
||||||
|
wantsApproval := containsAnyFold(prompt, "审批", "确认", "审核", "负责人", "人工", "review", "approve", "approval", "human")
|
||||||
|
wantsReport := containsAnyFold(prompt, "报告", "汇总", "输出", "通知", "任务", "工单", "report", "summary", "notify", "ticket")
|
||||||
|
wantsCondition := containsAnyFold(prompt, "如果", "发现", "存在", "高危", "新增", "失败", "通过", "否则", "if", "when", "high", "critical", "new", "fail")
|
||||||
|
highRisk := highRiskDraftRE.MatchString(prompt)
|
||||||
|
|
||||||
|
builder := &draftGraphBuilder{x: 120, y: 150}
|
||||||
|
assumptions := make([]string, 0)
|
||||||
|
riskWarnings := make([]string, 0)
|
||||||
|
missingFields := make([]string, 0)
|
||||||
|
|
||||||
|
start := builder.add("start", "开始", map[string]any{"input_keys": "message, conversationId, projectId, target"}, 0)
|
||||||
|
previous := start
|
||||||
|
for _, capability := range capabilities {
|
||||||
|
hasTool := strings.TrimSpace(capability.ToolName) != ""
|
||||||
|
var id string
|
||||||
|
if hasTool {
|
||||||
|
id = builder.add("tool", capability.Label, map[string]any{
|
||||||
|
"tool_name": capability.ToolName,
|
||||||
|
"arguments": `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`,
|
||||||
|
"timeout_seconds": "120",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
}, 0)
|
||||||
|
} else {
|
||||||
|
id = builder.add("agent", capability.Label, map[string]any{
|
||||||
|
"agent_mode": "eino_single",
|
||||||
|
"input_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"instruction": capability.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt,
|
||||||
|
"output_key": "agent_result",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
"missing_tool_candidates": strings.Join(capability.ToolCandidates, ", "),
|
||||||
|
}, 0)
|
||||||
|
if len(capability.ToolCandidates) > 0 {
|
||||||
|
assumptions = append(assumptions, capability.Label+" 未匹配到已启用工具,已生成 Agent 草稿节点。")
|
||||||
|
missingFields = append(missingFields, capability.Label+": 选择或启用对应 MCP 工具")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.connect(previous, id, "", nil)
|
||||||
|
previous = id
|
||||||
|
}
|
||||||
|
|
||||||
|
openConditionID := ""
|
||||||
|
if wantsCondition {
|
||||||
|
expr := `{{previous.output}} != ""`
|
||||||
|
label := "是否满足触发条件"
|
||||||
|
if highRisk {
|
||||||
|
expr = `{{previous.output}} contains "高危"`
|
||||||
|
label = "是否需要高风险处置"
|
||||||
|
}
|
||||||
|
condition := builder.add("condition", label, map[string]any{"expression": expr, "join_strategy": "all_merge"}, 0)
|
||||||
|
builder.connect(previous, condition, "", nil)
|
||||||
|
openConditionID = condition
|
||||||
|
report := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
|
||||||
|
"output_key": "result",
|
||||||
|
"source_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"static_value": "",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
}, 130)
|
||||||
|
builder.connect(condition, report, "否", map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"})
|
||||||
|
previous = condition
|
||||||
|
}
|
||||||
|
|
||||||
|
insertedHITL := false
|
||||||
|
if highRisk {
|
||||||
|
if !req.Options.AllowHighRisk || wantsApproval {
|
||||||
|
approval := builder.add("hitl", "人工审批", map[string]any{
|
||||||
|
"prompt": "请确认是否允许继续执行高风险处置:" + prompt,
|
||||||
|
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"reviewer": "human",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
"risk_level": "high",
|
||||||
|
}, 0)
|
||||||
|
builder.connect(previous, approval, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||||
|
if previous == openConditionID {
|
||||||
|
openConditionID = ""
|
||||||
|
}
|
||||||
|
previous = approval
|
||||||
|
insertedHITL = true
|
||||||
|
}
|
||||||
|
action := builder.add("agent", "执行受控处置", map[string]any{
|
||||||
|
"agent_mode": "eino_single",
|
||||||
|
"input_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"instruction": "仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:" + prompt,
|
||||||
|
"output_key": "remediation_plan",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
"risk_level": "high",
|
||||||
|
"requires_human_confirmation": "true",
|
||||||
|
}, 0)
|
||||||
|
builder.connect(previous, action, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||||
|
if previous == openConditionID {
|
||||||
|
openConditionID = ""
|
||||||
|
}
|
||||||
|
previous = action
|
||||||
|
if insertedHITL {
|
||||||
|
riskWarnings = append(riskWarnings, "检测到高风险动作,已加入人工审批与 requires_human_confirmation 标记。")
|
||||||
|
} else {
|
||||||
|
riskWarnings = append(riskWarnings, "检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。")
|
||||||
|
}
|
||||||
|
} else if wantsApproval {
|
||||||
|
approval := builder.add("hitl", "人工审批", map[string]any{
|
||||||
|
"prompt": "请审核工作流阶段结果:" + prompt,
|
||||||
|
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"reviewer": "human",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
}, 0)
|
||||||
|
builder.connect(previous, approval, "", nil)
|
||||||
|
previous = approval
|
||||||
|
insertedHITL = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
|
||||||
|
"output_key": "result",
|
||||||
|
"source_binding": map[string]any{"from": "previous", "field": "output"},
|
||||||
|
"static_value": "",
|
||||||
|
"join_strategy": "all_merge",
|
||||||
|
}, 0)
|
||||||
|
builder.connect(previous, output, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||||
|
|
||||||
|
graph := &graphDef{Nodes: builder.nodes, Edges: builder.edges, Config: map[string]any{
|
||||||
|
"schema_version": 1,
|
||||||
|
"generated_by": "natural_language",
|
||||||
|
"source_prompt": prompt,
|
||||||
|
}}
|
||||||
|
if req.Options.IncludeObjective {
|
||||||
|
graph.Config["objective"] = prompt
|
||||||
|
}
|
||||||
|
if req.Options.AllowSchedule && containsAnyFold(prompt, "每天", "每周", "定时", "周期", "持续", "daily", "weekly", "schedule", "monitor") {
|
||||||
|
if containsAnyFold(prompt, "每天", "daily") {
|
||||||
|
graph.Config["trigger_suggestion"] = "daily"
|
||||||
|
} else {
|
||||||
|
graph.Config["trigger_suggestion"] = "scheduled"
|
||||||
|
}
|
||||||
|
assumptions = append(assumptions, "已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(graph)
|
||||||
|
validation := make([]string, 0)
|
||||||
|
if err := ValidateGraphJSON(ctx, string(raw)); err != nil {
|
||||||
|
validation = append(validation, err.Error())
|
||||||
|
}
|
||||||
|
return &DraftResult{
|
||||||
|
Graph: graph,
|
||||||
|
Meta: DraftMeta{ID: draftSlug(prompt), Name: draftName(prompt), Description: prompt, Enabled: true},
|
||||||
|
Generator: "deterministic",
|
||||||
|
Audit: DraftAudit{
|
||||||
|
Savable: len(validation) == 0,
|
||||||
|
Validation: validation,
|
||||||
|
MissingFields: missingFields,
|
||||||
|
RiskWarnings: riskWarnings,
|
||||||
|
Assumptions: assumptions,
|
||||||
|
HighRisk: highRisk,
|
||||||
|
NeedsHITL: insertedHITL,
|
||||||
|
},
|
||||||
|
Capabilities: capabilities,
|
||||||
|
Stats: map[string]int{"nodes": len(graph.Nodes), "edges": len(graph.Edges)},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateDraftFromLLM(ctx context.Context, req DraftRequest, oa config.OpenAIConfig, logger *zap.Logger) (*DraftResult, error) {
|
||||||
|
prompt := strings.TrimSpace(req.Prompt)
|
||||||
|
if prompt == "" {
|
||||||
|
return nil, fmt.Errorf("工作流需求不能为空")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(oa.APIKey) == "" || strings.TrimSpace(oa.Model) == "" {
|
||||||
|
return nil, fmt.Errorf("AI 通道未配置 api_key 或 model")
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
callCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
toolJSON, _ := json.Marshal(req.AvailableTools)
|
||||||
|
systemPrompt := `你是 CyberStrikeAI 的工作流编排助手。你必须把用户的一句话需求转换为可保存的工作流草稿 JSON。
|
||||||
|
只返回 JSON 对象,不要 Markdown,不要解释。JSON 必须符合:
|
||||||
|
{
|
||||||
|
"meta": {"id":"kebab-case-id","name":"短名称","description":"用户需求","enabled":true},
|
||||||
|
"graph": {
|
||||||
|
"nodes": [{"id":"start-1","type":"start","label":"显示名","position":{"x":120,"y":150},"config":{}}],
|
||||||
|
"edges": [{"id":"edge-1","source":"start-1","target":"node-2","label":"","config":{}}],
|
||||||
|
"config": {"schema_version":1,"generated_by":"llm","source_prompt":"用户原文"}
|
||||||
|
},
|
||||||
|
"capabilities": [{"label":"能力名","tool_name":"已匹配工具名","tool_candidates":["候选工具"]}],
|
||||||
|
"audit": {"assumptions":[],"missing_fields":[],"risk_warnings":[]}
|
||||||
|
}
|
||||||
|
硬性规则:
|
||||||
|
- 只能输出一个合法 JSON object;不要输出 JSON Schema、注释、解释文字、Markdown 代码块或多余前后缀。
|
||||||
|
- 不要在 JSON 字符串值中使用竖线枚举写法;type 字段一次只能填写一个节点类型字符串。
|
||||||
|
- 至少 1 个 start 和 1 个 output;output/end 不能有出边。
|
||||||
|
- 节点 type 只能从这些字符串中选择:start、tool、agent、condition、hitl、output、end。
|
||||||
|
- 每个 agent、tool、output 节点都必须配置唯一的 output_key;output 节点默认使用 result。
|
||||||
|
- agent 节点必须配置 instruction 或 input_binding;默认 input_binding 为 {"from":"previous","field":"output"}。
|
||||||
|
- output 节点必须配置 source_binding 或 static_value;默认 source_binding 为 {"from":"previous","field":"output"}。
|
||||||
|
- tool 节点必须配置 tool_name、arguments、timeout_seconds;arguments 必须是合法 JSON 字符串。
|
||||||
|
- 所有非 start 且可能有多个上游的节点必须配置 join_strategy:"all_merge"。
|
||||||
|
- condition 最多 2 条出边,必须用 branch true/false,并用 label 是/否。
|
||||||
|
- tool 节点只有在 available_tools 中存在启用工具时才使用,否则用 agent 节点并在 audit.missing_fields 写明缺失工具。
|
||||||
|
- 高风险动作(执行脚本、隔离、封禁、删除、利用、payload、命令执行等)必须加入 hitl 审批,或在高风险节点 config 中标记 requires_human_confirmation:"true"、risk_level:"high"。
|
||||||
|
- 不要生成会真实执行攻击的参数;工具参数使用 {{inputs.target}}、{{inputs.message}} 占位。
|
||||||
|
- 所有节点 config 加 generated_by:"llm" 和 needs_review:"true"。`
|
||||||
|
userPrompt := fmt.Sprintf("用户需求:%s\n\n选项:%+v\n\n可用工具 JSON:%s", prompt, req.Options, string(toolJSON))
|
||||||
|
requestBody := map[string]interface{}{
|
||||||
|
"model": strings.TrimSpace(oa.Model),
|
||||||
|
"messages": []map[string]interface{}{
|
||||||
|
{"role": "system", "content": systemPrompt},
|
||||||
|
{"role": "user", "content": userPrompt},
|
||||||
|
},
|
||||||
|
"temperature": 0,
|
||||||
|
"max_completion_tokens": 4096,
|
||||||
|
"response_format": map[string]interface{}{"type": "json_object"},
|
||||||
|
"thinking": map[string]interface{}{"type": "disabled"},
|
||||||
|
}
|
||||||
|
var apiResponse struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
client := openai.NewClient(&oa, nil, logger)
|
||||||
|
if err := client.ChatCompletion(callCtx, requestBody, &apiResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("调用大模型失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(apiResponse.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("大模型未返回候选结果")
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
|
||||||
|
if raw == "" {
|
||||||
|
raw = strings.TrimSpace(apiResponse.Choices[0].Message.ReasoningContent)
|
||||||
|
}
|
||||||
|
env, err := parseLLMDraftEnvelope(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := normalizeLLMDraft(prompt, req, env)
|
||||||
|
graphRaw, _ := json.Marshal(result.Graph)
|
||||||
|
validation := make([]string, 0)
|
||||||
|
if err := ValidateGraphJSON(ctx, string(graphRaw)); err != nil {
|
||||||
|
validation = append(validation, err.Error())
|
||||||
|
}
|
||||||
|
result.Audit.Validation = validation
|
||||||
|
result.Audit.Savable = len(validation) == 0
|
||||||
|
if !result.Audit.Savable {
|
||||||
|
return nil, fmt.Errorf("大模型生成的工作流未通过校验: %s", strings.Join(validation, ";"))
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type draftGraphBuilder struct {
|
||||||
|
nodes []graphNode
|
||||||
|
edges []graphEdge
|
||||||
|
x float64
|
||||||
|
y float64
|
||||||
|
nodeSeq int
|
||||||
|
edgeSeq int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *draftGraphBuilder) add(nodeType, label string, config map[string]any, yOffset float64) string {
|
||||||
|
b.nodeSeq++
|
||||||
|
id := fmt.Sprintf("%s-%d", nodeType, b.nodeSeq)
|
||||||
|
if config == nil {
|
||||||
|
config = make(map[string]any)
|
||||||
|
}
|
||||||
|
config["generated_by"] = "natural_language"
|
||||||
|
config["needs_review"] = "true"
|
||||||
|
b.nodes = append(b.nodes, graphNode{
|
||||||
|
ID: id,
|
||||||
|
Type: nodeType,
|
||||||
|
Label: label,
|
||||||
|
Position: graphPosition{X: b.x, Y: b.y + yOffset},
|
||||||
|
Config: config,
|
||||||
|
})
|
||||||
|
b.x += 210
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *draftGraphBuilder) connect(source, target, label string, config map[string]any) {
|
||||||
|
b.edgeSeq++
|
||||||
|
if config == nil {
|
||||||
|
config = make(map[string]any)
|
||||||
|
}
|
||||||
|
b.edges = append(b.edges, graphEdge{ID: fmt.Sprintf("edge-ai-%d", b.edgeSeq), Source: source, Target: target, Label: label, Config: config})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLLMDraftEnvelope(raw string) (llmDraftEnvelope, error) {
|
||||||
|
var lastErr error
|
||||||
|
for _, candidate := range jsonObjectCandidates(raw) {
|
||||||
|
var env llmDraftEnvelope
|
||||||
|
if err := json.Unmarshal([]byte(candidate), &env); err == nil {
|
||||||
|
if len(env.Graph.Nodes) == 0 {
|
||||||
|
lastErr = fmt.Errorf("大模型 JSON 缺少 graph.nodes")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return env, nil
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("大模型响应为空")
|
||||||
|
}
|
||||||
|
return llmDraftEnvelope{}, fmt.Errorf("解析大模型工作流 JSON 失败: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonObjectCandidates(raw string) []string {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
s = strings.TrimPrefix(s, "```json")
|
||||||
|
s = strings.TrimPrefix(s, "```")
|
||||||
|
s = strings.TrimSuffix(s, "```")
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
candidates := []string{s}
|
||||||
|
if start := strings.Index(s, "{"); start >= 0 {
|
||||||
|
if end := strings.LastIndex(s, "}"); end > start {
|
||||||
|
candidates = append(candidates, s[start:end+1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLLMDraft(prompt string, req DraftRequest, env llmDraftEnvelope) *DraftResult {
|
||||||
|
g := env.Graph
|
||||||
|
if g.Config == nil {
|
||||||
|
g.Config = make(map[string]any)
|
||||||
|
}
|
||||||
|
g.Config["schema_version"] = 1
|
||||||
|
g.Config["generated_by"] = "llm"
|
||||||
|
g.Config["source_prompt"] = prompt
|
||||||
|
if req.Options.IncludeObjective {
|
||||||
|
g.Config["objective"] = prompt
|
||||||
|
}
|
||||||
|
enabledTools := enabledDraftToolNames(req.AvailableTools)
|
||||||
|
usedOutputKeys := make(map[string]bool)
|
||||||
|
nodeTypes := make(map[string]string, len(g.Nodes))
|
||||||
|
for i := range g.Nodes {
|
||||||
|
if strings.TrimSpace(g.Nodes[i].ID) == "" {
|
||||||
|
g.Nodes[i].ID = fmt.Sprintf("%s-%d", firstNonEmpty(g.Nodes[i].Type, "node"), i+1)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(g.Nodes[i].Type) == "" {
|
||||||
|
g.Nodes[i].Type = "agent"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(g.Nodes[i].Label) == "" {
|
||||||
|
g.Nodes[i].Label = displayNodeType(g.Nodes[i].Type)
|
||||||
|
}
|
||||||
|
if g.Nodes[i].Position.X == 0 && g.Nodes[i].Position.Y == 0 {
|
||||||
|
g.Nodes[i].Position = graphPosition{X: 120 + float64(i)*210, Y: 150}
|
||||||
|
}
|
||||||
|
if g.Nodes[i].Config == nil {
|
||||||
|
g.Nodes[i].Config = make(map[string]any)
|
||||||
|
}
|
||||||
|
g.Nodes[i].Config["generated_by"] = "llm"
|
||||||
|
g.Nodes[i].Config["needs_review"] = "true"
|
||||||
|
normalizeLLMNodeConfig(prompt, &g.Nodes[i], enabledTools, usedOutputKeys)
|
||||||
|
nodeTypes[g.Nodes[i].ID] = strings.ToLower(strings.TrimSpace(g.Nodes[i].Type))
|
||||||
|
}
|
||||||
|
conditionBranchCounts := make(map[string]int)
|
||||||
|
for i := range g.Edges {
|
||||||
|
if strings.TrimSpace(g.Edges[i].ID) == "" {
|
||||||
|
g.Edges[i].ID = fmt.Sprintf("edge-llm-%d", i+1)
|
||||||
|
}
|
||||||
|
if g.Edges[i].Config == nil {
|
||||||
|
g.Edges[i].Config = make(map[string]any)
|
||||||
|
}
|
||||||
|
normalizeLLMEdgeConfig(&g.Edges[i], nodeTypes, conditionBranchCounts)
|
||||||
|
}
|
||||||
|
audit := env.Audit
|
||||||
|
highRisk := highRiskDraftRE.MatchString(prompt) || graphHasHighRisk(g)
|
||||||
|
audit.HighRisk = highRisk
|
||||||
|
audit.NeedsHITL = graphHasNodeType(g, "hitl")
|
||||||
|
if highRisk && !audit.NeedsHITL && !graphHasConfirmation(g) {
|
||||||
|
audit.RiskWarnings = append(audit.RiskWarnings, "大模型生成包含高风险语义,请补充人工审批或确认标记后再运行。")
|
||||||
|
}
|
||||||
|
if len(audit.RiskWarnings) == 0 && highRisk {
|
||||||
|
audit.RiskWarnings = append(audit.RiskWarnings, "检测到高风险动作,已标记为需要重点审计。")
|
||||||
|
}
|
||||||
|
meta := env.Meta
|
||||||
|
if strings.TrimSpace(meta.Description) == "" {
|
||||||
|
meta.Description = prompt
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.Name) == "" {
|
||||||
|
meta.Name = draftName(prompt)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(meta.ID) == "" {
|
||||||
|
meta.ID = draftSlug(prompt)
|
||||||
|
}
|
||||||
|
meta.Enabled = true
|
||||||
|
return &DraftResult{
|
||||||
|
Graph: &g,
|
||||||
|
Meta: meta,
|
||||||
|
Generator: "llm",
|
||||||
|
Audit: audit,
|
||||||
|
Capabilities: env.Capabilities,
|
||||||
|
Stats: map[string]int{"nodes": len(g.Nodes), "edges": len(g.Edges)},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLLMEdgeConfig(edge *graphEdge, nodeTypes map[string]string, conditionBranchCounts map[string]int) {
|
||||||
|
if nodeTypes[strings.TrimSpace(edge.Source)] != "condition" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if conditionBranchHint(*edge) != "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conditionBranchCounts[edge.Source]++
|
||||||
|
branch := "true"
|
||||||
|
label := "是"
|
||||||
|
if conditionBranchCounts[edge.Source] > 1 {
|
||||||
|
branch = "false"
|
||||||
|
label = "否"
|
||||||
|
}
|
||||||
|
edge.Label = label
|
||||||
|
edge.Config["branch"] = branch
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLLMNodeConfig(prompt string, node *graphNode, enabledTools map[string]bool, usedOutputKeys map[string]bool) {
|
||||||
|
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
|
||||||
|
switch nodeType {
|
||||||
|
case "start":
|
||||||
|
if cfgString(node.Config, "input_keys") == "" {
|
||||||
|
node.Config["input_keys"] = "message, conversationId, projectId, target"
|
||||||
|
}
|
||||||
|
case "tool":
|
||||||
|
toolName := cfgString(node.Config, "tool_name")
|
||||||
|
if toolName == "" || !enabledTools[strings.ToLower(toolName)] {
|
||||||
|
node.Type = "agent"
|
||||||
|
node.Config["missing_tool_name"] = toolName
|
||||||
|
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "arguments") == "" {
|
||||||
|
node.Config["arguments"] = `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "timeout_seconds") == "" {
|
||||||
|
node.Config["timeout_seconds"] = "120"
|
||||||
|
}
|
||||||
|
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "tool_result"))
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
case "agent":
|
||||||
|
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
|
||||||
|
case "condition":
|
||||||
|
if cfgString(node.Config, "expression") == "" {
|
||||||
|
node.Config["expression"] = `{{previous.output}} != ""`
|
||||||
|
}
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
case "hitl":
|
||||||
|
if cfgString(node.Config, "prompt") == "" {
|
||||||
|
node.Config["prompt"] = "请审核工作流阶段结果:" + prompt
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "reviewer") == "" {
|
||||||
|
node.Config["reviewer"] = "human"
|
||||||
|
}
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
case "output":
|
||||||
|
ensureNodeOutputKey(node, usedOutputKeys, "result")
|
||||||
|
if cfgString(node.Config, "static_value") == "" {
|
||||||
|
if _, ok := parseFieldBinding(node.Config, "source_binding"); !ok {
|
||||||
|
node.Config["source_binding"] = map[string]any{"from": "previous", "field": "output"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
case "end":
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAgentDraftConfig(prompt string, node *graphNode, usedOutputKeys map[string]bool) {
|
||||||
|
if cfgString(node.Config, "agent_mode") == "" {
|
||||||
|
node.Config["agent_mode"] = "eino_single"
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "instruction") == "" {
|
||||||
|
node.Config["instruction"] = node.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt
|
||||||
|
}
|
||||||
|
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
|
||||||
|
node.Config["input_binding"] = map[string]any{"from": "previous", "field": "output"}
|
||||||
|
}
|
||||||
|
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "agent_result"))
|
||||||
|
ensureJoinStrategy(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureJoinStrategy(node *graphNode) {
|
||||||
|
if cfgString(node.Config, "join_strategy") == "" {
|
||||||
|
node.Config["join_strategy"] = "all_merge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureNodeOutputKey(node *graphNode, used map[string]bool, fallback string) {
|
||||||
|
current := sanitizeOutputKey(cfgString(node.Config, "output_key"))
|
||||||
|
if current == "" {
|
||||||
|
current = sanitizeOutputKey(fallback)
|
||||||
|
}
|
||||||
|
if current == "" {
|
||||||
|
current = "result"
|
||||||
|
}
|
||||||
|
base := current
|
||||||
|
for i := 2; used[current]; i++ {
|
||||||
|
current = fmt.Sprintf("%s_%d", base, i)
|
||||||
|
}
|
||||||
|
node.Config["output_key"] = current
|
||||||
|
used[current] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftOutputKeyBase(node *graphNode, fallback string) string {
|
||||||
|
if name := cfgString(node.Config, "tool_name"); name != "" {
|
||||||
|
return name + "_result"
|
||||||
|
}
|
||||||
|
if node.ID != "" {
|
||||||
|
return node.ID + "_result"
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeOutputKey(value string) string {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
var b strings.Builder
|
||||||
|
lastUnderscore := false
|
||||||
|
for _, r := range value {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastUnderscore = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Len() > 0 && !lastUnderscore {
|
||||||
|
b.WriteByte('_')
|
||||||
|
lastUnderscore = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Trim(b.String(), "_")
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabledDraftToolNames(tools []DraftTool) map[string]bool {
|
||||||
|
names := make(map[string]bool, len(tools)*2)
|
||||||
|
for _, tool := range tools {
|
||||||
|
if !tool.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key := strings.ToLower(strings.TrimSpace(tool.Key)); key != "" {
|
||||||
|
names[key] = true
|
||||||
|
}
|
||||||
|
if name := strings.ToLower(strings.TrimSpace(tool.Name)); name != "" {
|
||||||
|
names[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphHasNodeType(g graphDef, nodeType string) bool {
|
||||||
|
for _, node := range g.Nodes {
|
||||||
|
if strings.EqualFold(node.Type, nodeType) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphHasConfirmation(g graphDef) bool {
|
||||||
|
for _, node := range g.Nodes {
|
||||||
|
if cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphHasHighRisk(g graphDef) bool {
|
||||||
|
for _, node := range g.Nodes {
|
||||||
|
if cfgString(node.Config, "risk_level") == "high" || cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if highRiskDraftRE.MatchString(node.Label) || highRiskDraftRE.MatchString(cfgString(node.Config, "instruction")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectDraftCapabilities(prompt string, tools []DraftTool) []DraftCapability {
|
||||||
|
capabilities := make([]DraftCapability, 0)
|
||||||
|
for _, hint := range draftToolHints {
|
||||||
|
if containsAnyFold(prompt, hint.Keywords...) {
|
||||||
|
capabilities = append(capabilities, DraftCapability{
|
||||||
|
Label: hint.Label,
|
||||||
|
ToolName: matchDraftTool(hint.Tools, tools),
|
||||||
|
ToolCandidates: append([]string(nil), hint.Tools...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(capabilities) == 0 {
|
||||||
|
capabilities = append(capabilities, DraftCapability{Label: "节点能力", ToolCandidates: nil})
|
||||||
|
}
|
||||||
|
return capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchDraftTool(candidates []string, tools []DraftTool) string {
|
||||||
|
if len(candidates) == 0 || len(tools) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, enabledOnly := range []bool{true, false} {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
||||||
|
for _, tool := range tools {
|
||||||
|
if enabledOnly && !tool.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(tool.Key, tool.Name)))
|
||||||
|
if key != "" && strings.Contains(key, candidate) {
|
||||||
|
return firstNonEmpty(tool.Key, tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAnyFold(text string, needles ...string) bool {
|
||||||
|
lower := strings.ToLower(text)
|
||||||
|
for _, needle := range needles {
|
||||||
|
if strings.Contains(lower, strings.ToLower(needle)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftOutputLabel(wantsReport bool) string {
|
||||||
|
if wantsReport {
|
||||||
|
return "输出报告"
|
||||||
|
}
|
||||||
|
return "输出"
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchLabel(source, conditionID string) string {
|
||||||
|
if source == conditionID && conditionID != "" {
|
||||||
|
return "是"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func branchConfig(source, conditionID string, yes bool) map[string]any {
|
||||||
|
if source != conditionID || conditionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if yes {
|
||||||
|
return map[string]any{"condition": `{{previous.matched}} == "true"`, "branch": "true"}
|
||||||
|
}
|
||||||
|
return map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftName(prompt string) string {
|
||||||
|
runes := []rune(strings.TrimSpace(prompt))
|
||||||
|
if len(runes) > 22 {
|
||||||
|
return string(runes[:22]) + "..."
|
||||||
|
}
|
||||||
|
return string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func draftSlug(prompt string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(prompt))
|
||||||
|
var b strings.Builder
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range lower {
|
||||||
|
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||||
|
b.WriteRune(r)
|
||||||
|
lastDash = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastDash && b.Len() > 0 {
|
||||||
|
b.WriteByte('-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slug := strings.Trim(b.String(), "-")
|
||||||
|
if slug != "" {
|
||||||
|
if len(slug) > 48 {
|
||||||
|
return strings.Trim(slug[:48], "-")
|
||||||
|
}
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(lower))
|
||||||
|
if !utf8.ValidString(lower) || lower == "" {
|
||||||
|
lower = "workflow"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("ai-workflow-%x", h.Sum32())
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package workflow
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateDraftFromNaturalLanguageHighRiskAddsHITLAndValidGraph(t *testing.T) {
|
||||||
|
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
|
||||||
|
Prompt: "对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告",
|
||||||
|
Options: DraftOptions{
|
||||||
|
IncludeObjective: true,
|
||||||
|
AllowSchedule: false,
|
||||||
|
AllowHighRisk: false,
|
||||||
|
},
|
||||||
|
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(result.Graph)
|
||||||
|
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||||
|
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
if !result.Audit.HighRisk || !result.Audit.NeedsHITL || len(result.Audit.RiskWarnings) == 0 {
|
||||||
|
t.Fatalf("audit did not flag high-risk HITL path: %#v", result.Audit)
|
||||||
|
}
|
||||||
|
var hasTool, hasHITL, hasConfirmation bool
|
||||||
|
for _, node := range result.Graph.Nodes {
|
||||||
|
if node.Type == "tool" && cfgString(node.Config, "tool_name") == "nmap" {
|
||||||
|
hasTool = true
|
||||||
|
}
|
||||||
|
if node.Type == "hitl" {
|
||||||
|
hasHITL = true
|
||||||
|
}
|
||||||
|
if cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||||
|
hasConfirmation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasTool || !hasHITL || !hasConfirmation {
|
||||||
|
t.Fatalf("expected nmap tool, HITL, and confirmation marker; tool=%v hitl=%v confirmation=%v", hasTool, hasHITL, hasConfirmation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDraftAllowHighRiskStillLabelsConditionBranch(t *testing.T) {
|
||||||
|
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
|
||||||
|
Prompt: "如果漏洞扫描发现高危漏洞,允许生成执行修复脚本的草稿并输出报告",
|
||||||
|
Options: DraftOptions{
|
||||||
|
AllowHighRisk: true,
|
||||||
|
},
|
||||||
|
AvailableTools: []DraftTool{{Key: "nuclei", Name: "nuclei", Enabled: true}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(result.Graph)
|
||||||
|
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||||
|
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
branches := map[string]bool{}
|
||||||
|
for _, edge := range result.Graph.Edges {
|
||||||
|
if branch := cfgString(edge.Config, "branch"); branch != "" {
|
||||||
|
branches[branch] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !branches["true"] || !branches["false"] {
|
||||||
|
t.Fatalf("condition branches = %#v, want true and false", branches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDraftFromLLMUsesOpenAICompatibleEndpoint(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
if r.URL.Path != "/chat/completions" {
|
||||||
|
t.Fatalf("path = %s, want /chat/completions", r.URL.Path)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||||
|
t.Fatalf("authorization = %q", got)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
ResponseFormat struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"response_format"`
|
||||||
|
Messages []struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Temperature != 0 || payload.ResponseFormat.Type != "json_object" {
|
||||||
|
t.Fatalf("unexpected structured output controls: temperature=%v response_format=%#v", payload.Temperature, payload.ResponseFormat)
|
||||||
|
}
|
||||||
|
if len(payload.Messages) == 0 || strings.Contains(payload.Messages[0].Content, "start|tool|agent") {
|
||||||
|
t.Fatalf("system prompt still contains pipe enum: %q", payload.Messages[0].Content)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"llm-port-scan\",\"name\":\"端口扫描\",\"description\":\"端口扫描\",\"enabled\":true},\"graph\":{\"nodes\":[{\"id\":\"start-1\",\"type\":\"start\",\"label\":\"开始\",\"position\":{\"x\":120,\"y\":150},\"config\":{\"input_keys\":\"message, target\"}},{\"id\":\"tool-2\",\"type\":\"tool\",\"label\":\"端口扫描\",\"position\":{\"x\":330,\"y\":150},\"config\":{\"tool_name\":\"nmap\",\"arguments\":\"{\\\"target\\\":\\\"{{inputs.target}}\\\"}\",\"timeout_seconds\":\"120\",\"join_strategy\":\"all_merge\"}},{\"id\":\"output-3\",\"type\":\"output\",\"label\":\"输出报告\",\"position\":{\"x\":540,\"y\":150},\"config\":{\"source_binding\":{\"from\":\"previous\",\"field\":\"output\"},\"join_strategy\":\"all_merge\"}}],\"edges\":[{\"id\":\"edge-1\",\"source\":\"start-1\",\"target\":\"tool-2\"},{\"id\":\"edge-2\",\"source\":\"tool-2\",\"target\":\"output-3\"}],\"config\":{\"schema_version\":1}},\"capabilities\":[{\"label\":\"端口扫描\",\"tool_name\":\"nmap\",\"tool_candidates\":[\"nmap\"]}],\"audit\":{\"assumptions\":[]}}"}}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
result, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
|
||||||
|
Prompt: "对目标做端口扫描并输出报告",
|
||||||
|
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
|
||||||
|
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateDraftFromLLM: %v", err)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatal("expected LLM endpoint to be called")
|
||||||
|
}
|
||||||
|
if result.Generator != "llm" || !result.Audit.Savable || result.Meta.ID != "llm-port-scan" {
|
||||||
|
t.Fatalf("unexpected result: %#v", result)
|
||||||
|
}
|
||||||
|
for _, node := range result.Graph.Nodes {
|
||||||
|
if node.Type == "output" && cfgString(node.Config, "output_key") != "result" {
|
||||||
|
t.Fatalf("output_key = %q, want result", cfgString(node.Config, "output_key"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateDraftFromLLMReturnsErrorOnMalformedJSON(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"bad\"},|\"graph\":{\"nodes\":[]}}"}}]}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
|
||||||
|
Prompt: "随便生成一个工作流,要求所有节点都用到输出变量",
|
||||||
|
Options: DraftOptions{
|
||||||
|
IncludeObjective: true,
|
||||||
|
},
|
||||||
|
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected malformed JSON error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "解析大模型工作流 JSON 失败") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLLMDraftRepairsMissingRequiredConfig(t *testing.T) {
|
||||||
|
result := normalizeLLMDraft("随便生成一个工作流", DraftRequest{}, llmDraftEnvelope{
|
||||||
|
Graph: graphDef{
|
||||||
|
Nodes: []graphNode{
|
||||||
|
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
|
||||||
|
{ID: "agent-1", Type: "agent", Label: "分析", Config: map[string]any{}},
|
||||||
|
{ID: "out-1", Type: "output", Label: "输出结果", Config: map[string]any{}},
|
||||||
|
},
|
||||||
|
Edges: []graphEdge{
|
||||||
|
{ID: "e1", Source: "start-1", Target: "agent-1"},
|
||||||
|
{ID: "e2", Source: "agent-1", Target: "out-1"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
raw, _ := json.Marshal(result.Graph)
|
||||||
|
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||||
|
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
var agentKey, outputKey string
|
||||||
|
for _, node := range result.Graph.Nodes {
|
||||||
|
switch node.Type {
|
||||||
|
case "agent":
|
||||||
|
agentKey = cfgString(node.Config, "output_key")
|
||||||
|
case "output":
|
||||||
|
outputKey = cfgString(node.Config, "output_key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if agentKey == "" || outputKey != "result" {
|
||||||
|
t.Fatalf("agentKey=%q outputKey=%q", agentKey, outputKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLLMDraftRepairsConditionBranches(t *testing.T) {
|
||||||
|
result := normalizeLLMDraft("如果发现异常则输出详情,否则输出正常", DraftRequest{}, llmDraftEnvelope{
|
||||||
|
Graph: graphDef{
|
||||||
|
Nodes: []graphNode{
|
||||||
|
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
|
||||||
|
{ID: "cond-1", Type: "condition", Label: "判断", Config: map[string]any{"expression": `{{inputs.message}} != ""`}},
|
||||||
|
{ID: "out-yes", Type: "output", Label: "异常", Config: map[string]any{}},
|
||||||
|
{ID: "out-no", Type: "output", Label: "正常", Config: map[string]any{}},
|
||||||
|
},
|
||||||
|
Edges: []graphEdge{
|
||||||
|
{ID: "e1", Source: "start-1", Target: "cond-1"},
|
||||||
|
{ID: "e2", Source: "cond-1", Target: "out-yes"},
|
||||||
|
{ID: "e3", Source: "cond-1", Target: "out-no"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
raw, _ := json.Marshal(result.Graph)
|
||||||
|
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||||
|
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
|
||||||
|
}
|
||||||
|
branches := map[string]bool{}
|
||||||
|
for _, edge := range result.Graph.Edges {
|
||||||
|
if edge.Source == "cond-1" {
|
||||||
|
branches[cfgString(edge.Config, "branch")] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !branches["true"] || !branches["false"] {
|
||||||
|
t.Fatalf("branches = %#v, want true and false", branches)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
name: active-directory-attack
|
name: active-directory-attack
|
||||||
description: >-
|
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.
|
内网域攻击: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.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: ai-llm-app-attack
|
name: ai-llm-app-attack
|
||||||
description: >-
|
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.
|
AI/LLM应用攻击:提示注入,Agent工具滥用RCE,RAG投毒,MCP供应链,torch.load pickle RCE。Use when testing LLM apps, agents, RAG, MCP plugins, or AI model file risks.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: attack-surface-recon
|
name: attack-surface-recon
|
||||||
description: >-
|
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.
|
侦察/攻击面测绘:被动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.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: binary-mobile-reversing
|
name: binary-mobile-reversing
|
||||||
description: >-
|
description: >-
|
||||||
APK/EXE/二进制:UniApp/DCloud/Flutter逆向,证书固定绕过,导出组件,内存破坏exploit链,IoT固件。Use when reversing APK/EXE, UniApp/Flutter, native .so, or memory-corruption exploits.
|
APK/EXE/二进制:UniApp/DCloud/Flutter逆向,证书固定绕过,导出组件,内存破坏exploit链,IoT固件。Use when reversing APK/EXE, UniApp/Flutter, native .so, or memory-corruption exploits.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: blockchain-contract-attack
|
name: blockchain-contract-attack
|
||||||
description: >-
|
description: >-
|
||||||
区块链/智能合约:Etherscan,slither/mythril,重入/访问控制/预言机/闪电贷,跨链桥,RPC暴露。Use when auditing smart contracts, DeFi, or blockchain attack surfaces.
|
区块链/智能合约:Etherscan,slither/mythril,重入/访问控制/预言机/闪电贷,跨链桥,RPC暴露。Use when auditing smart contracts, DeFi, or blockchain attack surfaces.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: capability-primitive-search
|
name: capability-primitive-search
|
||||||
description: >-
|
description: >-
|
||||||
能力原语+状态空间搜索:read/write/exec/ssrf等原语凑RCE等式A-F,低危映射,正反向搜索,跨域兑现。Use when no single RCE, chaining low-severity vulns, or deriving novel attack chains.
|
能力原语+状态空间搜索:read/write/exec/ssrf等原语凑RCE等式A-F,低危映射,正反向搜索,跨域兑现。Use when no single RCE, chaining low-severity vulns, or deriving novel attack chains.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: cloud-attack-methods
|
name: cloud-attack-methods
|
||||||
description: >-
|
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.
|
云攻击:元数据API,S3/K8s,AWS/Azure/GCP身份提权,MinIO矩阵,阿里云FC,ChengZi SDK解密。Use when attacking cloud metadata, IAM, K8s, MinIO, Aliyun FC, or cloud post-ex.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: component-vuln-intel
|
name: component-vuln-intel
|
||||||
description: >-
|
description: >-
|
||||||
联网情报收集:识别组件后必做CVE/搜索引擎/中文社区/GitHub PoC/资产引擎/即时情报/依赖扩展+受阻换路。Use when a framework/component/version is identified and must search before exploit.
|
联网情报收集:识别组件后必做CVE/搜索引擎/中文社区/GitHub PoC/资产引擎/即时情报/依赖扩展+受阻换路。Use when a framework/component/version is identified and must search before exploit.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: initial-access-phishing
|
name: initial-access-phishing
|
||||||
description: >-
|
description: >-
|
||||||
初始访问/钓鱼/社工:凭据喷洒,AiTM,设备码,OAuth同意钓鱼,载荷,vishing。Use when needing initial access, phishing, AiTM, device code, or social engineering.
|
初始访问/钓鱼/社工:凭据喷洒,AiTM,设备码,OAuth同意钓鱼,载荷,vishing。Use when needing initial access, phishing, AiTM, device code, or social engineering.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ description: >-
|
|||||||
(联网情报/Web/认证/服务端/源码/社工/后渗透/二进制/内网域/云/区块链/AI/无线/硬件)+0day+
|
(联网情报/Web/认证/服务端/源码/社工/后渗透/二进制/内网域/云/区块链/AI/无线/硬件)+0day+
|
||||||
组合拳+代理自举。核心:全网搜不到洞时现场推导独属于目标的攻击链。本文件为套件索引。
|
组合拳+代理自举。核心:全网搜不到洞时现场推导独属于目标的攻击链。本文件为套件索引。
|
||||||
Use when starting a full-chain pentest engagement or needing the skill map for this suite.
|
Use when starting a full-chain pentest engagement or needing the skill map for this suite.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队, autonomous]
|
tags: [渗透测试, penetration-testing, 红队, autonomous]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ description: >-
|
|||||||
CyberStrikeAI 项目黑板:跨会话 Fact 图(SQLite)+ upsert_project_fact/record_vulnerability
|
CyberStrikeAI 项目黑板:跨会话 Fact 图(SQLite)+ upsert_project_fact/record_vulnerability
|
||||||
边渗透边记录节奏、关系边 links、confidence、与多代理协调落库。Use when managing project
|
边渗透边记录节奏、关系边 links、confidence、与多代理协调落库。Use when managing project
|
||||||
facts, blackboard index, writing evidence, or avoiding context-loss after compression.
|
facts, blackboard index, writing evidence, or avoiding context-loss after compression.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
|
tags: [渗透测试, penetration-testing, 红队, 项目黑板]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name: pentest-output-standards
|
|||||||
description: >-
|
description: >-
|
||||||
输出规范:中文分析,思维链,漏洞报告模板,负结果,黑板状态总览,改动台账,死锁突破。
|
输出规范:中文分析,思维链,漏洞报告模板,负结果,黑板状态总览,改动台账,死锁突破。
|
||||||
Use when reporting findings, maintaining change ledger, or formatting pentest output.
|
Use when reporting findings, maintaining change ledger, or formatting pentest output.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ name: pentest-verification
|
|||||||
description: >-
|
description: >-
|
||||||
验证铁律:搜索≠漏洞,confirmed Fact须证据,tentative表线索,禁止空泛推测,负结果也落库,
|
验证铁律:搜索≠漏洞,confirmed Fact须证据,tentative表线索,禁止空泛推测,负结果也落库,
|
||||||
想象力拉满+单步验证零容忍。Use when writing project facts, validating findings, or avoiding hallucination.
|
想象力拉满+单步验证零容忍。Use when writing project facts, validating findings, or avoiding hallucination.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: post-exploitation
|
name: post-exploitation
|
||||||
description: >-
|
description: >-
|
||||||
后渗透/提权+凭据破解+密码学:反弹shell,Linux/Windows提权,横向,隧道,免杀,C2持久化,hashcat/Padding Oracle/hash长度扩展。Use when post-exploitation, privilege escalation, lateral movement, or cracking crypto.
|
后渗透/提权+凭据破解+密码学:反弹shell,Linux/Windows提权,横向,隧道,免杀,C2持久化,hashcat/Padding Oracle/hash长度扩展。Use when post-exploitation, privilege escalation, lateral movement, or cracking crypto.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: proxy-tool-bootstrap
|
name: proxy-tool-bootstrap
|
||||||
description: >-
|
description: >-
|
||||||
自找代理+工具自举:SOCKS5/HTTP/Tor换路序列,工具Python自举,字典自生成,OOB基础设施。Use when blocked by 403/429/WAF/timeout, missing tools, or needing OOB confirmation.
|
自找代理+工具自举:SOCKS5/HTTP/Tor换路序列,工具Python自举,字典自生成,OOB基础设施。Use when blocked by 403/429/WAF/timeout, missing tools, or needing OOB confirmation.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: redteam-opsec
|
name: redteam-opsec
|
||||||
description: >-
|
description: >-
|
||||||
OPSEC隐蔽作战纪律:IP黑名单绕过,速率时序,流量混淆,最小足迹,反取证,渐进暴露。Use when maintaining stealth, bypassing IP bans, or planning covert red-team ops.
|
OPSEC隐蔽作战纪律:IP黑名单绕过,速率时序,流量混淆,最小足迹,反取证,渐进暴露。Use when maintaining stealth, bypassing IP bans, or planning covert red-team ops.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: source-code-hunting
|
name: source-code-hunting
|
||||||
description: >-
|
description: >-
|
||||||
源码狩猎:.git泄露,危险函数grep,JS RC4解混淆,semgrep/CodeQL,trufflehog,patch diff,供应链/CI。Use when hunting source leaks, secrets, JS deobfuscation, or supply-chain issues.
|
源码狩猎:.git泄露,危险函数grep,JS RC4解混淆,semgrep/CodeQL,trufflehog,patch diff,供应链/CI。Use when hunting source leaks, secrets, JS deobfuscation, or supply-chain issues.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: specialized-attack-playbooks
|
name: specialized-attack-playbooks
|
||||||
description: >-
|
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.
|
专题实战利用: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.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: unlimited-attack-scope
|
name: unlimited-attack-scope
|
||||||
description: >-
|
description: >-
|
||||||
不设限原则:搜索/验证/串联/记录一切,全领域适用,被拦换路,不达不休。Use when reminding scope is unlimited across Web/APK/cloud/AI/wireless/social eng.
|
不设限原则:搜索/验证/串联/记录一切,全领域适用,被拦换路,不达不休。Use when reminding scope is unlimited across Web/APK/cloud/AI/wireless/social eng.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: web-attack-methods
|
name: web-attack-methods
|
||||||
description: >-
|
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.
|
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.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: wireless-hardware-attack
|
name: wireless-hardware-attack
|
||||||
description: >-
|
description: >-
|
||||||
无线/硬件:WiFi PMKID/WPS/Evil Twin,BLE,Zigbee,NFC,SDR,UART/JTAG/SPI,侧信道,故障注入。Use when attacking WiFi, BLE, RFID, SDR, or hardware interfaces.
|
无线/硬件:WiFi PMKID/WPS/Evil Twin,BLE,Zigbee,NFC,SDR,UART/JTAG/SPI,侧信道,故障注入。Use when attacking WiFi, BLE, RFID, SDR, or hardware interfaces.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
name: zero-day-discovery
|
name: zero-day-discovery
|
||||||
description: >-
|
description: >-
|
||||||
0day自主发现引擎:变体分析/补丁间隙/差分/Fuzzing/污点推理/N-day武器化/猎人思维。Use when public vulns not found and need to discover 0day or weaponize N-day.
|
0day自主发现引擎:变体分析/补丁间隙/差分/Fuzzing/污点推理/N-day武器化/猎人思维。Use when public vulns not found and need to discover 0day or weaponize N-day.
|
||||||
|
metadata:
|
||||||
tags: [渗透测试, penetration-testing, 红队]
|
tags: [渗透测试, penetration-testing, 红队]
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3990,6 +3990,17 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
|
|||||||
border-top-left-radius: 2px;
|
border-top-left-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message.assistant.assistant-not-finalized .message-bubble {
|
||||||
|
border-color: rgba(245, 124, 0, 0.28);
|
||||||
|
border-left: 4px solid #f57c00;
|
||||||
|
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), rgba(245, 124, 0, 0.035));
|
||||||
|
box-shadow: 0 6px 18px rgba(245, 124, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.assistant.assistant-not-finalized .message-bubble strong:first-child {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
.message.assistant .message-bubble pre {
|
.message.assistant .message-bubble pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@@ -7381,6 +7392,16 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
|
|||||||
background: rgba(245, 124, 0, 0.09);
|
background: rgba(245, 124, 0, 0.09);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.timeline-item-finalization_check {
|
||||||
|
border-left-color: #f57c00;
|
||||||
|
background: linear-gradient(90deg, rgba(245, 124, 0, 0.1), rgba(96, 125, 139, 0.045));
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item-finalization_check .timeline-item-title {
|
||||||
|
color: #9a5200;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.timeline-item-tool_calls_detected {
|
.timeline-item-tool_calls_detected {
|
||||||
border-left-color: #0277bd;
|
border-left-color: #0277bd;
|
||||||
background: rgba(2, 119, 189, 0.06);
|
background: rgba(2, 119, 189, 0.06);
|
||||||
@@ -22967,6 +22988,18 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.webshell-ai-timeline-finalization_check {
|
||||||
|
border-left: 3px solid #f57c00;
|
||||||
|
padding-left: 10px;
|
||||||
|
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.webshell-ai-timeline-finalization_check .webshell-ai-timeline-title {
|
||||||
|
color: #9a5200;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.webshell-ai-timeline-msg {
|
.webshell-ai-timeline-msg {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
@@ -23144,6 +23177,11 @@ tr.mcp-stats-tool-row[data-tool-name]:focus-visible {
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
.webshell-ai-msg.assistant.webshell-ai-candidate-output {
|
||||||
|
border-color: rgba(245, 124, 0, 0.28);
|
||||||
|
background: linear-gradient(90deg, rgba(245, 124, 0, 0.08), rgba(245, 124, 0, 0.035));
|
||||||
|
box-shadow: 0 6px 16px rgba(245, 124, 0, 0.08);
|
||||||
|
}
|
||||||
.webshell-ai-msg.assistant.webshell-ai-msg-error {
|
.webshell-ai-msg.assistant.webshell-ai-msg-error {
|
||||||
max-width: 72%;
|
max-width: 72%;
|
||||||
border-color: rgba(220, 53, 69, 0.35);
|
border-color: rgba(220, 53, 69, 0.35);
|
||||||
@@ -30312,6 +30350,27 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skills-management-page .skill-card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skills-management-page .skill-card-tags span {
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||||
|
background: rgba(15, 23, 42, 0.04);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.skills-management-page .skill-card-actions {
|
.skills-management-page .skill-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
@@ -36232,12 +36291,42 @@ html[data-theme="dark"] .timeline-item-user_interrupt_continue {
|
|||||||
background: rgba(251, 191, 36, 0.08);
|
background: rgba(251, 191, 36, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .timeline-item-finalization_check {
|
||||||
|
background: linear-gradient(90deg, rgba(251, 191, 36, 0.1), rgba(30, 41, 59, 0.18));
|
||||||
|
border-left-color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .timeline-item-finalization_check .timeline-item-title,
|
||||||
|
html[data-theme="dark"] .webshell-ai-timeline-finalization_check .webshell-ai-timeline-title {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .message.assistant .message-bubble {
|
html[data-theme="dark"] .message.assistant .message-bubble {
|
||||||
background: #111827;
|
background: #111827;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble {
|
||||||
|
border-color: rgba(251, 191, 36, 0.24);
|
||||||
|
border-left-color: #fbbf24;
|
||||||
|
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.9));
|
||||||
|
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .message.assistant.assistant-not-finalized .message-bubble strong:first-child {
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .webshell-ai-timeline-finalization_check {
|
||||||
|
border-left-color: #fbbf24;
|
||||||
|
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .webshell-ai-msg.assistant.webshell-ai-candidate-output {
|
||||||
|
background: linear-gradient(90deg, rgba(251, 191, 36, 0.08), rgba(17, 24, 39, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .message-copy-btn {
|
html[data-theme="dark"] .message-copy-btn {
|
||||||
background: #1f2937;
|
background: #1f2937;
|
||||||
border-color: #334155;
|
border-color: #334155;
|
||||||
@@ -40035,6 +40124,247 @@ html[data-theme="dark"] .workflow-status-toggle.is-disabled {
|
|||||||
padding: 12px 18px;
|
padding: 12px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-ai-modal-content {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 64px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-modal-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-modal-header h2 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-modal-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
max-height: min(66vh, 640px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-prompt-field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 116px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-examples,
|
||||||
|
.workflow-ai-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-options label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-progress {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-progress[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-progress span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-progress b {
|
||||||
|
display: inline-grid;
|
||||||
|
flex: 0 0 22px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-progress small {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-progress .is-active {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 45%, var(--border-color));
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-progress .is-active b {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-progress .is-complete b {
|
||||||
|
background: #16a34a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-preview-title {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-preview-flow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-preview-flow i {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-preview-node {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 2px;
|
||||||
|
width: 128px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-color) 22%, var(--border-color));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-preview-node small {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-preview-node strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-preview-node.is-risky {
|
||||||
|
border-color: rgba(249, 115, 22, 0.62);
|
||||||
|
background: rgba(249, 115, 22, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-grid span,
|
||||||
|
.workflow-ai-result-section {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-grid span {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-grid strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-section {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-section p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-section ul {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-section.is-warning {
|
||||||
|
border-color: rgba(245, 158, 11, 0.45);
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-section.is-attention {
|
||||||
|
border-color: rgba(14, 165, 233, 0.42);
|
||||||
|
background: rgba(14, 165, 233, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-modal-footer {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.workflow-ai-progress {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-result-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] .workflow-dry-run-modal-icon {
|
html[data-theme="dark"] .workflow-dry-run-modal-icon {
|
||||||
color: #bfdbfe;
|
color: #bfdbfe;
|
||||||
background: rgba(59, 130, 246, 0.14);
|
background: rgba(59, 130, 246, 0.14);
|
||||||
@@ -40275,6 +40605,27 @@ html[data-theme="dark"] .workflow-toolbar #workflow-connect-btn[aria-pressed="tr
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-ai-canvas-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 9px 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-color) 35%, var(--border-color));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 92%, var(--accent-color));
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-ai-canvas-status[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-properties {
|
.workflow-properties {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3532,6 +3532,81 @@
|
|||||||
"canvasTools": "Canvas tools",
|
"canvasTools": "Canvas tools",
|
||||||
"moreActions": "More",
|
"moreActions": "More",
|
||||||
"deleteWorkflow": "Delete workflow",
|
"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": {
|
"package": {
|
||||||
"importLocal": "Import local package",
|
"importLocal": "Import local package",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
@@ -3641,7 +3716,7 @@
|
|||||||
"deleteSelected": "Delete selected",
|
"deleteSelected": "Delete selected",
|
||||||
"autoLayout": "Auto layout",
|
"autoLayout": "Auto layout",
|
||||||
"dryRun": "Dry run",
|
"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",
|
"properties": "Properties",
|
||||||
"nodeProperties": "Node properties",
|
"nodeProperties": "Node properties",
|
||||||
"edgeProperties": "Edge properties",
|
"edgeProperties": "Edge properties",
|
||||||
|
|||||||
@@ -3520,6 +3520,81 @@
|
|||||||
"canvasTools": "画布工具",
|
"canvasTools": "画布工具",
|
||||||
"moreActions": "更多",
|
"moreActions": "更多",
|
||||||
"deleteWorkflow": "删除工作流",
|
"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": {
|
"package": {
|
||||||
"importLocal": "导入本地包",
|
"importLocal": "导入本地包",
|
||||||
"export": "导出",
|
"export": "导出",
|
||||||
@@ -3629,7 +3704,7 @@
|
|||||||
"deleteSelected": "删除选中",
|
"deleteSelected": "删除选中",
|
||||||
"autoLayout": "自动布局",
|
"autoLayout": "自动布局",
|
||||||
"dryRun": "试运行",
|
"dryRun": "试运行",
|
||||||
"canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加",
|
"canvasEmpty": "从左侧拖拽节点到画布,或用自然语言生成工作流",
|
||||||
"properties": "属性",
|
"properties": "属性",
|
||||||
"nodeProperties": "节点属性",
|
"nodeProperties": "节点属性",
|
||||||
"edgeProperties": "连线属性",
|
"edgeProperties": "连线属性",
|
||||||
|
|||||||
@@ -1147,6 +1147,7 @@ async function sendAssetsToChat(assets, template) {
|
|||||||
input.value = message;
|
input.value = message;
|
||||||
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
|
if (typeof adjustTextareaHeight === 'function') adjustTextareaHeight(input);
|
||||||
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
|
// 消息流可能持续很久;启动发送即可返回,让提交弹窗立即关闭。
|
||||||
|
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-1
@@ -80,6 +80,7 @@ let chatAttachmentSeq = 0;
|
|||||||
|
|
||||||
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
// 对话模式:eino_single = Eino ADK 单代理(/api/eino-agent/stream);deep / plan_execute / supervisor = Eino 多代理(/api/multi-agent/stream,请求体 orchestration)
|
||||||
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
|
||||||
|
const AGENT_MODE_CONVERSATION_STORAGE_PREFIX = 'cyberstrike-chat-agent-mode:conversation';
|
||||||
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
const AI_CHANNEL_STORAGE_KEY = 'cyberstrike-chat-ai-channel';
|
||||||
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
const REASONING_MODE_LS = 'cyberstrike-chat-reasoning-mode';
|
||||||
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
const REASONING_EFFORT_LS = 'cyberstrike-chat-reasoning-effort';
|
||||||
@@ -733,6 +734,44 @@ function chatAgentModeNormalizeStored(stored, cfg) {
|
|||||||
return CHAT_AGENT_MODE_EINO_SINGLE;
|
return CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeConversationAgentModeForUI(mode) {
|
||||||
|
const v = String(mode || '').trim().toLowerCase().replace(/-/g, '_');
|
||||||
|
if (chatAgentModeIsEinoSingle(v)) return v;
|
||||||
|
if (chatAgentModeIsEino(v)) {
|
||||||
|
return multiAgentAPIEnabled ? v : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function conversationAgentModeStorageKey(conversationId) {
|
||||||
|
return `${AGENT_MODE_CONVERSATION_STORAGE_PREFIX}:${String(conversationId || '').trim()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConversationAgentModePreference(conversationId) {
|
||||||
|
if (!conversationId) return '';
|
||||||
|
try {
|
||||||
|
return normalizeConversationAgentModeForUI(localStorage.getItem(conversationAgentModeStorageKey(conversationId)) || '');
|
||||||
|
} catch (e) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConversationAgentModePreference(conversationId, mode) {
|
||||||
|
const normalized = normalizeConversationAgentModeForUI(mode);
|
||||||
|
if (!conversationId || !normalized) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(conversationAgentModeStorageKey(conversationId), normalized);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyConversationAgentMode(conversationId, conversation) {
|
||||||
|
const saved = readConversationAgentModePreference(conversationId);
|
||||||
|
const fromServer = normalizeConversationAgentModeForUI(conversation && (conversation.agentMode || conversation.agent_mode));
|
||||||
|
const mode = saved || fromServer;
|
||||||
|
if (!mode) return;
|
||||||
|
syncAgentModeFromValue(mode);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
||||||
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
||||||
@@ -1098,6 +1137,7 @@ function toggleAgentModePanel() {
|
|||||||
function selectAgentMode(mode) {
|
function selectAgentMode(mode) {
|
||||||
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
const ok = chatAgentModeIsEinoSingle(mode) || chatAgentModeIsEino(mode);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
saveConversationAgentModePreference(currentConversationId, mode);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
localStorage.setItem(AGENT_MODE_STORAGE_KEY, mode);
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
@@ -1351,6 +1391,10 @@ async function sendMessage() {
|
|||||||
conversationId: currentConversationId,
|
conversationId: currentConversationId,
|
||||||
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
|
||||||
};
|
};
|
||||||
|
if (window.__csNextChatFinalizationPolicy && typeof window.__csNextChatFinalizationPolicy === 'object') {
|
||||||
|
body.finalization = window.__csNextChatFinalizationPolicy;
|
||||||
|
window.__csNextChatFinalizationPolicy = null;
|
||||||
|
}
|
||||||
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
|
let streamConversationId = body.conversationId ? String(body.conversationId) : null;
|
||||||
const isStreamStillVisibleForRequest = function () {
|
const isStreamStillVisibleForRequest = function () {
|
||||||
if (!document.getElementById(progressId)) return false;
|
if (!document.getElementById(progressId)) return false;
|
||||||
@@ -1405,6 +1449,7 @@ async function sendMessage() {
|
|||||||
try {
|
try {
|
||||||
const modeSel = document.getElementById('agent-mode-select');
|
const modeSel = document.getElementById('agent-mode-select');
|
||||||
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
let modeVal = modeSel ? modeSel.value : CHAT_AGENT_MODE_EINO_SINGLE;
|
||||||
|
saveConversationAgentModePreference(streamConversationId || currentConversationId, modeVal);
|
||||||
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
const useMulti = multiAgentAPIEnabled && chatAgentModeIsEino(modeVal);
|
||||||
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
|
const streamPath = useMulti ? '/api/multi-agent/stream' : '/api/eino-agent/stream';
|
||||||
if (useMulti && modeVal) {
|
if (useMulti && modeVal) {
|
||||||
@@ -3544,7 +3589,8 @@ function normalizeToolExecutionSummaryForButton(raw) {
|
|||||||
status: data.status || '',
|
status: data.status || '',
|
||||||
executionId: data.executionId || '',
|
executionId: data.executionId || '',
|
||||||
toolCallId: data.toolCallId || '',
|
toolCallId: data.toolCallId || '',
|
||||||
processDetailId: data.processDetailId || ''
|
processDetailId: data.processDetailId || '',
|
||||||
|
resultDetailId: data.resultDetailId || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3879,6 +3925,15 @@ async function fetchProcessDetailDataForModal(detailId) {
|
|||||||
return detail && detail.data ? detail.data : null;
|
return detail && detail.data ? detail.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchProcessDetailForModal(detailId) {
|
||||||
|
const id = detailId != null ? String(detailId).trim() : '';
|
||||||
|
if (!id || typeof apiFetch !== 'function') return null;
|
||||||
|
const res = await apiFetch('/api/process-details/' + encodeURIComponent(id));
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return j && j.processDetail ? j.processDetail : null;
|
||||||
|
}
|
||||||
|
|
||||||
function processToolResultTextFromData(resultData) {
|
function processToolResultTextFromData(resultData) {
|
||||||
if (!resultData) return '';
|
if (!resultData) return '';
|
||||||
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
|
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
|
||||||
@@ -3905,6 +3960,56 @@ function processToolResultToMCPResult(resultData, rawText) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function showMCPDetailFromProcessToolItem(messageElement, summary, index) {
|
async function showMCPDetailFromProcessToolItem(messageElement, summary, index) {
|
||||||
|
const item = normalizeToolExecutionSummaryForButton(summary);
|
||||||
|
if (item.processDetailId) {
|
||||||
|
const callDetail = await fetchProcessDetailForModal(item.processDetailId);
|
||||||
|
const callData = callDetail && callDetail.data ? callDetail.data : null;
|
||||||
|
if (callData) {
|
||||||
|
const args = typeof window.parseToolCallArgsFromData === 'function'
|
||||||
|
? window.parseToolCallArgsFromData(callData)
|
||||||
|
: (callData.argumentsObj || {});
|
||||||
|
let resultData = callData._mergedResult || null;
|
||||||
|
let resultDetailId = item.resultDetailId || callData._mergedResultDetailId || '';
|
||||||
|
let rawText = resultData ? processToolResultTextFromData(resultData) : '';
|
||||||
|
const resultPayloadDeferred = resultData && resultData._payloadDeferred === true;
|
||||||
|
const resultOnlyHasPreview = resultData && resultData.result == null && resultData.error == null && resultData.resultPreview != null;
|
||||||
|
if (resultDetailId && (!resultData || resultPayloadDeferred || resultOnlyHasPreview)) {
|
||||||
|
const resultDetail = await fetchProcessDetailForModal(resultDetailId);
|
||||||
|
const fullResult = resultDetail && resultDetail.data ? resultDetail.data : null;
|
||||||
|
if (fullResult) {
|
||||||
|
resultData = fullResult;
|
||||||
|
rawText = processToolResultTextFromData(fullResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const displayState = resultData && typeof window.getToolResultDisplayState === 'function'
|
||||||
|
? window.getToolResultDisplayState(resultData, { rawText: rawText })
|
||||||
|
: null;
|
||||||
|
const backgroundRunning = (displayState && displayState.kind === 'background_running') || String(item.status || '').toLowerCase() === 'background_running';
|
||||||
|
const success = resultData
|
||||||
|
? !(displayState ? displayState.isError : (resultData.isError || resultData.success === false))
|
||||||
|
: String(item.status || '').toLowerCase() !== 'failed';
|
||||||
|
const status = backgroundRunning
|
||||||
|
? 'background_running'
|
||||||
|
: (resultData || item.status
|
||||||
|
? (success ? 'completed' : 'failed')
|
||||||
|
: 'running');
|
||||||
|
const exec = {
|
||||||
|
id: (resultData && resultData.executionId) || item.executionId || callData.executionId || '',
|
||||||
|
toolName: item.toolName || callData.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'),
|
||||||
|
status: status,
|
||||||
|
startTime: callDetail.createdAt || '',
|
||||||
|
arguments: args || {},
|
||||||
|
result: processToolResultToMCPResult(resultData, rawText),
|
||||||
|
error: resultData && resultData.error ? String(resultData.error) : ''
|
||||||
|
};
|
||||||
|
openAppModal('mcp-detail-modal', { focus: false });
|
||||||
|
deferModalContent(function () {
|
||||||
|
renderMCPDetailModal(exec);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const target = await findToolExecutionTimelineItem(messageElement, summary, index);
|
const target = await findToolExecutionTimelineItem(messageElement, summary, index);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
alert(typeof window.t === 'function'
|
alert(typeof window.t === 'function'
|
||||||
@@ -4845,6 +4950,7 @@ async function loadConversation(conversationId) {
|
|||||||
if (typeof window.setCurrentRole === 'function') {
|
if (typeof window.setCurrentRole === 'function') {
|
||||||
window.setCurrentRole(conversationRoleName || '默认');
|
window.setCurrentRole(conversationRoleName || '默认');
|
||||||
}
|
}
|
||||||
|
applyConversationAgentMode(conversationId, conversation);
|
||||||
try {
|
try {
|
||||||
window.currentConversationId = conversationId;
|
window.currentConversationId = conversationId;
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
|
|||||||
@@ -1334,6 +1334,7 @@ function scanFofaRow(encodedRowJson, clickEvent) {
|
|||||||
}
|
}
|
||||||
if (autoSend) {
|
if (autoSend) {
|
||||||
if (typeof sendMessage === 'function') {
|
if (typeof sendMessage === 'function') {
|
||||||
|
window.__csNextChatFinalizationPolicy = { requireExecutionEvidence: true };
|
||||||
sendMessage();
|
sendMessage();
|
||||||
} else {
|
} else {
|
||||||
alert(_t('infoCollect.noSendMessage'));
|
alert(_t('infoCollect.noSendMessage'));
|
||||||
|
|||||||
+123
-4
@@ -198,6 +198,92 @@ function resolveFinalAssistantResponseText(finalMessage, streamState) {
|
|||||||
return finalMessage;
|
return finalMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFinalizedResponseData(data) {
|
||||||
|
return !!(data && data.finalized === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFinalizationContract(data) {
|
||||||
|
if (!data || typeof data !== 'object') return false;
|
||||||
|
return Object.prototype.hasOwnProperty.call(data, 'finalized')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'finalizable')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'completionReason')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'evidenceVerified')
|
||||||
|
|| Object.prototype.hasOwnProperty.call(data, 'missingChecks');
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationCheckTitle(data) {
|
||||||
|
return isFinalizedResponseData(data) ? '最终回复检查通过' : '最终回复检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationReasonLabel(reason, status) {
|
||||||
|
const key = String(reason || status || '').trim();
|
||||||
|
const labels = {
|
||||||
|
pending_tool_executions: '等待工具执行完成',
|
||||||
|
missing_execution_evidence: '缺少完成态证据',
|
||||||
|
awaiting_hitl: '等待人工确认',
|
||||||
|
empty_response: '未捕获到有效回复',
|
||||||
|
missing_finalization_contract: '缺少最终化证明',
|
||||||
|
in_progress: '仍在验证',
|
||||||
|
blocked: '检查未通过',
|
||||||
|
failed: '任务失败',
|
||||||
|
cancelled: '任务已取消',
|
||||||
|
verified: '已验证'
|
||||||
|
};
|
||||||
|
return labels[key] || key || '检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationMissingCheckLabel(check) {
|
||||||
|
const s = String(check || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束';
|
||||||
|
if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录';
|
||||||
|
if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认';
|
||||||
|
if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本';
|
||||||
|
if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactStringList(values, limit) {
|
||||||
|
const arr = Array.isArray(values) ? values.filter(Boolean).map(String) : [];
|
||||||
|
const max = limit || 3;
|
||||||
|
if (arr.length <= max) return arr;
|
||||||
|
return arr.slice(0, max).concat('另 ' + (arr.length - max) + ' 项');
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizationNoticeMarkdown(responseData, eventMessage) {
|
||||||
|
const hasContract = hasFinalizationContract(responseData);
|
||||||
|
const reason = hasContract
|
||||||
|
? finalizationReasonLabel(responseData && responseData.completionReason, responseData && responseData.status)
|
||||||
|
: finalizationReasonLabel('missing_finalization_contract');
|
||||||
|
const lines = ['**仍在验证,暂不生成最终结论**', '', '状态:' + reason];
|
||||||
|
const pending = compactStringList(responseData && responseData.pendingExecutionIds, 3);
|
||||||
|
if (pending.length) {
|
||||||
|
lines.push('待完成工具:`' + pending.join('`, `') + '`');
|
||||||
|
}
|
||||||
|
const rawMissingChecks = responseData && responseData.missingChecks;
|
||||||
|
const missingChecks = Array.isArray(rawMissingChecks)
|
||||||
|
? rawMissingChecks
|
||||||
|
: (rawMissingChecks ? [rawMissingChecks] : []);
|
||||||
|
const missing = compactStringList(missingChecks.map(finalizationMissingCheckLabel).filter(Boolean), 3);
|
||||||
|
if (missing.length) {
|
||||||
|
lines.push('待完成检查:' + missing.join(';'));
|
||||||
|
}
|
||||||
|
if (!hasContract && eventMessage != null && String(eventMessage).trim() !== '') {
|
||||||
|
lines.push('', '候选输出已移入过程详情,避免误判为最终结论。');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAssistantFinalizationState(assistantMessageId, responseData) {
|
||||||
|
const assistantElement = document.getElementById(assistantMessageId);
|
||||||
|
if (!assistantElement) return;
|
||||||
|
const finalized = isFinalizedResponseData(responseData);
|
||||||
|
assistantElement.dataset.finalized = finalized ? 'true' : 'false';
|
||||||
|
assistantElement.dataset.finalizationStatus = responseData && responseData.status ? String(responseData.status) : '';
|
||||||
|
assistantElement.classList.toggle('assistant-finalized', finalized);
|
||||||
|
assistantElement.classList.toggle('assistant-not-finalized', !finalized);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
* 主通道 response 结束时:将流式占位条目固化为 planning(与后端 flushResponsePlan 落库类型一致),
|
||||||
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
* 避免 integrateProgressToMCPSection 快照前删除占位导致「助手输出」仅刷新后才出现。
|
||||||
@@ -2440,6 +2526,26 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'finalization_check':
|
||||||
|
const finalizationCheckData = event.data || {};
|
||||||
|
const finalizationCheckPassed = isFinalizedResponseData(finalizationCheckData);
|
||||||
|
addTimelineItem(timeline, 'finalization_check', {
|
||||||
|
title: finalizationCheckTitle(finalizationCheckData),
|
||||||
|
message: finalizationCheckPassed ? (event.message || '最终回复检查通过。') : finalizationNoticeMarkdown(finalizationCheckData, event.message),
|
||||||
|
data: event.data,
|
||||||
|
expanded: !finalizationCheckPassed
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'finalization_auto_continue':
|
||||||
|
addTimelineItem(timeline, 'progress', {
|
||||||
|
title: '继续验证',
|
||||||
|
message: event.message,
|
||||||
|
data: event.data,
|
||||||
|
expanded: false
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
case 'hitl_interrupt':
|
case 'hitl_interrupt':
|
||||||
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
const hitlTargetItem = findToolCallItemForHitl(timeline, event.data || {});
|
||||||
if (hitlTargetItem && hitlTargetItem.id) {
|
if (hitlTargetItem && hitlTargetItem.id) {
|
||||||
@@ -2958,7 +3064,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const streamState = responseStreamStateByProgressId.get(progressId);
|
const streamState = responseStreamStateByProgressId.get(progressId);
|
||||||
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
||||||
let assistantIdFinal = existingAssistantId;
|
let assistantIdFinal = existingAssistantId;
|
||||||
const bubbleText = resolveFinalAssistantResponseText(event.message, streamState);
|
const responseFinalized = isFinalizedResponseData(responseData);
|
||||||
|
const responseHasFinalizationContract = hasFinalizationContract(responseData);
|
||||||
|
const resolvedResponseText = resolveFinalAssistantResponseText(event.message, streamState);
|
||||||
|
const bubbleText = responseFinalized
|
||||||
|
? resolvedResponseText
|
||||||
|
: finalizationNoticeMarkdown(responseData, event.message);
|
||||||
|
|
||||||
if (!assistantIdFinal) {
|
if (!assistantIdFinal) {
|
||||||
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
assistantIdFinal = addMessage('assistant', bubbleText, mcpIds, progressId);
|
||||||
@@ -2967,11 +3078,12 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
setAssistantId(assistantIdFinal);
|
setAssistantId(assistantIdFinal);
|
||||||
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
updateAssistantBubbleContent(assistantIdFinal, bubbleText, true);
|
||||||
}
|
}
|
||||||
|
markAssistantFinalizationState(assistantIdFinal, responseData);
|
||||||
|
|
||||||
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
// 将 response_start/response_delta 占位固化为 planning,与后端落库一致后再快照过程详情
|
||||||
if (streamState && streamState.itemId) {
|
if (streamState && streamState.itemId) {
|
||||||
finalizeMainResponseStreamItem(streamState, event.message, responseData);
|
finalizeMainResponseStreamItem(streamState, responseFinalized ? event.message : '', responseData);
|
||||||
} else if (timeline && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
} else if (timeline && responseFinalized && bubbleText && String(bubbleText).trim() && !isEinoEmptyResponsePlaceholder(event.message)) {
|
||||||
addTimelineItem(timeline, 'planning', {
|
addTimelineItem(timeline, 'planning', {
|
||||||
title: typeof einoMainStreamPlanningTitle === 'function'
|
title: typeof einoMainStreamPlanningTitle === 'function'
|
||||||
? einoMainStreamPlanningTitle(responseData)
|
? einoMainStreamPlanningTitle(responseData)
|
||||||
@@ -2980,6 +3092,13 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
data: responseData,
|
data: responseData,
|
||||||
expanded: false
|
expanded: false
|
||||||
});
|
});
|
||||||
|
} else if (timeline && !responseFinalized && !responseHasFinalizationContract && resolvedResponseText && String(resolvedResponseText).trim()) {
|
||||||
|
addTimelineItem(timeline, 'finalization_check', {
|
||||||
|
title: '候选输出缺少最终化证明',
|
||||||
|
message: resolvedResponseText,
|
||||||
|
data: Object.assign({}, responseData, { missingFinalizationContract: true }),
|
||||||
|
expanded: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||||
@@ -5562,7 +5681,7 @@ function updateMonitorTimelineSection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const MCP_STATS_TOP_N = 3;
|
const MCP_STATS_TOP_N = 6;
|
||||||
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
|
const MCP_TIMELINE_RANGES = ['24h', '7d', '30d'];
|
||||||
|
|
||||||
function getMcpMonitorTimelineRange() {
|
function getMcpMonitorTimelineRange() {
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ function renderSkillsList() {
|
|||||||
? _t('skills.cardFiles', { count: skill.file_count })
|
? _t('skills.cardFiles', { count: skill.file_count })
|
||||||
: '';
|
: '';
|
||||||
const metaItems = [ver, fc, sc].filter(Boolean);
|
const metaItems = [ver, fc, sc].filter(Boolean);
|
||||||
|
const tags = Array.isArray(skill.tags) ? skill.tags.filter(Boolean) : [];
|
||||||
|
const visibleTags = tags.slice(0, 4);
|
||||||
|
const hiddenTagCount = Math.max(0, tags.length - visibleTags.length);
|
||||||
|
const tagsHtml = visibleTags.length
|
||||||
|
? `<div class="skill-card-tags">${visibleTags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}${hiddenTagCount ? `<span>+${hiddenTagCount}</span>` : ''}</div>`
|
||||||
|
: '';
|
||||||
return `
|
return `
|
||||||
<div class="skill-card">
|
<div class="skill-card">
|
||||||
<div class="skill-card-body">
|
<div class="skill-card-body">
|
||||||
@@ -177,6 +183,7 @@ function renderSkillsList() {
|
|||||||
${metaItems.length ? `<div class="skill-card-meta">${metaItems.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>` : ''}
|
${metaItems.length ? `<div class="skill-card-meta">${metaItems.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
|
<div class="skill-card-description">${escapeHtml(skill.description || _t('skills.noDescription'))}</div>
|
||||||
|
${tagsHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="skill-card-actions">
|
<div class="skill-card-actions">
|
||||||
<button type="button" class="btn-secondary btn-small" data-skill-view="${escapeHtml(sid)}">${_t('common.view')}</button>
|
<button type="button" class="btn-secondary btn-small" data-skill-view="${escapeHtml(sid)}">${_t('common.view')}</button>
|
||||||
|
|||||||
@@ -1338,6 +1338,55 @@ function escapeHtmlAttr(s) {
|
|||||||
return escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
return escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationReasonLabel(reason, status) {
|
||||||
|
var key = String(reason || status || '').trim();
|
||||||
|
var labels = {
|
||||||
|
pending_tool_executions: '等待工具执行完成',
|
||||||
|
missing_execution_evidence: '缺少完成态证据',
|
||||||
|
awaiting_hitl: '等待人工确认',
|
||||||
|
empty_response: '未捕获到有效回复',
|
||||||
|
missing_finalization_contract: '缺少最终化证明',
|
||||||
|
in_progress: '仍在验证',
|
||||||
|
blocked: '检查未通过',
|
||||||
|
failed: '任务失败',
|
||||||
|
cancelled: '任务已取消',
|
||||||
|
verified: '已验证'
|
||||||
|
};
|
||||||
|
return labels[key] || key || '检查未通过';
|
||||||
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationMissingCheckLabel(check) {
|
||||||
|
var s = String(check || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (s.indexOf('tool execution still queued or running') !== -1) return '仍有工具执行未结束';
|
||||||
|
if (s.indexOf('execution evidence is required but no completed tool execution was recorded') !== -1) return '本轮要求执行证据,但没有 completed 工具记录';
|
||||||
|
if (s.indexOf('workflow is awaiting HITL approval') !== -1) return '工作流正在等待人工确认';
|
||||||
|
if (s.indexOf('assistant final text is empty') !== -1) return '未捕获到有效最终文本';
|
||||||
|
if (s.indexOf('agent run status is ') === 0) return '任务状态仍为 ' + s.replace('agent run status is ', '');
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webshellFinalizationNotice(data, eventMessage, hasContract) {
|
||||||
|
var reason = hasContract
|
||||||
|
? webshellFinalizationReasonLabel(data && data.completionReason, data && data.status)
|
||||||
|
: webshellFinalizationReasonLabel('missing_finalization_contract');
|
||||||
|
var lines = ['仍在验证,暂不生成最终结论', '状态:' + reason];
|
||||||
|
var pending = Array.isArray(data && data.pendingExecutionIds) ? data.pendingExecutionIds.filter(Boolean).map(String) : [];
|
||||||
|
if (pending.length) {
|
||||||
|
lines.push('待完成工具:' + pending.slice(0, 3).join(', ') + (pending.length > 3 ? ' 等' : ''));
|
||||||
|
}
|
||||||
|
var missing = Array.isArray(data && data.missingChecks)
|
||||||
|
? data.missingChecks.map(webshellFinalizationMissingCheckLabel).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
if (missing.length) {
|
||||||
|
lines.push('待完成检查:' + missing.slice(0, 2).join(';') + (missing.length > 2 ? ' 等' : ''));
|
||||||
|
}
|
||||||
|
if (!hasContract && eventMessage) {
|
||||||
|
lines.push('候选输出已移入过程详情。');
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
function escapeSingleQuotedShellArg(value) {
|
function escapeSingleQuotedShellArg(value) {
|
||||||
var s = value == null ? '' : String(value);
|
var s = value == null ? '' : String(value);
|
||||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||||
@@ -3393,7 +3442,10 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
message: message,
|
message: message,
|
||||||
webshellConnectionId: conn.id,
|
webshellConnectionId: conn.id,
|
||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
role: wsRole
|
role: wsRole,
|
||||||
|
finalization: {
|
||||||
|
requireExecutionEvidence: true
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
var wsPid = getWebshellAiProjectSelection(conn);
|
var wsPid = getWebshellAiProjectSelection(conn);
|
||||||
@@ -3465,6 +3517,8 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
streamingTarget = '';
|
streamingTarget = '';
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
streamingTypingId = webshellStreamingTypingId;
|
streamingTypingId = webshellStreamingTypingId;
|
||||||
|
assistantDiv.dataset.finalized = 'false';
|
||||||
|
assistantDiv.classList.add('webshell-ai-candidate-output');
|
||||||
assistantDiv.textContent = '…';
|
assistantDiv.textContent = '…';
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
} else if (_et === 'response_delta') {
|
} else if (_et === 'response_delta') {
|
||||||
@@ -3485,6 +3539,23 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
} else if (_et === 'response') {
|
} else if (_et === 'response') {
|
||||||
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
|
var text = (_em != null && _em !== '') ? _em : (typeof _ed === 'string' ? _ed : '');
|
||||||
|
var finalized = !!(_ed && _ed.finalized === true);
|
||||||
|
var hasFinalizationContract = !!(_ed && (
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'finalized') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'finalizable') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'completionReason') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'evidenceVerified') ||
|
||||||
|
Object.prototype.hasOwnProperty.call(_ed, 'missingChecks')
|
||||||
|
));
|
||||||
|
assistantDiv.dataset.finalized = finalized ? 'true' : 'false';
|
||||||
|
assistantDiv.classList.toggle('webshell-ai-candidate-output', !finalized);
|
||||||
|
assistantDiv.classList.toggle('webshell-ai-finalized-output', finalized);
|
||||||
|
if (!finalized && !hasFinalizationContract && text) {
|
||||||
|
appendTimelineItem('finalization_check', '候选输出缺少最终化证明', text, Object.assign({}, _ed || {}, { missingFinalizationContract: true }));
|
||||||
|
text = webshellFinalizationNotice(_ed || {}, text, false);
|
||||||
|
} else if (!finalized && hasFinalizationContract) {
|
||||||
|
text = webshellFinalizationNotice(_ed || {}, text, true);
|
||||||
|
}
|
||||||
if (text) {
|
if (text) {
|
||||||
streamingTarget = String(text);
|
streamingTarget = String(text);
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
@@ -3493,6 +3564,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Terminal events ───
|
// ─── Terminal events ───
|
||||||
|
} else if (_et === 'finalization_check') {
|
||||||
|
var finalizationOk = !!(_ed && _ed.finalized === true);
|
||||||
|
appendTimelineItem('finalization_check', finalizationOk ? '最终回复检查通过' : '最终回复检查未通过', finalizationOk ? (_em || '最终回复检查通过。') : webshellFinalizationNotice(_ed || {}, _em, true), _ed);
|
||||||
|
} else if (_et === 'finalization_auto_continue') {
|
||||||
|
appendTimelineItem('progress', '继续验证', _em, _ed);
|
||||||
} else if (_et === 'error' && _em) {
|
} else if (_et === 'error' && _em) {
|
||||||
streamingTypingId += 1;
|
streamingTypingId += 1;
|
||||||
var errLabel = wsTOr('chat.error', '错误');
|
var errLabel = wsTOr('chat.error', '错误');
|
||||||
|
|||||||
+586
-4
@@ -37,6 +37,12 @@
|
|||||||
requestSignature: '',
|
requestSignature: '',
|
||||||
dragDepth: 0
|
dragDepth: 0
|
||||||
};
|
};
|
||||||
|
const workflowAiState = {
|
||||||
|
draft: null,
|
||||||
|
result: null,
|
||||||
|
activeStep: '',
|
||||||
|
animating: false
|
||||||
|
};
|
||||||
const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id';
|
const WORKFLOW_PACKAGE_INSPECTION_STORAGE_KEY = 'csai.workflow-package.inspection-id';
|
||||||
const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id';
|
const WORKFLOW_PACKAGE_IMPORT_STORAGE_KEY = 'csai.workflow-package.import-id';
|
||||||
|
|
||||||
@@ -67,6 +73,18 @@
|
|||||||
const NODE_PLACEMENT_PADDING = 20;
|
const NODE_PLACEMENT_PADDING = 20;
|
||||||
|
|
||||||
const WORKFLOW_EDIT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
const WORKFLOW_EDIT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
||||||
|
const WORKFLOW_AI_TOOL_HINTS = [
|
||||||
|
{ keywords: ['子域名', 'subdomain', 'subfinder', 'amass'], tools: ['subfinder', 'amass'], label: '子域名发现' },
|
||||||
|
{ keywords: ['端口', 'port', 'nmap', 'rustscan', 'masscan'], tools: ['nmap', 'rustscan', 'masscan'], label: '端口扫描' },
|
||||||
|
{ keywords: ['漏洞', 'vuln', '漏洞扫描', 'nuclei', 'nikto', 'zap'], tools: ['nuclei', 'nikto', 'zap'], label: '漏洞扫描' },
|
||||||
|
{ keywords: ['目录', '路径', '暴露页面', 'dir', 'ffuf', 'gobuster', 'feroxbuster'], tools: ['ffuf', 'gobuster', 'feroxbuster', 'dirsearch'], label: '暴露面探测' },
|
||||||
|
{ keywords: ['证书', 'certificate', 'crt'], tools: ['subfinder'], label: '证书与域名线索收集' },
|
||||||
|
{ keywords: ['云', 'cloud', '配置审计', 'prowler', 'scout'], tools: ['prowler', 'scout-suite'], label: '云配置审计' },
|
||||||
|
{ keywords: ['容器', '镜像', 'k8s', 'kubernetes', 'trivy', 'kube'], tools: ['trivy', 'kube-bench', 'kube-hunter'], label: '容器安全检查' },
|
||||||
|
{ keywords: ['情报', '威胁情报', 'threat', 'ioc', 'virustotal', 'shodan', 'fofa'], tools: ['virustotal_search', 'shodan_search', 'fofa_search'], label: '威胁情报收集' }
|
||||||
|
];
|
||||||
|
const WORKFLOW_AI_HIGH_RISK_RE = /(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)/i;
|
||||||
|
const WORKFLOW_AI_PROGRESS_STEPS = ['understand', 'match', 'draft', 'audit'];
|
||||||
|
|
||||||
function esc(text) {
|
function esc(text) {
|
||||||
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
|
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
|
||||||
@@ -166,6 +184,10 @@
|
|||||||
function graphToElements(graph) {
|
function graphToElements(graph) {
|
||||||
const nodes = (graph.nodes || []).map((node, index) => ({
|
const nodes = (graph.nodes || []).map((node, index) => ({
|
||||||
group: 'nodes',
|
group: 'nodes',
|
||||||
|
classes: [
|
||||||
|
node.config && node.config.generated_by === 'natural_language' ? 'ai-generated' : '',
|
||||||
|
node.config && (node.config.risk_level === 'high' || node.config.requires_human_confirmation === 'true') ? 'high-risk' : ''
|
||||||
|
].filter(Boolean).join(' '),
|
||||||
data: {
|
data: {
|
||||||
id: node.id || `node-${index + 1}`,
|
id: node.id || `node-${index + 1}`,
|
||||||
label: node.label || wfNodeLabel(node.type) || node.id || _t('workflows.nodeFallback', { n: index + 1 }),
|
label: node.label || wfNodeLabel(node.type) || node.id || _t('workflows.nodeFallback', { n: index + 1 }),
|
||||||
@@ -265,6 +287,8 @@
|
|||||||
{ selector: 'node[type="hitl"]', style: { 'background-color': '#0f766e', 'border-color': '#5eead4' } },
|
{ selector: 'node[type="hitl"]', style: { 'background-color': '#0f766e', 'border-color': '#5eead4' } },
|
||||||
{ selector: 'node[type="output"]', style: { 'background-color': '#4338ca', 'border-color': '#a5b4fc' } },
|
{ selector: 'node[type="output"]', style: { 'background-color': '#4338ca', 'border-color': '#a5b4fc' } },
|
||||||
{ selector: 'node[type="end"]', style: { 'background-color': '#be123c', 'border-color': '#fb7185' } },
|
{ selector: 'node[type="end"]', style: { 'background-color': '#be123c', 'border-color': '#fb7185' } },
|
||||||
|
{ selector: 'node.ai-generated', style: { 'border-width': 2, 'border-color': '#38bdf8' } },
|
||||||
|
{ selector: 'node.high-risk', style: { 'border-width': 3, 'border-color': '#f97316' } },
|
||||||
{
|
{
|
||||||
selector: 'edge',
|
selector: 'edge',
|
||||||
style: {
|
style: {
|
||||||
@@ -1350,6 +1374,559 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workflowAiOption(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return !!(el && el.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiSleep(ms) {
|
||||||
|
return new Promise(function (resolve) { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiStepLabel(step) {
|
||||||
|
return _t('workflows.ai.steps.' + step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiSetProgress(activeStep, done) {
|
||||||
|
workflowAiState.activeStep = activeStep || '';
|
||||||
|
const wrap = document.getElementById('workflow-ai-progress');
|
||||||
|
if (!wrap) return;
|
||||||
|
if (!activeStep && !done) {
|
||||||
|
wrap.hidden = true;
|
||||||
|
wrap.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wrap.hidden = false;
|
||||||
|
const activeIndex = WORKFLOW_AI_PROGRESS_STEPS.indexOf(activeStep);
|
||||||
|
wrap.innerHTML = WORKFLOW_AI_PROGRESS_STEPS.map(function (step, index) {
|
||||||
|
const complete = done || (activeIndex >= 0 && index < activeIndex);
|
||||||
|
const active = !done && step === activeStep;
|
||||||
|
return `<span class="${complete ? 'is-complete' : ''} ${active ? 'is-active' : ''}">
|
||||||
|
<b>${complete ? '✓' : index + 1}</b>
|
||||||
|
<small>${esc(workflowAiStepLabel(step))}</small>
|
||||||
|
</span>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiPreviewItems(graph) {
|
||||||
|
const nodes = (graph.nodes || []).slice().sort(function (a, b) {
|
||||||
|
const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0;
|
||||||
|
const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0;
|
||||||
|
const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0;
|
||||||
|
const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0;
|
||||||
|
return ax === bx ? ay - by : ax - bx;
|
||||||
|
});
|
||||||
|
return nodes.slice(0, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiPreviewHtml(graph) {
|
||||||
|
const items = workflowAiPreviewItems(graph);
|
||||||
|
if (!items.length) return '';
|
||||||
|
return `<div class="workflow-ai-preview" aria-label="${esc(_t('workflows.ai.preview'))}">
|
||||||
|
<div class="workflow-ai-preview-title">${esc(_t('workflows.ai.preview'))}</div>
|
||||||
|
<div class="workflow-ai-preview-flow">
|
||||||
|
${items.map(function (node, index) {
|
||||||
|
const type = node.type || 'tool';
|
||||||
|
const cfg = node.config || {};
|
||||||
|
const risky = cfg.risk_level === 'high' || cfg.requires_human_confirmation === 'true';
|
||||||
|
return `<span class="workflow-ai-preview-node is-${esc(type)} ${risky ? 'is-risky' : ''}">
|
||||||
|
<small>${esc(wfNodeLabel(type))}</small>
|
||||||
|
<strong>${esc(node.label || wfNodeLabel(type))}</strong>
|
||||||
|
</span>${index < items.length - 1 ? '<i aria-hidden="true">→</i>' : ''}`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiSlug(text) {
|
||||||
|
const raw = String(text || '').trim().toLowerCase();
|
||||||
|
const ascii = raw.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
if (ascii) return ascii.slice(0, 48);
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < raw.length; i++) hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
|
||||||
|
return 'ai-workflow-' + Math.abs(hash || Date.now()).toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiMatchTool(toolNames) {
|
||||||
|
if (!workflowToolOptions.length) return '';
|
||||||
|
const enabled = workflowToolOptions.filter(tool => tool.enabled !== false);
|
||||||
|
const all = enabled.length ? enabled : workflowToolOptions;
|
||||||
|
for (const wanted of toolNames || []) {
|
||||||
|
const lower = String(wanted || '').toLowerCase();
|
||||||
|
const found = all.find(tool => String(tool.key || '').toLowerCase().includes(lower));
|
||||||
|
if (found) return found.key;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiDetectCapabilities(prompt) {
|
||||||
|
const lower = String(prompt || '').toLowerCase();
|
||||||
|
const capabilities = [];
|
||||||
|
WORKFLOW_AI_TOOL_HINTS.forEach(function (hint) {
|
||||||
|
if (hint.keywords.some(keyword => lower.indexOf(String(keyword).toLowerCase()) !== -1)) {
|
||||||
|
capabilities.push({
|
||||||
|
label: hint.label,
|
||||||
|
tool_name: workflowAiMatchTool(hint.tools),
|
||||||
|
tool_candidates: hint.tools.slice()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!capabilities.length) {
|
||||||
|
capabilities.push({ label: _t('workflows.ai.resultCapabilities'), tool_name: '', tool_candidates: [] });
|
||||||
|
}
|
||||||
|
return capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiNode(id, type, label, x, y, config) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
position: { x, y },
|
||||||
|
config: Object.assign(configWithDefaults(type, {}), config || {}, {
|
||||||
|
generated_by: 'natural_language',
|
||||||
|
needs_review: 'true'
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiEdge(id, source, target, label, config) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
label: label || '',
|
||||||
|
config: config || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiBuildDraft(prompt, options) {
|
||||||
|
const capabilities = workflowAiDetectCapabilities(prompt);
|
||||||
|
const wantsApproval = /(审批|确认|审核|负责人|人工|review|approve|approval|human)/i.test(prompt);
|
||||||
|
const wantsReport = /(报告|汇总|输出|通知|任务|工单|report|summary|notify|ticket)/i.test(prompt);
|
||||||
|
const wantsCondition = /(如果|发现|存在|高危|新增|失败|通过|否则|if|when|high|critical|new|fail)/i.test(prompt);
|
||||||
|
const highRisk = WORKFLOW_AI_HIGH_RISK_RE.test(prompt);
|
||||||
|
const nodes = [];
|
||||||
|
const edges = [];
|
||||||
|
const assumptions = [];
|
||||||
|
const riskWarnings = [];
|
||||||
|
let x = 120;
|
||||||
|
const y = 150;
|
||||||
|
let nodeIndex = 1;
|
||||||
|
let edgeIndex = 1;
|
||||||
|
let openConditionId = '';
|
||||||
|
|
||||||
|
function nextId(prefix) {
|
||||||
|
const id = prefix + '-' + nodeIndex;
|
||||||
|
nodeIndex += 1;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function add(type, label, config, yy) {
|
||||||
|
const id = nextId(type);
|
||||||
|
nodes.push(workflowAiNode(id, type, label, x, yy || y, config));
|
||||||
|
x += 210;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
function connect(source, target, label, config) {
|
||||||
|
edges.push(workflowAiEdge('edge-ai-' + edgeIndex, source, target, label, config));
|
||||||
|
edgeIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = add('start', wfNodeLabel('start'), { input_keys: 'message, conversationId, projectId, target' });
|
||||||
|
let previous = start;
|
||||||
|
capabilities.forEach(function (capability) {
|
||||||
|
const hasTool = !!capability.tool_name;
|
||||||
|
const id = add(hasTool ? 'tool' : 'agent', capability.label, hasTool ? {
|
||||||
|
tool_name: capability.tool_name,
|
||||||
|
arguments: '{"target":"{{inputs.target}}","message":"{{inputs.message}}"}',
|
||||||
|
timeout_seconds: '120',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
} : {
|
||||||
|
agent_mode: 'eino_single',
|
||||||
|
input_binding: { from: 'previous', field: 'output' },
|
||||||
|
instruction: capability.label + '。根据用户需求执行安全流程步骤,并输出结构化结果:' + prompt,
|
||||||
|
output_key: 'agent_result',
|
||||||
|
join_strategy: 'all_merge',
|
||||||
|
missing_tool_candidates: capability.tool_candidates.join(', ')
|
||||||
|
});
|
||||||
|
connect(previous, id);
|
||||||
|
previous = id;
|
||||||
|
if (!hasTool && capability.tool_candidates.length) {
|
||||||
|
assumptions.push(capability.label + ' 未匹配到已启用工具,已生成 Agent 草稿节点。');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wantsCondition) {
|
||||||
|
const condition = add('condition', highRisk ? '是否需要高风险处置' : '是否满足触发条件', {
|
||||||
|
expression: highRisk ? '{{previous.output}} contains "高危"' : '{{previous.output}} != ""',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
});
|
||||||
|
connect(previous, condition);
|
||||||
|
openConditionId = condition;
|
||||||
|
const report = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), {
|
||||||
|
output_key: 'result',
|
||||||
|
source_binding: { from: 'previous', field: 'output' },
|
||||||
|
static_value: '',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
}, y + 130);
|
||||||
|
connect(condition, report, _t('workflows.edges.no'), { condition: '{{previous.matched}} == "false"', branch: 'false' });
|
||||||
|
previous = condition;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (highRisk) {
|
||||||
|
let insertedApproval = false;
|
||||||
|
if (!options.allowHighRisk || wantsApproval) {
|
||||||
|
const approval = add('hitl', '人工审批', {
|
||||||
|
prompt: '请确认是否允许继续执行高风险处置:' + prompt,
|
||||||
|
prompt_binding: { from: 'previous', field: 'output' },
|
||||||
|
reviewer: 'human',
|
||||||
|
join_strategy: 'all_merge',
|
||||||
|
risk_level: 'high'
|
||||||
|
});
|
||||||
|
connect(previous, approval, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
|
||||||
|
if (previous === openConditionId) openConditionId = '';
|
||||||
|
previous = approval;
|
||||||
|
insertedApproval = true;
|
||||||
|
}
|
||||||
|
const action = add('agent', '执行受控处置', {
|
||||||
|
agent_mode: 'eino_single',
|
||||||
|
input_binding: { from: 'previous', field: 'output' },
|
||||||
|
instruction: '仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:' + prompt,
|
||||||
|
output_key: 'remediation_plan',
|
||||||
|
join_strategy: 'all_merge',
|
||||||
|
risk_level: 'high',
|
||||||
|
requires_human_confirmation: 'true'
|
||||||
|
});
|
||||||
|
connect(previous, action, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
|
||||||
|
if (previous === openConditionId) openConditionId = '';
|
||||||
|
previous = action;
|
||||||
|
riskWarnings.push(insertedApproval
|
||||||
|
? '检测到高风险动作,已加入人工确认与 requires_human_confirmation 标记。'
|
||||||
|
: '检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。');
|
||||||
|
} else if (wantsApproval && !nodes.some(node => node.type === 'hitl')) {
|
||||||
|
const approval = add('hitl', '人工审批', {
|
||||||
|
prompt: '请审核工作流阶段结果:' + prompt,
|
||||||
|
prompt_binding: { from: 'previous', field: 'output' },
|
||||||
|
reviewer: 'human',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
});
|
||||||
|
connect(previous, approval);
|
||||||
|
previous = approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = add('output', wantsReport ? '输出报告' : wfNodeLabel('output'), {
|
||||||
|
output_key: 'result',
|
||||||
|
source_binding: { from: 'previous', field: 'output' },
|
||||||
|
static_value: '',
|
||||||
|
join_strategy: 'all_merge'
|
||||||
|
});
|
||||||
|
connect(previous, output, previous === openConditionId ? _t('workflows.edges.yes') : '', previous === openConditionId ? { condition: '{{previous.matched}} == "true"', branch: 'true' } : {});
|
||||||
|
|
||||||
|
const graph = {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
config: {
|
||||||
|
schema_version: 1,
|
||||||
|
generated_by: 'natural_language',
|
||||||
|
source_prompt: prompt,
|
||||||
|
objective: options.includeObjective ? prompt : ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (options.allowSchedule && /(每天|每周|定时|周期|持续|daily|weekly|schedule|monitor)/i.test(prompt)) {
|
||||||
|
graph.config.trigger_suggestion = /(每天|daily)/i.test(prompt) ? 'daily' : 'scheduled';
|
||||||
|
assumptions.push('已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
graph,
|
||||||
|
meta: {
|
||||||
|
id: workflowAiSlug(prompt),
|
||||||
|
name: prompt.length > 22 ? prompt.slice(0, 22) + '...' : prompt,
|
||||||
|
description: prompt,
|
||||||
|
enabled: true
|
||||||
|
},
|
||||||
|
generator: 'deterministic-fallback',
|
||||||
|
audit: {
|
||||||
|
risk_warnings: riskWarnings,
|
||||||
|
assumptions: assumptions,
|
||||||
|
high_risk: highRisk,
|
||||||
|
needs_hitl: nodes.some(node => node.type === 'hitl'),
|
||||||
|
savable: validateWorkflowGraph(graph).length === 0
|
||||||
|
},
|
||||||
|
assumptions,
|
||||||
|
riskWarnings,
|
||||||
|
capabilities,
|
||||||
|
stats: { nodes: nodes.length, edges: edges.length }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiAvailableToolsPayload() {
|
||||||
|
return (workflowToolOptions || []).map(function (tool) {
|
||||||
|
return {
|
||||||
|
key: tool.key || tool.name || '',
|
||||||
|
name: tool.name || '',
|
||||||
|
enabled: tool.enabled !== false
|
||||||
|
};
|
||||||
|
}).filter(function (tool) {
|
||||||
|
return tool.key || tool.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workflowAiGenerateDraftOnServer(prompt, options) {
|
||||||
|
const response = await apiFetch('/api/workflows/generate-draft', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: prompt,
|
||||||
|
options: {
|
||||||
|
include_objective: !!options.includeObjective,
|
||||||
|
allow_schedule: !!options.allowSchedule,
|
||||||
|
allow_high_risk: !!options.allowHighRisk
|
||||||
|
},
|
||||||
|
available_tools: workflowAiAvailableToolsPayload()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(function () { return {}; });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || _t('workflows.ai.generateFailed'));
|
||||||
|
}
|
||||||
|
const result = data.result || data;
|
||||||
|
if (!result || !result.graph) {
|
||||||
|
throw new Error(_t('workflows.ai.generateFailed'));
|
||||||
|
}
|
||||||
|
result.generator = result.generator || 'server';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiRenderResult(result) {
|
||||||
|
const target = document.getElementById('workflow-ai-result');
|
||||||
|
const applyBtn = document.getElementById('workflow-ai-apply-btn');
|
||||||
|
if (!target) return;
|
||||||
|
if (!result) {
|
||||||
|
target.innerHTML = '';
|
||||||
|
if (applyBtn) applyBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const graph = result.graph || defaultGraph();
|
||||||
|
const errors = validateWorkflowGraph(graph);
|
||||||
|
const audit = result.audit || {};
|
||||||
|
const risks = audit.risk_warnings || result.riskWarnings || [];
|
||||||
|
const assumptions = audit.assumptions || result.assumptions || [];
|
||||||
|
const missing = audit.missing_fields || [];
|
||||||
|
const stats = result.stats || {};
|
||||||
|
const generator = String(result.generator || '');
|
||||||
|
const generatorKey = generator === 'llm'
|
||||||
|
? 'workflows.ai.generatorLLM'
|
||||||
|
: (generator === 'deterministic'
|
||||||
|
? 'workflows.ai.generatorServer'
|
||||||
|
: (generator.indexOf('fallback') !== -1 ? 'workflows.ai.generatorFallback' : 'workflows.ai.generatorUnknown'));
|
||||||
|
const capabilityText = (result.capabilities || []).map(function (cap) {
|
||||||
|
return cap.label + (cap.tool_name ? ' -> ' + cap.tool_name : (cap.toolName ? ' -> ' + cap.toolName : ''));
|
||||||
|
});
|
||||||
|
target.innerHTML = `
|
||||||
|
${workflowAiPreviewHtml(graph)}
|
||||||
|
<div class="workflow-ai-result-grid">
|
||||||
|
<span>${esc(_t('workflows.ai.resultNodes'))}<strong>${stats.nodes || (graph.nodes || []).length}</strong></span>
|
||||||
|
<span>${esc(_t('workflows.ai.resultEdges'))}<strong>${stats.edges || (graph.edges || []).length}</strong></span>
|
||||||
|
<span>${esc(_t('workflows.ai.resultRisk'))}<strong>${risks.length ? esc(_t('workflows.ai.riskHigh')) : esc(_t('workflows.ai.riskLow'))}</strong></span>
|
||||||
|
<span>${esc(_t('workflows.ai.resultSaveState'))}<strong>${errors.length ? esc(_t('workflows.ai.no')) : esc(_t('workflows.ai.yes'))}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-ai-result-section"><strong>${esc(_t(generatorKey))}</strong><p>${esc(generator === 'llm' ? _t('workflows.ai.llmTitle') : (generator === 'deterministic' ? _t('workflows.ai.serverTitle') : _t('workflows.ai.fallbackTitle')))}</p></div>
|
||||||
|
${capabilityText.length ? '<div class="workflow-ai-result-section"><strong>' + esc(_t('workflows.ai.capabilityTrace')) + '</strong><ul>' + capabilityText.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
${missing.length ? '<div class="workflow-ai-result-section is-attention"><strong>' + esc(_t('workflows.ai.missingFields')) + '</strong><ul>' + missing.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
${assumptions.length ? '<div class="workflow-ai-result-section"><strong>' + esc(_t('workflows.ai.assumptions')) + '</strong><ul>' + assumptions.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
${risks.length ? '<div class="workflow-ai-result-section is-warning"><strong>' + esc(_t('workflows.ai.riskWarnings')) + '</strong><ul>' + risks.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
${errors.length ? '<div class="workflow-ai-result-section is-warning"><strong>' + esc(_t('workflows.audit.validationIssues')) + '</strong><ul>' + errors.map(item => '<li>' + esc(item) + '</li>').join('') + '</ul></div>' : ''}
|
||||||
|
`;
|
||||||
|
if (applyBtn) applyBtn.disabled = !!errors.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiApplyGraph(result) {
|
||||||
|
if (!result || !result.graph) return;
|
||||||
|
fillWorkflowForm({
|
||||||
|
id: '',
|
||||||
|
name: result.meta.name,
|
||||||
|
description: result.meta.description,
|
||||||
|
enabled: true,
|
||||||
|
graph_json: result.graph
|
||||||
|
});
|
||||||
|
syncWorkflowMetaIdField(false, result.meta.id);
|
||||||
|
updateWorkflowCanvasTitle();
|
||||||
|
if (cy && cy.nodes().length) {
|
||||||
|
cy.fit(cy.elements(), 60);
|
||||||
|
const generated = cy.nodes('.ai-generated');
|
||||||
|
if (generated.length) {
|
||||||
|
generated.addClass('just-added');
|
||||||
|
const risky = generated.filter('.high-risk');
|
||||||
|
if (risky.length) risky.select();
|
||||||
|
setTimeout(function () {
|
||||||
|
if (cy) cy.nodes('.ai-generated').removeClass('just-added');
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowAiSortedNodes(graph) {
|
||||||
|
return (graph.nodes || []).slice().sort(function (a, b) {
|
||||||
|
const ax = a.position && typeof a.position.x === 'number' ? a.position.x : 0;
|
||||||
|
const bx = b.position && typeof b.position.x === 'number' ? b.position.x : 0;
|
||||||
|
const ay = a.position && typeof a.position.y === 'number' ? a.position.y : 0;
|
||||||
|
const by = b.position && typeof b.position.y === 'number' ? b.position.y : 0;
|
||||||
|
return ax === bx ? ay - by : ax - bx;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workflowAiAnimateGraph(result) {
|
||||||
|
if (!result || !result.graph || workflowAiState.animating) return;
|
||||||
|
workflowAiState.animating = true;
|
||||||
|
const status = document.getElementById('workflow-ai-canvas-status');
|
||||||
|
if (status) status.hidden = false;
|
||||||
|
try {
|
||||||
|
initCy();
|
||||||
|
if (!cy) {
|
||||||
|
workflowAiApplyGraph(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const graph = parseGraph(result.graph);
|
||||||
|
syncWorkflowMetaForm({
|
||||||
|
id: '',
|
||||||
|
name: result.meta && result.meta.name ? result.meta.name : '',
|
||||||
|
description: result.meta && result.meta.description ? result.meta.description : '',
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
currentWorkflowId = '';
|
||||||
|
syncWorkflowMetaIdField(false, result.meta && result.meta.id ? result.meta.id : '');
|
||||||
|
updateWorkflowCanvasTitle();
|
||||||
|
resetSequences(graph);
|
||||||
|
cy.elements().remove();
|
||||||
|
updateEmptyState();
|
||||||
|
closeWorkflowDryRunPanel();
|
||||||
|
renderWorkflowList();
|
||||||
|
const nodeElements = graphToElements({ nodes: workflowAiSortedNodes(graph), edges: [] });
|
||||||
|
const edgeElements = graphToElements({ nodes: [], edges: graph.edges || [] });
|
||||||
|
for (const ele of nodeElements) {
|
||||||
|
const added = cy.add(ele);
|
||||||
|
selectWorkflowElement(added);
|
||||||
|
added.addClass('just-added');
|
||||||
|
cy.animate({ center: { eles: added }, duration: 180 });
|
||||||
|
await workflowAiSleep(120);
|
||||||
|
added.removeClass('just-added');
|
||||||
|
}
|
||||||
|
for (const edge of edgeElements) {
|
||||||
|
const added = cy.add(edge);
|
||||||
|
added.select();
|
||||||
|
await workflowAiSleep(80);
|
||||||
|
added.unselect();
|
||||||
|
}
|
||||||
|
if (cy.nodes().length) {
|
||||||
|
layoutWorkflowGraph(true);
|
||||||
|
await workflowAiSleep(320);
|
||||||
|
const risky = cy.nodes('.high-risk');
|
||||||
|
if (risky.length) {
|
||||||
|
selectWorkflowElement(risky.first());
|
||||||
|
} else {
|
||||||
|
selectWorkflowElement(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateEmptyState();
|
||||||
|
} finally {
|
||||||
|
workflowAiState.animating = false;
|
||||||
|
if (status) status.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openWorkflowAiModal = function () {
|
||||||
|
if (typeof requirePermission === 'function' && !requirePermission('workflow:write')) return;
|
||||||
|
workflowAiState.draft = null;
|
||||||
|
workflowAiState.result = null;
|
||||||
|
workflowAiSetProgress('', false);
|
||||||
|
workflowAiRenderResult(null);
|
||||||
|
const prompt = document.getElementById('workflow-ai-prompt');
|
||||||
|
const generateBtn = document.getElementById('workflow-ai-generate-btn');
|
||||||
|
if (generateBtn) generateBtn.disabled = false;
|
||||||
|
if (typeof openAppModal === 'function') {
|
||||||
|
openAppModal('workflow-ai-modal', { focusEl: prompt });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeWorkflowAiModal = function () {
|
||||||
|
if (typeof closeAppModal === 'function') closeAppModal('workflow-ai-modal');
|
||||||
|
};
|
||||||
|
|
||||||
|
window.useWorkflowAiExample = function (key) {
|
||||||
|
const prompt = document.getElementById('workflow-ai-prompt');
|
||||||
|
if (!prompt) return;
|
||||||
|
prompt.value = _t('workflows.ai.examples.' + key);
|
||||||
|
prompt.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.generateWorkflowFromNaturalLanguage = async function () {
|
||||||
|
const promptEl = document.getElementById('workflow-ai-prompt');
|
||||||
|
const generateBtn = document.getElementById('workflow-ai-generate-btn');
|
||||||
|
const prompt = promptEl ? promptEl.value.trim() : '';
|
||||||
|
if (!prompt) {
|
||||||
|
if (typeof showNotification === 'function') showNotification(_t('workflows.ai.promptRequired'), 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (generateBtn) {
|
||||||
|
generateBtn.disabled = true;
|
||||||
|
generateBtn.textContent = _t('workflows.ai.generating');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
workflowAiRenderResult(null);
|
||||||
|
workflowAiSetProgress('understand');
|
||||||
|
await workflowAiSleep(140);
|
||||||
|
await loadWorkflowTools();
|
||||||
|
workflowAiSetProgress('match');
|
||||||
|
await workflowAiSleep(140);
|
||||||
|
const options = {
|
||||||
|
includeObjective: workflowAiOption('workflow-ai-include-objective'),
|
||||||
|
allowSchedule: workflowAiOption('workflow-ai-allow-schedule'),
|
||||||
|
allowHighRisk: workflowAiOption('workflow-ai-allow-high-risk')
|
||||||
|
};
|
||||||
|
workflowAiSetProgress('draft');
|
||||||
|
const result = await workflowAiGenerateDraftOnServer(prompt, options);
|
||||||
|
workflowAiSetProgress('audit');
|
||||||
|
await workflowAiSleep(120);
|
||||||
|
workflowAiState.draft = result.graph;
|
||||||
|
workflowAiState.result = result;
|
||||||
|
workflowAiRenderResult(result);
|
||||||
|
workflowAiSetProgress('', true);
|
||||||
|
if (validateWorkflowGraph(result.graph).length && typeof showNotification === 'function') {
|
||||||
|
showNotification(_t('workflows.ai.generatedWithIssues'), 'warning');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
workflowAiState.draft = null;
|
||||||
|
workflowAiState.result = null;
|
||||||
|
workflowAiSetProgress('', false);
|
||||||
|
workflowAiRenderResult(null);
|
||||||
|
if (typeof showNotification === 'function') showNotification(error.message || _t('workflows.ai.generateFailed'), 'error');
|
||||||
|
} finally {
|
||||||
|
if (generateBtn) {
|
||||||
|
generateBtn.disabled = false;
|
||||||
|
generateBtn.textContent = _t('workflows.ai.generate');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.applyWorkflowAiDraft = async function () {
|
||||||
|
if (!workflowAiState.result) return;
|
||||||
|
const applyBtn = document.getElementById('workflow-ai-apply-btn');
|
||||||
|
if (applyBtn) {
|
||||||
|
applyBtn.disabled = true;
|
||||||
|
applyBtn.textContent = _t('workflows.ai.rendering');
|
||||||
|
}
|
||||||
|
closeWorkflowAiModal();
|
||||||
|
try {
|
||||||
|
await workflowAiAnimateGraph(workflowAiState.result);
|
||||||
|
if (typeof showNotification === 'function') showNotification(_t('workflows.ai.applied'), 'success');
|
||||||
|
} finally {
|
||||||
|
if (applyBtn) {
|
||||||
|
applyBtn.disabled = false;
|
||||||
|
applyBtn.textContent = _t('workflows.ai.apply');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.refreshWorkflows = async function () {
|
window.refreshWorkflows = async function () {
|
||||||
initCy();
|
initCy();
|
||||||
const list = document.getElementById('workflow-list');
|
const list = document.getElementById('workflow-list');
|
||||||
@@ -1818,15 +2395,20 @@
|
|||||||
|
|
||||||
window.layoutWorkflowGraph = function (animate) {
|
window.layoutWorkflowGraph = function (animate) {
|
||||||
if (!cy || !cy.nodes().length) return;
|
if (!cy || !cy.nodes().length) return;
|
||||||
cy.layout({
|
const layout = cy.layout({
|
||||||
name: 'breadthfirst',
|
name: 'breadthfirst',
|
||||||
directed: true,
|
directed: true,
|
||||||
padding: 40,
|
padding: 40,
|
||||||
spacingFactor: 1.25,
|
spacingFactor: 1.25,
|
||||||
animate: animate !== false,
|
animate: animate !== false,
|
||||||
animationDuration: 250
|
animationDuration: 250
|
||||||
}).run();
|
});
|
||||||
cy.fit(undefined, 40);
|
layout.one('layoutstop', function () {
|
||||||
|
if (cy && cy.elements().length) cy.fit(cy.elements(), 40);
|
||||||
|
});
|
||||||
|
layout.run();
|
||||||
|
if (animate === false && cy.elements().length) cy.fit(cy.elements(), 40);
|
||||||
|
return layout;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.updateWorkflowSelectedProperty = function () {
|
window.updateWorkflowSelectedProperty = function () {
|
||||||
@@ -2458,7 +3040,7 @@
|
|||||||
if (page && typeof window.applyTranslations === 'function') {
|
if (page && typeof window.applyTranslations === 'function') {
|
||||||
window.applyTranslations(page);
|
window.applyTranslations(page);
|
||||||
}
|
}
|
||||||
['workflow-dry-run-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
|
['workflow-dry-run-modal', 'workflow-ai-modal', 'workflow-package-import-modal', 'workflow-package-overwrite-modal'].forEach(function (id) {
|
||||||
const modal = document.getElementById(id);
|
const modal = document.getElementById(id);
|
||||||
if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal);
|
if (modal && typeof window.applyTranslations === 'function') window.applyTranslations(modal);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3088,6 +3088,7 @@
|
|||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
<button class="btn-secondary" onclick="refreshWorkflows()" data-i18n="common.refresh">刷新</button>
|
<button class="btn-secondary" onclick="refreshWorkflows()" data-i18n="common.refresh">刷新</button>
|
||||||
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowPackageImportModal()" data-i18n="workflows.package.importLocal">导入本地包</button>
|
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowPackageImportModal()" data-i18n="workflows.package.importLocal">导入本地包</button>
|
||||||
|
<button class="btn-secondary" data-require-permission="workflow:write" type="button" onclick="openWorkflowAiModal()" data-i18n="workflows.ai.open">用自然语言创建</button>
|
||||||
<button class="btn-primary" onclick="newWorkflowDraft()" data-i18n="workflows.newGraph">新建工作流</button>
|
<button class="btn-primary" onclick="newWorkflowDraft()" data-i18n="workflows.newGraph">新建工作流</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3139,6 +3140,10 @@
|
|||||||
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.4v13.2a1 1 0 0 0 1.55.84l9.2-6.6a1 1 0 0 0 0-1.68l-9.2-6.6A1 1 0 0 0 8 5.4Z"/></svg>
|
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.4v13.2a1 1 0 0 0 1.55.84l9.2-6.6a1 1 0 0 0 0-1.68l-9.2-6.6A1 1 0 0 0 8 5.4Z"/></svg>
|
||||||
<span class="workflow-toolbar-label" data-i18n="workflows.dryRun">试运行</span>
|
<span class="workflow-toolbar-label" data-i18n="workflows.dryRun">试运行</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-secondary btn-small workflow-toolbar-button workflow-ai-button" data-require-permission="workflow:write" type="button" onclick="openWorkflowAiModal()">
|
||||||
|
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z"/><path d="M19 14l.8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z"/></svg>
|
||||||
|
<span class="workflow-toolbar-label" data-i18n="workflows.ai.generate">生成草稿</span>
|
||||||
|
</button>
|
||||||
<details class="workflow-more-actions" id="workflow-more-actions" data-require-permission-any="workflow:read workflow:delete">
|
<details class="workflow-more-actions" id="workflow-more-actions" data-require-permission-any="workflow:read workflow:delete">
|
||||||
<summary class="btn-secondary btn-small workflow-more-actions-trigger" aria-haspopup="menu">
|
<summary class="btn-secondary btn-small workflow-more-actions-trigger" aria-haspopup="menu">
|
||||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>
|
||||||
@@ -3164,7 +3169,8 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="workflow-canvas-wrap" ondragover="workflowCanvasDragOver(event)" ondrop="workflowCanvasDrop(event)">
|
<section class="workflow-canvas-wrap" ondragover="workflowCanvasDragOver(event)" ondrop="workflowCanvasDrop(event)">
|
||||||
<div id="workflow-canvas"></div>
|
<div id="workflow-canvas"></div>
|
||||||
<div id="workflow-canvas-empty" class="workflow-canvas-empty" data-i18n="workflows.canvasEmpty">从左侧拖拽节点到画布,或点击节点按钮快速添加</div>
|
<div id="workflow-canvas-empty" class="workflow-canvas-empty" data-i18n="workflows.canvasEmpty">从左侧拖拽节点到画布,或用自然语言生成工作流</div>
|
||||||
|
<div id="workflow-ai-canvas-status" class="workflow-ai-canvas-status" hidden data-i18n="workflows.ai.canvasRendering">AI 正在编排画布…</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<aside class="workflow-properties">
|
<aside class="workflow-properties">
|
||||||
@@ -5568,6 +5574,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="workflow-ai-modal" class="modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-ai-modal-title" onclick="if(event.target===this)closeWorkflowAiModal()" onkeydown="if(event.key==='Escape')closeWorkflowAiModal()">
|
||||||
|
<form class="modal-content workflow-ai-modal-content" onsubmit="event.preventDefault(); generateWorkflowFromNaturalLanguage()" onclick="event.stopPropagation()">
|
||||||
|
<div class="modal-header workflow-ai-modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="workflow-ai-modal-title" data-i18n="workflows.ai.title">用自然语言创建工作流</h2>
|
||||||
|
<p class="workflow-ai-subtitle" data-i18n="workflows.ai.subtitle">AI 会生成可编辑草稿,不会自动保存或运行。</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="modal-close" onclick="closeWorkflowAiModal()" data-i18n="common.close" data-i18n-attr="aria-label" data-i18n-skip-text="true" aria-label="关闭"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body workflow-ai-modal-body">
|
||||||
|
<div class="form-group workflow-ai-prompt-field">
|
||||||
|
<label for="workflow-ai-prompt" data-i18n="workflows.ai.promptLabel">工作流需求</label>
|
||||||
|
<textarea id="workflow-ai-prompt" class="form-input" rows="5" data-i18n="workflows.ai.promptPlaceholder" data-i18n-attr="placeholder" placeholder="例如:持续监控一个域名的子域名、证书和暴露页面,发现新增资产后生成报告"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-ai-examples" aria-label="示例需求" data-i18n="workflows.ai.examplesLabel" data-i18n-attr="aria-label">
|
||||||
|
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('domainMonitor')" data-i18n="workflows.ai.exampleDomain">域名监控</button>
|
||||||
|
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('reportReview')" data-i18n="workflows.ai.exampleReport">报告审批</button>
|
||||||
|
<button type="button" class="btn-secondary btn-small" onclick="useWorkflowAiExample('assetTriage')" data-i18n="workflows.ai.exampleTriage">资产研判</button>
|
||||||
|
</div>
|
||||||
|
<div class="workflow-ai-options">
|
||||||
|
<label><input type="checkbox" id="workflow-ai-include-objective" checked> <span data-i18n="workflows.ai.includeObjective">同时生成 objective 配置</span></label>
|
||||||
|
<label><input type="checkbox" id="workflow-ai-allow-schedule"> <span data-i18n="workflows.ai.allowSchedule">允许生成定时触发建议</span></label>
|
||||||
|
<label><input type="checkbox" id="workflow-ai-allow-high-risk"> <span data-i18n="workflows.ai.allowHighRisk">允许包含高风险节点草稿</span></label>
|
||||||
|
</div>
|
||||||
|
<div id="workflow-ai-progress" class="workflow-ai-progress" hidden aria-live="polite"></div>
|
||||||
|
<div id="workflow-ai-result" class="workflow-ai-result" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer workflow-ai-modal-footer">
|
||||||
|
<button type="button" class="btn-secondary btn-small" onclick="closeWorkflowAiModal()" data-i18n="common.cancel">取消</button>
|
||||||
|
<button id="workflow-ai-apply-btn" type="button" class="btn-secondary btn-small" onclick="applyWorkflowAiDraft()" disabled data-i18n="workflows.ai.apply">渲染到画布</button>
|
||||||
|
<button id="workflow-ai-generate-btn" type="submit" class="btn-primary btn-small" data-i18n="workflows.ai.generate">生成草稿</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<div id="workflow-package-import-modal" class="modal workflow-package-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-package-import-title">
|
<div id="workflow-package-import-modal" class="modal workflow-package-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="workflow-package-import-title">
|
||||||
<div class="modal-content workflow-package-modal-content">
|
<div class="modal-content workflow-package-modal-content">
|
||||||
<div class="modal-header workflow-package-modal-header">
|
<div class="modal-header workflow-package-modal-header">
|
||||||
|
|||||||
Reference in New Issue
Block a user