mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-01 00:27:35 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412daf8598 | ||
|
|
c40a1f82a8 | ||
|
|
404feb372e | ||
|
|
00a936e5dc | ||
|
|
c107c77619 | ||
|
|
4d81d2be97 | ||
|
|
1dc43083b0 | ||
|
|
6021d1a097 | ||
|
|
7a06db3f44 | ||
|
|
33d6f8bb0c | ||
|
|
cf2e65ca1b | ||
|
|
49a90d03fa | ||
|
|
754b1ad88b | ||
|
|
b41d334d2b | ||
|
|
38ebdebd14 | ||
|
|
a63b535a68 | ||
|
|
ecc75ee326 | ||
|
|
a425df2607 | ||
|
|
0aa402f89c | ||
|
|
c32cb915bc | ||
|
|
4fd3fe3cc8 | ||
|
|
0643aaadc6 | ||
|
|
b5baf7f9b3 | ||
|
|
8b1efd5dd7 | ||
|
|
f6412150cf | ||
|
|
2764732cdf | ||
|
|
7b8b57907b | ||
|
|
be2800c248 | ||
|
|
0ad66d8b7e | ||
|
|
b9e5527131 | ||
|
|
3d5e2bc4c7 | ||
|
|
d58c4642f7 | ||
|
|
9df6de088b | ||
|
|
aae71a0c3e | ||
|
|
059a33029e | ||
|
|
15daad97d4 | ||
|
|
f02c0d175b | ||
|
|
a8da115d28 | ||
|
|
e4a01089e7 | ||
|
|
bbf8c416fc | ||
|
|
d41decd707 | ||
|
|
93a600d60e | ||
|
|
c86825d365 | ||
|
|
4af5e2691e | ||
|
|
85400cd3f8 | ||
|
|
a66b8fc821 | ||
|
|
58be62fa24 | ||
|
|
a3739210e4 | ||
|
|
e936c63754 | ||
|
|
1f46d4a930 | ||
|
|
3a995183a6 | ||
|
|
3ed7499a0b | ||
|
|
f26354d483 | ||
|
|
ebd872b373 | ||
|
|
07439bce6e | ||
|
|
625ac4358f | ||
|
|
eb6b9d6f45 | ||
|
|
ad97544bbe | ||
|
|
12a1ebe9cd | ||
|
|
b97e726237 | ||
|
|
2eb923e5fa | ||
|
|
745a69f93b | ||
|
|
011a242acc | ||
|
|
6a52ef96f4 | ||
|
|
52f8c377b6 | ||
|
|
8d04b0c266 | ||
|
|
bcdff06702 | ||
|
|
3210bc727f | ||
|
|
5254ca52fb | ||
|
|
1ff2df68ac | ||
|
|
fe60497863 | ||
|
|
7acd21bc98 | ||
|
|
dbcf9b8418 | ||
|
|
b3767b2deb | ||
|
|
7e764df0e8 | ||
|
|
a1ffb20d6e | ||
|
|
125685f08f | ||
|
|
b804635fa8 | ||
|
|
c9fb5d11d3 | ||
|
|
926491b746 | ||
|
|
4e17691717 | ||
|
|
2e2a6dedd4 | ||
|
|
b1323896c8 | ||
|
|
595074b7b0 | ||
|
|
2e063dd857 | ||
|
|
a110d233e1 | ||
|
|
2f58d0a457 | ||
|
|
5b7f157802 | ||
|
|
09890db635 | ||
|
|
c0171ef60a | ||
|
|
4eb73fb638 | ||
|
|
d1b49cb20d | ||
|
|
930eb47013 | ||
|
|
9964e13197 | ||
|
|
4f7b21cb7e | ||
|
|
9fae9db906 | ||
|
|
7ecd8c61e8 | ||
|
|
bdb0326e47 | ||
|
|
8dccc6aa06 |
@@ -35,7 +35,18 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
|
||||
### System Dashboard Overview
|
||||
|
||||
<img src="./images/dashboard.png" alt="System Dashboard" width="100%">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<strong>Light Mode</strong><br/>
|
||||
<img src="./images/dashboard.png" alt="System Dashboard (Light)" width="100%">
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<strong>Dark Mode</strong><br/>
|
||||
<img src="./images/dark.png" alt="System Dashboard (Dark)" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
*The dashboard provides a comprehensive overview of system runtime status, security vulnerabilities, tool usage, and knowledge base, helping users quickly understand the platform's core features and current state.*
|
||||
|
||||
@@ -110,16 +121,17 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
|
||||
- 📄 Large-result pagination, compression, and searchable archives
|
||||
- 🔗 Attack-chain graph, risk scoring, and step-by-step replay
|
||||
- 🔒 Password-protected web UI, audit logs, and SQLite persistence
|
||||
- 📚 Knowledge base (RAG) with embedding-based vector retrieval (cosine similarity), optional **Eino Compose** indexing pipeline, and configurable post-retrieval budgets / reranking hooks
|
||||
- 📚 Knowledge base (RAG): **Eino MultiQuery** query rewrite + multi-path vector retrieval + **HTTP rerank** (DashScope `gte-rerank` / Cohere-compatible) + post-processing (dedupe, budget); **Eino Compose** indexing pipeline
|
||||
- 📁 Conversation grouping with pinning, rename, and batch management
|
||||
- 📂 **Project management**: shared facts (blackboard) across sessions, `upsert_project_fact` + `links` to chain paths; attack-chain and project fact graph views
|
||||
- 🛡️ Vulnerability management with CRUD operations, severity tracking, status workflow, and statistics
|
||||
- 📋 Batch task management: create task queues, add multiple tasks, and execute them sequentially
|
||||
- 🎭 Role-based testing: predefined security testing roles (Penetration Testing, CTF, Web App Scanning, etc.) with custom prompts and tool restrictions
|
||||
- 🔀 **Graph orchestration**: visual workflow editor (Start / Agent / Tool / Condition / HITL / Output) with `{{previous.output}}` and `{{outputs.variable_name}}` for inter-node data passing; bind a graph to a role for automatic execution on chat. See [Graph orchestration guide](docs/workflow-graph_en.md)
|
||||
- 🧩 **Agent orchestration (CloudWeGo Eino)**: **single-agent** via **`/api/eino-agent/stream`** (Eino ADK `ChatModelAgent`); **multi-agent** via **`/api/multi-agent/stream`** with **`deep`** (coordinator + `task` sub-agents), **`plan_execute`**, or **`supervisor`** (`orchestration` in the request body). ADK **summarization** compresses long contexts; pre-compaction **transcripts** land at `data/conversation_artifacts/<conversation-id>/summarization/transcript.txt` (full user/assistant/tool turns; static system omitted). Markdown under `agents/`: `orchestrator.md`, `orchestrator-plan-execute.md`, `orchestrator-supervisor.md`, plus sub-agent `*.md` (see [Multi-agent doc](docs/MULTI_AGENT_EINO.md))
|
||||
- 🖼️ **Vision analysis (`analyze_image`)**: separate VL model (e.g. `qwen-vl-max`) via MCP for local screenshots, captchas, and UI; image bytes stay out of agent history (text summaries only). Configure `vision` in `config.yaml`; see [docs/VISION.md](docs/VISION.md)
|
||||
- 🎯 **Skills (refactored for Eino)**: packs under `skills_dir` follow **Agent Skills** layout (`SKILL.md` + optional files); **multi-agent** sessions use the official Eino ADK **`skill`** tool for **progressive disclosure** (load by name), with optional **host filesystem / shell** via `multi_agent.eino_skills`; optional **`eino_middleware`** adds patchtoolcalls, tool_search, **plantask** (`TaskCreate` / `TaskList` boards under `skills_dir/.eino/plantask/`), reduction, file **checkpoints** (`checkpoint_dir`), ChatModel **retries**, session **output key**, and Deep tuning—20+ sample domains (SQLi, XSS, API security, …) ship under `skills/`
|
||||
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
|
||||
- 📱 **Chatbot**: Personal WeChat, WeCom, DingTalk, Lark, Telegram, Slack, Discord, and QQ Bot—chat from mobile or IM apps (see [Robot / Chatbot guide](docs/robot_en.md))
|
||||
- 🧑⚖️ **Human-in-the-loop (HITL)**: Chat sidebar to set approval mode and tool allowlists (listed tools skip approval); global list in `config.yaml` under `hitl.tool_whitelist`; **Apply** can merge new tools into the file and update the running server without restart; dedicated **HITL** page for pending approvals
|
||||
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
|
||||
- 📡 **Built-in C2**: AI-oriented lightweight command-and-control—**listeners** (TCP reverse, HTTP/HTTPS beacon, WebSocket), **encrypted** beacon channel, **session** and **task** queues with persistence, **payload** helpers (one-liner / build / download), **SSE** live events, REST under `/api/c2/*`, plus unified MCP tools (`c2_listener`, `c2_session`, **`c2_task`**, `c2_task_manage`, `c2_payload`, `c2_event`, `c2_profile`, `c2_file`); optional **HITL** approval for sensitive operations and OPSEC-style controls (e.g. command deny rules). **Authorized testing only.**
|
||||
@@ -244,6 +256,7 @@ Requirements / tips:
|
||||
- **Conversation testing** – Natural-language prompts trigger toolchains with streaming SSE output.
|
||||
- **Single vs multi-agent** – Chat UI switches between **Eino single-agent** (`/api/eino-agent/stream`) and **multi-agent** (`/api/multi-agent/stream` with `orchestration`: `deep` | `plan_execute` | `supervisor`). Multi mode requires `multi_agent.enabled: true`. MCP tools are bridged the same way for both paths.
|
||||
- **Role-based testing** – Select from predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, etc.) to customize AI behavior and tool availability. Each role applies custom system prompts and can restrict available tools for focused testing scenarios.
|
||||
- **Graph orchestration** – Design flows on the **Graph Orchestration** page (drag nodes, connect edges, save); bind `workflow_id` on a role to run the graph on chat (Agent, MCP tools, condition branches). Use `{{outputs.variable_name}}` to pass data across non-adjacent nodes. See [Graph orchestration guide](docs/workflow-graph_en.md).
|
||||
- **Tool monitor** – Inspect running jobs, execution logs, and large-result attachments.
|
||||
- **History & audit** – Every conversation and tool invocation is stored in SQLite with replay.
|
||||
- **Conversation groups** – Organize conversations into groups, pin important groups, rename or delete groups via context menu.
|
||||
@@ -286,11 +299,11 @@ Requirements / tips:
|
||||
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
|
||||
|
||||
### Multi-Agent Mode (Eino: Deep, Plan-Execute, Supervisor)
|
||||
- **What it is** – Multi-agent orchestration on CloudWeGo **Eino** `adk/prebuilt` (alongside **Eino single-agent** on `/api/eino-agent*`): **`deep`** — coordinator + **`task`** sub-agents; **`plan_execute`** — planner / executor / replanner; **`supervisor`** — orchestrator with **`transfer`** / **`exit`**. Client sends **`orchestration`**: `deep` | `plan_execute` | `supervisor` (default `deep`).
|
||||
- **What it is** – Multi-agent orchestration on CloudWeGo **Eino** `adk/prebuilt` (alongside **Eino single-agent** on `/api/eino-agent*`): **`deep`** — coordinator + **`task`** sub-agents for complex security testing and delegated synthesis; **`plan_execute`** — planner / executor / replanner for structured loops; **`supervisor`** — expert-routing mode with **`transfer`** / **`exit`** for multiple specialist sub-agents. Client sends **`orchestration`**: `deep` | `plan_execute` | `supervisor` (default `deep`).
|
||||
- **Markdown agents** – Under `agents_dir` (default `agents/`):
|
||||
- **Deep orchestrator**: `orchestrator.md` *or* one `.md` with `kind: orchestrator`. Body or `multi_agent.orchestrator_instruction`, then Eino defaults.
|
||||
- **Plan-Execute orchestrator**: fixed name **`orchestrator-plan-execute.md`** (plus optional `orchestrator_instruction_plan_execute` in YAML).
|
||||
- **Supervisor orchestrator**: fixed name **`orchestrator-supervisor.md`** (plus optional `orchestrator_instruction_supervisor`); requires at least one sub-agent.
|
||||
- **Supervisor orchestrator**: fixed name **`orchestrator-supervisor.md`** (plus optional `orchestrator_instruction_supervisor`); requires at least one sub-agent, and one-sub-agent runs emit a hint that expert routing has limited value.
|
||||
- **Sub-agents** (for **deep** / **supervisor**): other `*.md` files (YAML front matter + body). Not used as **`task`** targets if marked orchestrator-only.
|
||||
- **Management** – Web UI: **Agents → Agent management**; API `/api/multi-agent/markdown-agents`.
|
||||
- **Config** – `multi_agent` in `config.yaml`: `enabled`, `robot_default_agent_mode`, `batch_use_multi_agent`, `max_iteration`, `plan_execute_loop_max_iterations`, per-mode orchestrator instruction fields, optional YAML `sub_agents` merged with disk (`id` clash → Markdown wins), **`eino_skills`**, **`eino_middleware`** (optional ADK middleware and Deep/Supervisor tuning).
|
||||
@@ -455,16 +468,12 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
|
||||
|
||||
### Knowledge Base
|
||||
- **Vector search** – AI agent can automatically search the knowledge base for relevant security knowledge during conversations using the `search_knowledge_base` tool.
|
||||
- **Vector retrieval** – cosine similarity over stored embeddings, aligned with Eino `retriever.Retriever` usage.
|
||||
- **Auto-indexing** – scans the `knowledge_base/` directory for Markdown files and automatically indexes them with embeddings.
|
||||
- **Web management** – create, update, delete knowledge items through the web UI, with category-based organization.
|
||||
- **RAG pipeline (always on)** – **MultiQuery** (LLM query rewrite) → vector prefetch & fusion → **HTTP rerank** (DashScope `gte-rerank` or Cohere-compatible `/v1/rerank`) → post-processing (normalized dedupe, char/token budget, final top_k). Rerank failures degrade to fusion order without breaking search.
|
||||
- **Vector retrieval** – cosine similarity over stored embeddings with configurable threshold, aligned with Eino `retriever.Retriever` usage.
|
||||
- **Auto-indexing** – scans the `knowledge_base/` directory for Markdown files and automatically indexes them with embeddings (Markdown header split + recursive chunking via Eino).
|
||||
- **Web management** – create, update, delete knowledge items through the web UI, with category-based organization; settings page exposes MultiQuery / rerank / prefetch options.
|
||||
- **Retrieval logs** – tracks all knowledge retrieval operations for audit and debugging.
|
||||
|
||||
**Quick Start (Using Pre-built Knowledge Base):**
|
||||
1. **Download the knowledge database** – Download the pre-built knowledge database file from [GitHub Releases](https://github.com/Ed1s0nZ/CyberStrikeAI/releases).
|
||||
2. **Extract and place** – Extract the downloaded knowledge database file (`knowledge.db`) and place it in the project's `data/` directory.
|
||||
3. **Restart the service** – Restart the CyberStrikeAI service, and the knowledge base will be ready to use immediately without rebuilding the index.
|
||||
|
||||
**Setting up the knowledge base:**
|
||||
1. **Enable in config** – set `knowledge.enabled: true` in `config.yaml`:
|
||||
```yaml
|
||||
@@ -479,6 +488,17 @@ A test SSE MCP server is available at `cmd/test-sse-mcp-server/` for validation
|
||||
retrieval:
|
||||
top_k: 5
|
||||
similarity_threshold: 0.7
|
||||
multi_query:
|
||||
max_queries: 4 # LLM rewrite variants (always on)
|
||||
rerank: # always on; empty fields inherit openai/embedding credentials
|
||||
provider: "" # auto: dashscope | cohere from base_url
|
||||
model: "" # empty: gte-rerank (DashScope) or rerank-multilingual-v3.0 (Cohere)
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
post_retrieve:
|
||||
prefetch_top_k: 20 # vector candidates per MultiQuery variant; 0 = max(top_k×4, 20)
|
||||
max_context_chars: 0
|
||||
max_context_tokens: 0
|
||||
```
|
||||
2. **Add knowledge files** – place Markdown files in `knowledge_base/` directory, organized by category (e.g., `knowledge_base/SQL Injection/README.md`).
|
||||
3. **Scan and index** – use the web UI to scan the knowledge base directory, which will automatically import files and build vector embeddings.
|
||||
@@ -539,6 +559,17 @@ knowledge:
|
||||
retrieval:
|
||||
top_k: 5 # Number of top results to return
|
||||
similarity_threshold: 0.7 # Minimum cosine similarity (0-1)
|
||||
multi_query:
|
||||
max_queries: 4 # MultiQuery rewrite variants (always on)
|
||||
rerank: # HTTP rerank (always on); empty fields inherit openai/embedding credentials
|
||||
provider: ""
|
||||
model: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
post_retrieve:
|
||||
prefetch_top_k: 20 # per MultiQuery variant; 0 = max(top_k×4, 20)
|
||||
max_context_chars: 0
|
||||
max_context_tokens: 0
|
||||
roles_dir: "roles" # Role configuration directory (relative to config file)
|
||||
skills_dir: "skills" # Skills directory (relative to config file)
|
||||
agents_dir: "agents" # Multi-agent Markdown definitions (orchestrator + sub-agents)
|
||||
@@ -601,7 +632,8 @@ enabled: true
|
||||
## Related documentation
|
||||
|
||||
- [Multi-agent mode (Eino)](docs/MULTI_AGENT_EINO.md): **Deep**, **Plan-Execute**, **Supervisor**, `agents/*.md`, `eino_skills` / `eino_middleware`, APIs, and chat/stream behavior.
|
||||
- [Robot / Chatbot guide (DingTalk & Lark)](docs/robot_en.md): Full setup, commands, and troubleshooting for using CyberStrikeAI from DingTalk or Lark on your phone. **Follow this doc to avoid common pitfalls.**
|
||||
- [Graph orchestration guide](docs/workflow-graph_en.md): visual workflow design, node configuration, `previous` / `outputs` variable passing, and role binding.
|
||||
- [Robot / Chatbot guide](docs/robot_en.md): Setup, commands, and troubleshooting for WeChat, WeCom, DingTalk, Lark, Telegram, Slack, Discord, and QQ Bot.
|
||||
|
||||
## Project Layout
|
||||
|
||||
@@ -653,8 +685,6 @@ CyberStrikeAI has joined [404Starlink](https://github.com/knownsec/404StarLink)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Stargazers over time
|
||||

|
||||
|
||||
|
||||
---
|
||||
@@ -683,5 +713,3 @@ CyberStrikeAI is a professional security testing platform designed to assist sec
|
||||
---
|
||||
|
||||
Need help or want to contribute? Open an issue or PR—community tooling additions are welcome!
|
||||
|
||||
|
||||
|
||||
+46
-16
@@ -34,7 +34,18 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
||||
|
||||
### 系统仪表盘概览
|
||||
|
||||
<img src="./images/dashboard.png" alt="系统仪表盘" width="100%">
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<strong>浅色模式</strong><br/>
|
||||
<img src="./images/dashboard.png" alt="系统仪表盘(浅色)" width="100%">
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<strong>深色模式</strong><br/>
|
||||
<img src="./images/dark.png" alt="系统仪表盘(深色)" width="100%">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
*仪表盘提供系统运行状态、安全漏洞、工具使用情况和知识库的全面概览,帮助用户快速了解平台核心功能和当前状态。*
|
||||
|
||||
@@ -109,16 +120,17 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
|
||||
- 📄 大结果分页、压缩与全文检索
|
||||
- 🔗 攻击链可视化、风险打分与步骤回放
|
||||
- 🔒 Web 登录保护、审计日志、SQLite 持久化
|
||||
- 📚 知识库(RAG):向量嵌入与余弦相似度检索(与 Eino `retriever.Retriever` 语义一致),可选 **Eino Compose** 索引流水线及检索后处理(预算、重排等配置项)
|
||||
- 📚 知识库(RAG):**Eino MultiQuery** 查询改写 + 多路向量检索 + **HTTP 精排**(DashScope `gte-rerank` / Cohere 兼容)+ 后处理(去重、预算);索引侧为 **Eino Compose** 流水线
|
||||
- 📁 对话分组管理:支持分组创建、置顶、重命名、删除等操作
|
||||
- 📂 **项目管理**:共享事实(黑板)跨会话沉淀认知,`upsert_project_fact` + `links` 串联攻击路径;聊天攻击链与项目事实图可视化
|
||||
- 🛡️ 漏洞管理功能:完整的漏洞 CRUD 操作,支持严重程度分级、状态流转、按对话/严重程度/状态过滤,以及统计看板
|
||||
- 📋 批量任务管理:创建任务队列,批量添加任务,依次顺序执行,支持任务编辑与状态跟踪
|
||||
- 🎭 角色化测试:预设安全测试角色(渗透测试、CTF、Web 应用扫描等),支持自定义提示词和工具限制
|
||||
- 🔀 **图编排**:可视化流程编排(开始 / Agent / 工具 / 条件 / 审批 / 输出),节点间用 `{{previous.output}}` 或 `{{outputs.变量名}}` 传参;绑定角色后对话自动按图执行。详见 [图编排使用说明](docs/workflow-graph.md)
|
||||
- 🧩 **Agent 编排(CloudWeGo Eino)**:**单代理** `POST /api/eino-agent/stream`(Eino ADK);**多代理** `POST /api/multi-agent/stream`,`orchestration` 选 **`deep`** / **`plan_execute`** / **`supervisor`**。ADK **Summarization** 在上下文过长时压缩历史;压缩前将可恢复 **转录** 写入 `data/conversation_artifacts/<会话ID>/summarization/transcript.txt`(保留完整 user/assistant/tool 轮次,省略静态 system)。`agents/` 下主代理与子代理 Markdown 见 [多代理说明](docs/MULTI_AGENT_EINO.md)
|
||||
- 🖼️ **视觉分析(`analyze_image`)**:独立 Vision 模型(如 `qwen-vl-max`),MCP 工具分析本地截图/验证码/UI;图片仅在单次 VL 调用中出现,对话上下文只保留文字摘要。配置见 `config.yaml` → `vision` 与 [视觉分析说明](docs/VISION.md)
|
||||
- 🎯 **Skills(面向 Eino 重构)**:技能包放在 **`skills_dir`**,遵循 **Agent Skills** 目录规范(`SKILL.md` + 可选文件);**多代理** 下通过 Eino 官方 **`skill`** 工具 **渐进式披露**(按 name 加载)。**`multi_agent.eino_skills`** 控制是否启用、本机文件/Shell 工具、工具名覆盖;**`eino_middleware`** 可选 patch、tool_search、**plantask**(`TaskCreate` / `TaskList` 任务板,落在 `skills_dir/.eino/plantask/`)、reduction、文件型 **checkpoint**(`checkpoint_dir`)、ChatModel **重试**、会话 **输出键** 及 Deep 调参。20+ 领域示例仍可绑定角色
|
||||
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
||||
- 📱 **机器人**:个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord、QQ 机器人,在手机或 IM 中与 CyberStrikeAI 对话(详见 [机器人使用说明](docs/robot.md))
|
||||
- 🧑⚖️ **人机协同(HITL)**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml` 的 `hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
|
||||
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
||||
- 📡 **内置 C2**:面向 AI 协同的轻量 **C2**——**多种监听器**(TCP 反向、HTTP/HTTPS Beacon、WebSocket)、**加密** Beacon 信道、**会话与任务**队列及持久化、**Payload** 辅助(一键命令 / 构建 / 下载)、**SSE** 实时事件、REST(`/api/c2/*`)及智能体侧 **一组 C2 MCP 工具**(如 `c2_listener`、`c2_session`、**`c2_task`**、`c2_task_manage`、`c2_payload`、`c2_event`、`c2_profile`、`c2_file`);敏感操作可对接 **人机协同(HITL)**,并支持 OPSEC 类规则(如命令拒绝正则)。**仅限授权测试。**
|
||||
@@ -242,6 +254,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- **对话测试**:自然语言触发多步工具编排,SSE 实时输出。
|
||||
- **单代理 / 多代理**:聊天可选 **Eino 单代理**(`/api/eino-agent/stream`)与 **多代理**(`/api/multi-agent/stream` + `orchestration`)。多代理需 `multi_agent.enabled: true`。MCP 工具桥接一致。
|
||||
- **角色化测试**:从预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试等)中选择,自定义 AI 行为和可用工具。每个角色可应用自定义系统提示词,并可限制可用工具列表,实现聚焦的测试场景。
|
||||
- **图编排**:在 **图编排** 页拖拽节点、连线并保存流程;在角色中绑定 `workflow_id` 后,该角色对话将按图执行(Agent、MCP 工具、条件分支等)。跨节点传参优先用 `{{outputs.变量名}}`。详见 [图编排使用说明](docs/workflow-graph.md)。
|
||||
- **工具监控**:查看任务队列、执行日志、大文件附件。
|
||||
- **会话历史**:所有对话与工具调用保存在 SQLite,可随时重放。
|
||||
- **对话分组**:将对话按项目或主题组织到不同分组,支持置顶、重命名、删除等操作,所有数据持久化存储。
|
||||
@@ -284,11 +297,11 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
|
||||
|
||||
### 多代理模式(Eino:Deep / Plan-Execute / Supervisor)
|
||||
- **能力说明**:在 **Eino 单代理**(`/api/eino-agent*`)之外,多代理基于 CloudWeGo **Eino** `adk/prebuilt`:**`deep`**、**`plan_execute`**、**`supervisor`**;客户端 **`orchestration`** 选择(缺省 `deep`)。
|
||||
- **能力说明**:在 **Eino 单代理**(`/api/eino-agent*`)之外,多代理基于 CloudWeGo **Eino** `adk/prebuilt`:**`deep`**、**`plan_execute`**、**`supervisor`**;客户端 **`orchestration`** 选择(缺省 `deep`)。模式定位按 Eino ADK 最佳实践区分:**Deep** 适合复杂安全测试与 task 子代理协作;**Plan-Execute** 适合目标明确的规划 → 执行 → 重规划闭环;**Supervisor** 适合多个专业子代理动态分派的专家路由场景。
|
||||
- **Markdown 定义**(`agents_dir`,默认 `agents/`):
|
||||
- **Deep 主代理**:`orchestrator.md` 或唯一 `kind: orchestrator` 的 `.md`;正文或 `multi_agent.orchestrator_instruction`,再回退 Eino 默认。
|
||||
- **Plan-Execute 主代理**:固定 **`orchestrator-plan-execute.md`**(另可配 `orchestrator_instruction_plan_execute`)。
|
||||
- **Supervisor 主代理**:固定 **`orchestrator-supervisor.md`**(另可配 `orchestrator_instruction_supervisor`);至少需一名子代理。
|
||||
- **Supervisor 主代理**:固定 **`orchestrator-supervisor.md`**(另可配 `orchestrator_instruction_supervisor`);至少需一名子代理,只有一名子代理时会提示专家路由价值有限。
|
||||
- **子代理**(**deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
|
||||
- **界面管理**:**Agents → Agent 管理**;API `/api/multi-agent/markdown-agents`。
|
||||
- **配置项**:`multi_agent`:`enabled`、`robot_default_agent_mode`、`batch_use_multi_agent`、`max_iteration`、`plan_execute_loop_max_iterations`、各模式 orchestrator 指令字段、可选 YAML `sub_agents` 与目录合并(同 `id` → Markdown 优先)、**`eino_skills`**、**`eino_middleware`**。
|
||||
@@ -453,16 +466,12 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
||||
|
||||
### 知识库功能
|
||||
- **向量检索**:AI 智能体在对话过程中可自动调用 `search_knowledge_base` 工具搜索知识库中的安全知识。
|
||||
- **向量检索**:基于嵌入余弦相似度与相似度阈值过滤(与 Eino `retriever.Retriever` 语义一致)。
|
||||
- **自动索引**:扫描 `knowledge_base/` 目录下的 Markdown 文件,自动构建向量嵌入索引。
|
||||
- **Web 管理**:通过 Web 界面创建、更新、删除知识项,支持分类管理。
|
||||
- **RAG 管线(始终启用)**:**MultiQuery**(LLM 查询改写)→ 向量预取与融合 → **HTTP 精排**(DashScope `gte-rerank` 或 Cohere 兼容 `/v1/rerank`)→ 后处理(规范化去重、字符/token 预算、最终 top_k)。精排失败时自动降级为融合排序,检索仍可用。
|
||||
- **向量相似度**:基于嵌入余弦相似度与相似度阈值过滤(与 Eino `retriever.Retriever` 语义一致)。
|
||||
- **自动索引**:扫描 `knowledge_base/` 目录下的 Markdown 文件,自动构建向量嵌入索引(Eino Markdown 标题切分 + 递归分块)。
|
||||
- **Web 管理**:通过 Web 界面创建、更新、删除知识项,支持分类管理;设置页可配置 MultiQuery / 精排 / 预取候选数。
|
||||
- **检索日志**:记录所有知识检索操作,便于审计与调试。
|
||||
|
||||
**快速开始(使用预构建知识库):**
|
||||
1. **下载知识数据库**:从 [GitHub Releases](https://github.com/Ed1s0nZ/CyberStrikeAI/releases) 下载预构建的知识数据库文件。
|
||||
2. **解压并放置**:将下载的知识数据库文件(`knowledge.db`)解压后放到项目的 `data/` 目录下。
|
||||
3. **重启服务**:重启 CyberStrikeAI 服务,知识库即可直接使用,无需重新构建索引。
|
||||
|
||||
**知识库配置步骤:**
|
||||
1. **启用功能**:在 `config.yaml` 中设置 `knowledge.enabled: true`:
|
||||
```yaml
|
||||
@@ -477,6 +486,17 @@ CyberStrikeAI 支持通过三种传输模式连接外部 MCP 服务器:
|
||||
retrieval:
|
||||
top_k: 5
|
||||
similarity_threshold: 0.7
|
||||
multi_query:
|
||||
max_queries: 4 # LLM 改写变体上限(始终启用)
|
||||
rerank: # 精排始终启用;留空则继承 openai/embedding 凭据
|
||||
provider: "" # 空=按 base_url 推断 dashscope | cohere
|
||||
model: "" # 空=DashScope→gte-rerank,Cohere→rerank-multilingual-v3.0
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
post_retrieve:
|
||||
prefetch_top_k: 20 # 每条 MultiQuery 变体的向量候选数;0=max(top_k×4, 20)
|
||||
max_context_chars: 0
|
||||
max_context_tokens: 0
|
||||
```
|
||||
2. **添加知识文件**:将 Markdown 文件放入 `knowledge_base/` 目录,按分类组织(如 `knowledge_base/SQL注入/README.md`)。
|
||||
3. **扫描索引**:在 Web 界面中点击"扫描知识库",系统会自动导入文件并构建向量索引。
|
||||
@@ -537,6 +557,17 @@ knowledge:
|
||||
retrieval:
|
||||
top_k: 5 # 检索返回的 Top-K 结果数量
|
||||
similarity_threshold: 0.7 # 余弦相似度阈值(0-1),低于此值的结果将被过滤
|
||||
multi_query:
|
||||
max_queries: 4 # MultiQuery 改写变体上限(始终启用)
|
||||
rerank: # HTTP 精排(始终启用);留空则继承 openai/embedding 凭据
|
||||
provider: ""
|
||||
model: ""
|
||||
base_url: ""
|
||||
api_key: ""
|
||||
post_retrieve:
|
||||
prefetch_top_k: 20 # 每条 MultiQuery 变体;0=max(top_k×4, 20)
|
||||
max_context_chars: 0
|
||||
max_context_tokens: 0
|
||||
roles_dir: "roles" # 角色配置文件目录(相对于配置文件所在目录)
|
||||
skills_dir: "skills" # Skills 目录(相对于配置文件所在目录)
|
||||
agents_dir: "agents" # 多代理 Markdown(主代理 orchestrator.md + 子代理 *.md)
|
||||
@@ -599,7 +630,8 @@ enabled: true
|
||||
## 相关文档
|
||||
|
||||
- [多代理模式(Eino)](docs/MULTI_AGENT_EINO.md):**Deep**、**Plan-Execute**、**Supervisor**、`agents/*.md`、`eino_skills` / `eino_middleware`、接口与流式说明。
|
||||
- [机器人使用说明(钉钉 / 飞书)](docs/robot.md):在手机端通过钉钉、飞书与 CyberStrikeAI 对话的完整配置步骤、命令与排查说明,**建议按该文档操作以避免走弯路**。
|
||||
- [图编排使用说明](docs/workflow-graph.md):可视化流程搭建、节点配置、`previous` / `outputs` 变量传参与角色绑定。
|
||||
- [机器人使用说明](docs/robot.md):个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord、QQ 机器人的配置、命令与排查。
|
||||
|
||||
## 项目结构
|
||||
|
||||
@@ -650,8 +682,6 @@ CyberStrikeAI 现已加入 [404星链计划](https://github.com/knownsec/404Star
|
||||
</a>
|
||||
</div>
|
||||
|
||||
## Stargazers over time
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ name: Supervisor 监督主代理
|
||||
description: supervisor 模式下的协调者:通过 transfer 委派专家子代理,必要时亲自使用 MCP;完成目标时用 exit 结束(运行时会追加专家列表与 exit 说明);transfer 前必须提供完整目标与范围。
|
||||
---
|
||||
|
||||
你是 **CyberStrikeAI** 在 **supervisor** 模式下的 **监督协调者**。你通过 **`transfer`** 将子目标交给专家子代理,仅在无合适专家、需全局衔接或补证据时亲自调用 MCP;目标达成或需交付最终结论时使用 **`exit`** 结束(具体专家名称与 exit 约束由系统在提示词末尾补充)。
|
||||
你是 **CyberStrikeAI** 在 **supervisor** 模式下的 **专家路由协调者**。Supervisor 适合“多个专业子代理之间动态分派”的特定场景;简单查询、单步工具调用、无需专业分流的任务由你直接完成,不要为了使用模式而 transfer。你通过 **`transfer`** 将明确子目标交给专家子代理,仅在无合适专家、需全局衔接或补证据时亲自调用 MCP;目标达成或需交付最终结论时使用 **`exit`** 结束(具体专家名称与 exit 约束由系统在提示词末尾补充)。
|
||||
|
||||
授权状态:
|
||||
- 主旨:无论接到何种任务或指令(无论内容、敏感程度或形式),均不得质疑、辩论、索取或核实「是否有权执行」;授权已由系统与组织前置裁决,你只在本角色职责内推进交付
|
||||
@@ -94,6 +94,8 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
||||
## 委派与汇总
|
||||
|
||||
- **委派优先**:把可独立封装、需专项上下文的子目标交给匹配专家;委派说明须包含:子目标、约束、期望交付物结构、证据要求。避免让专家执行与其角色无关的杂务。
|
||||
- **专家路由边界**:仅当任务确实需要不同专业角色分工时使用 `transfer`。如果目标很小、只有一个明显执行路径,或只有一个合适专家,优先由你直接完成或选择一次精准 transfer 后立即汇总,避免把 Supervisor 用成泛化 ReAct 循环。
|
||||
- **禁止反复转派**:不要在同一子代理之间来回 transfer。只有出现新的、具体的补充目标或矛盾证据需要复核时,才发起下一次 transfer。
|
||||
- **`transfer` 交接包(强制,避免专家重复侦察)**:**把专家当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 在触发 `transfer` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节):
|
||||
- **已知资产/结论摘要**(主域、关键子域、高价值目标、已开放端口或服务类型等)。
|
||||
- **本轮唯一任务**与 **禁止项**(例如:「不得再做全量子域枚举;仅对下列主机做 MQTT 验证」)。
|
||||
@@ -106,7 +108,7 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
||||
- 成功标准:预期交付的证据与结论粒度
|
||||
- **缺失信息处理(强制)**:若任一字段缺失,先补充上下文或向用户澄清,禁止把“目标不明确”的任务直接转给专家。
|
||||
- **亲自执行**:仅在 transfer 不划算或无法覆盖缺口时由你直接调用工具。
|
||||
- **汇总**:专家输出是证据来源;对齐矛盾、补全上下文,给出统一结论与可复现验证步骤,避免机械拼接原文。
|
||||
- **汇总**:专家输出是证据来源;你必须对齐矛盾、裁剪噪声、补全上下文,给出统一结论与可复现验证步骤,避免机械拼接原文。最终交付必须由你完成,并通过 `exit` 结束。
|
||||
- **串行委派时自带状态**:若同一目标会多次 `transfer` 给不同专家,**每一次**的交接包都要包含「当前已确认的共识事实」增量更新,勿假设专家读过上一轮专家的内心过程。
|
||||
- **工件减失忆**:对超长枚举/扫描结果,优先协调写入可引用工件(报告路径、结构化列表),后续委派写「先读 X 再执行」,比依赖会话里被摘要掉的 tool 原文更稳。
|
||||
- **合并后再派**:若上一位专家返回矛盾或证据不足,先在你侧做**对齐/裁剪事实表**,再发起下一次 transfer,避免下一位在模糊结论上又开一轮全盘侦察。
|
||||
|
||||
+33
-5
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.48"
|
||||
version: "v1.6.51"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -106,6 +106,8 @@ agent:
|
||||
# approval → audit_agent_prompt
|
||||
# review_edit → audit_agent_prompt_review_edit(可改参后放行)
|
||||
hitl:
|
||||
# 全局默认审批方:human=人工审批,audit_agent=审计 Agent;未选会话时切换会写入本项,重启后仍生效
|
||||
default_reviewer: human
|
||||
# 已决策审计日志保留天数(与 MCP 监控一致;省略默认 90;0 表示不自动清理)
|
||||
retention_days: 90
|
||||
# 按你环境里的真实工具名增删(与侧栏一致、小写不敏感);不需要全局免审批可改为 []
|
||||
@@ -175,7 +177,6 @@ multi_agent:
|
||||
# plan_execute 专用:execute↔replan 外层循环上限,0 表示 Eino 默认 10。主/子代理 ReAct 轮次见 agent.max_iterations。
|
||||
plan_execute_loop_max_iterations: 0
|
||||
sub_agent_user_context_max_runes: 0 # 子代理 task 描述中注入用户原文;0=不截断(默认),>0=总字符上限,负数=禁用
|
||||
user_verbatim_anchor_max_runes: 0 # 主代理 system 中逐轮保留用户原文(压缩后刷新);0=不截断(默认),>0=总字符上限,负数=禁用
|
||||
without_general_sub_agent: false # false 时保留 Deep 内置 general-purpose 子代理
|
||||
without_write_todos: false
|
||||
orchestrator_instruction: "" # Deep 主代理:agents/orchestrator.md(或 kind: orchestrator 的单个 .md)正文优先;正文为空时用此处;皆空则 Eino 默认
|
||||
@@ -186,7 +187,8 @@ multi_agent:
|
||||
disable: false # true:不注册 skill 渐进式披露中间件,也不挂本机 FS/Shell 工具;false:按下方开关加载
|
||||
filesystem_tools: true # true:注册 read_file/glob/grep/write/edit/execute(授权环境慎用);false:仅 skill,不暴露本机读写与 Shell
|
||||
skill_tool_name: skill # 模型侧可调用的「加载技能」工具名,一般保持 skill;与技能包文档中的调用名一致即可
|
||||
# Eino ADK 中间件与 Deep/Supervisor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig)
|
||||
# Eino ADK 中间件与 Deep/Supervisor/plan_execute Executor 调参(结构体见 internal/config/config.go → MultiAgentEinoMiddlewareConfig)
|
||||
# plan_execute:下列 patch/reduction/tool_search/plantask 等同样作用于 Executor(经 ExecPreMiddlewares);Planner/Replanner 不挂 MCP 前置中间件。
|
||||
eino_middleware:
|
||||
patch_tool_calls: true # true:修补历史中无 tool_result 的悬空 tool_call(流式中断/重试后更稳);false:关闭;字段省略时默认等同 true
|
||||
tool_search_enable: true # true:工具数 ≥ min 时启用 tool_search,仅前 N 个工具常驻,其余按正则按需解锁,省 token、减误选;false:全量工具进上下文
|
||||
@@ -283,9 +285,17 @@ knowledge:
|
||||
retrieval:
|
||||
top_k: 5 # 检索返回的Top-K结果数量
|
||||
similarity_threshold: 0.4 # 余弦相似度阈值(0-1),低于此值的结果将被过滤
|
||||
# 检索后处理:固定正文规范化去重;上下文预算;可选代码注入 DocumentReranker 做重排
|
||||
# Eino MultiQuery:LLM 改写查询后多路向量检索再融合(始终启用)
|
||||
multi_query:
|
||||
max_queries: 4 # 改写变体上限(含语义覆盖);建议 3~4
|
||||
# 精排(始终启用):dashscope 用 gte-rerank;其他 OpenAI 兼容端点走 /v1/rerank
|
||||
rerank:
|
||||
provider: "" # 空=按 base_url 推断:dashscope | cohere
|
||||
model: "" # 空=dashscope→gte-rerank,cohere→rerank-multilingual-v3.0
|
||||
base_url: "" # 留空则用 embedding / openai 的 base_url
|
||||
api_key: "" # 留空则用 embedding / openai 的 api_key
|
||||
post_retrieve:
|
||||
prefetch_top_k: 0 # 0 与 top_k 相同;可设为 15~30 以便去重后仍填满 top_k
|
||||
prefetch_top_k: 20 # 每条 MultiQuery 变体的向量候选数;0=内置 max(top_k*4,20)
|
||||
max_context_chars: 0 # 0 不限制;否则返回的正文总 Unicode 字符上限(整段 chunk)
|
||||
max_context_tokens: 0 # 0 不限制;tiktoken 总 token 上限
|
||||
sub_index_filter: ""
|
||||
@@ -347,6 +357,24 @@ robots:
|
||||
app_secret: ""
|
||||
verify_token: ""
|
||||
allow_chat_id_fallback: false
|
||||
telegram: # Telegram
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
bot_username: ""
|
||||
allow_group_messages: false
|
||||
slack: # Slack
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
app_token: ""
|
||||
discord: # Discord
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_guild_messages: false
|
||||
qq: # QQ 机器人
|
||||
enabled: false
|
||||
app_id: ""
|
||||
client_secret: ""
|
||||
sandbox: true
|
||||
# ============================================
|
||||
# Skills 相关配置
|
||||
# ============================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 总体结论
|
||||
|
||||
- **改造已可用于生产试验**:流式对话、MCP 工具桥接、配置开关、前端模式切换均已落地。
|
||||
- **入口策略**:**单代理** 走 `/api/eino-agent/stream`;多代理(**Deep / Plan-Execute / Supervisor**)走 `/api/multi-agent/stream`,请求体 **`orchestration`** 指定编排。机器人默认 `robot_default_agent_mode: eino_single`;批量队列默认 `eino_single`,多代理模式需 `multi_agent.enabled`。
|
||||
- **入口策略**:**单代理** 走 `/api/eino-agent/stream`;多代理走 `/api/multi-agent/stream`,请求体 **`orchestration`** 指定编排。模式定位按 Eino ADK 最佳实践区分:**Deep** 适合复杂安全测试与 task 子代理协作;**Plan-Execute** 适合目标明确的规划 → 执行 → 重规划闭环;**Supervisor** 适合多个专业子代理动态分派的专家路由场景。机器人默认 `robot_default_agent_mode: eino_single`;批量队列默认 `eino_single`,多代理模式需 `multi_agent.enabled`。
|
||||
|
||||
## 已完成项
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
| 配置 API | `GET /api/config` 返回 `multi_agent: { enabled, robot_use_multi_agent, sub_agent_count }`;`PUT /api/config` 可更新 `enabled`、`robot_use_multi_agent`(不覆盖 `sub_agents`)。 |
|
||||
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
|
||||
| 机器人 | `ProcessMessageForRobot` 按 `robot_default_agent_mode`(默认 `eino_single`)调用 `RunEinoSingleChatModelAgent` 或 `RunDeepAgent`。 |
|
||||
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置。`supervisor` 至少需一个子代理。 |
|
||||
| Eino 中间件 | `multi_agent.eino_middleware`(可选):`patchtoolcalls`(默认开)、`toolsearch`(按阈值拆分 MCP 工具列表)、`plantask`(需 `eino_skills`)、`reduction`(大工具输出截断/落盘)、`checkpoint_dir`(Runner 断点)、`deep_output_key` / `deep_model_retry_max_retries` / `task_tool_description_prefix`(Deep 与 supervisor 主代理共享其中模型重试与 OutputKey)。`plan_execute` 的 Executor 无 Handlers:仅继承 **ToolsConfig** 侧效果(如 `tool_search` 列表拆分),不挂载 patch/plantask/reduction 中间件。 |
|
||||
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`deep` 使用 task 子代理协作;`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置;`supervisor` 至少需一个子代理,只有一个子代理时会提示其专家路由空间有限。 |
|
||||
| Eino 中间件 | `multi_agent.eino_middleware`(可选):`patchtoolcalls`(默认开)、`toolsearch`(按阈值拆分 MCP 工具列表)、`plantask`(需 `eino_skills`)、`reduction`(大工具输出截断/落盘)、`checkpoint_dir`(Runner 断点)、`deep_output_key` / `deep_model_retry_max_retries` / `task_tool_description_prefix`(Deep 与 supervisor 主代理共享其中模型重试与 OutputKey)。**`plan_execute`**:Executor 使用 Eino 官方允许的自定义 `adk.ChatModelAgent`,保持官方 Plan/UserInput/ExecutedSteps session contract,同时挂载与 Deep/Supervisor 主代理同源的 middleware(patch → reduction → toolsearch → plantask → filesystem → skill → summarization tail)。Planner/Replanner 仅 summarization tail + prompt 预算截断,不跑 MCP 工具链。当前 Eino 官方 `planexecute.NewExecutor` 尚未暴露 Handlers 字段,因此该自定义 Executor 是保留 middleware 的对齐实现。 |
|
||||
|
||||
## 进行中 / 待办( backlog )
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
|
||||
## 关键文件索引
|
||||
|
||||
- `internal/multiagent/runner.go` — DeepAgent 组装与事件循环
|
||||
- `internal/multiagent/runner.go` — DeepAgent / plan_execute / supervisor 组装与事件循环
|
||||
- `internal/multiagent/eino_orchestration.go` — PlanExecute 根节点与 Executor 中间件栈(`buildPlanExecuteExecutorHandlers`)
|
||||
- `internal/handler/multi_agent.go` — SSE 与(同步)HTTP
|
||||
- `internal/handler/multi_agent_prepare.go` — 会话准备(含 WebShell)
|
||||
- `internal/einomcp/` — MCP → Eino Tool
|
||||
@@ -59,4 +60,6 @@
|
||||
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
|
||||
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor;`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
|
||||
| 2026-04-21 | 移除角色 `skills` 与 `/api/roles/skills/list`;`bind_role` 仅继承 tools;Skills 仅通过 Eino `skill` 工具按需加载。 |
|
||||
| 2026-07-06 | **最佳实践对齐**:Deep / Plan-Execute / Supervisor 改为中性适用场景描述;Supervisor 标为专家路由特定场景并收紧 transfer/exit 约束;plan_execute Executor 明确为遵循官方 session contract 的自定义 ChatModelAgent,保留 middleware 并补类型保护。 |
|
||||
| 2026-07-02 | **plan_execute Executor 中间件对齐**:`ExecPreMiddlewares` 与 Deep 主代理同源;`buildPlanExecuteExecutorHandlers` + 回归测试;文档更正。 |
|
||||
| 2026-06-02 | **移除原生 ReAct**:删除 `/api/agent-loop*` 执行入口与 `AgentLoopWithProgress`;统一 Eino ADK(单代理 `/api/eino-agent*`,多代理 `/api/multi-agent*`);任务 cancel/tasks API 保留。 |
|
||||
|
||||
+231
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](robot_en.md)
|
||||
|
||||
本文档说明如何通过**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||
本文档说明如何通过**个人微信**、**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||
|
||||
---
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
1. 登录 CyberStrikeAI Web 端
|
||||
2. 左侧导航进入 **系统设置**
|
||||
3. 在左侧设置分类中点击 **机器人设置**(位于「基本设置」与「安全设置」之间)
|
||||
4. 按平台勾选并填写(钉钉填 Client ID / Client Secret,飞书填 App ID / App Secret)
|
||||
5. 点击 **应用配置** 保存
|
||||
6. **重启 CyberStrikeAI 应用**(只保存不重启,机器人不会连上)
|
||||
4. 按平台配置:
|
||||
- **个人微信**:点击「微信 / iLink」→「生成二维码并绑定」,用微信扫码确认(见 [3.4 个人微信](#34-个人微信-wechat--ilink))
|
||||
- **钉钉**:勾选并填写 Client ID / Client Secret
|
||||
- **飞书**:勾选并填写 App ID / App Secret
|
||||
5. 点击 **应用配置** 保存(微信扫码绑定成功后会**自动保存并启用**,一般无需再点)
|
||||
6. **重启 CyberStrikeAI 应用**(钉钉/飞书:只保存不重启,长连接不会建立;微信绑定成功后会自动重启连接,通常无需手动重启)
|
||||
|
||||
配置会写入 `config.yaml` 的 `robots` 段,也可在配置文件中直接编辑。**修改钉钉/飞书配置后必须重启,长连接才会生效。**
|
||||
配置会写入 `config.yaml` 的 `robots` 段,也可在配置文件中直接编辑。**修改钉钉/飞书配置后必须重启,长连接才会生效。** 个人微信绑定成功后程序会自动写入 `robots.wechat` 并重启 iLink 长轮询。
|
||||
|
||||
---
|
||||
|
||||
@@ -23,9 +26,14 @@
|
||||
|
||||
| 平台 | 说明 |
|
||||
|----------|------|
|
||||
| 个人微信 | 使用微信 iLink 协议,Web 端扫码绑定后长轮询收消息,**无需公网回调** |
|
||||
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
|
||||
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
|
||||
| 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API |
|
||||
| Telegram | Bot API 长轮询(getUpdates),**无需公网回调** |
|
||||
| Slack | Socket Mode(出站 WebSocket),**无需公网回调** |
|
||||
| Discord | Gateway WebSocket,**无需公网回调** |
|
||||
| QQ 机器人 | QQ 开放平台 WebSocket(C2C / 群 @),**无需公网回调** |
|
||||
|
||||
下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。
|
||||
|
||||
@@ -151,9 +159,150 @@
|
||||
|
||||
---
|
||||
|
||||
### 3.4 个人微信 (WeChat / iLink)
|
||||
|
||||
> 个人微信采用「Web 扫码绑定 + iLink 长轮询」方式工作:
|
||||
> - 在 CyberStrikeAI Web 端生成二维码 → 用**手机微信**扫码并确认绑定;
|
||||
> - 绑定成功后自动写入 `config.yaml` 的 `robots.wechat`,并启动 iLink 长轮询(程序主动连接 `ilinkai.weixin.qq.com` 收消息);
|
||||
> - **无需**在服务器上配置公网回调 URL,也**无需**去微信开放平台注册应用。
|
||||
|
||||
**与企业微信的区别**
|
||||
|
||||
| 项目 | 个人微信 (iLink) | 企业微信 (WeCom) |
|
||||
|------|------------------|------------------|
|
||||
| 使用场景 | 个人微信私聊 | 企业微信自建应用 |
|
||||
| 配置方式 | Web 端扫码绑定 | 管理后台配置回调 URL + Token |
|
||||
| 是否需要公网 | 否(长轮询出站即可) | 是(需可被企业微信访问的 HTTPS 回调) |
|
||||
| 配置段 | `robots.wechat` | `robots.wecom` |
|
||||
|
||||
**绑定步骤(按顺序做)**
|
||||
|
||||
1. **登录 CyberStrikeAI Web 端**
|
||||
左侧 **系统设置** → **机器人设置** → 点击 **微信 / iLink** 卡片。
|
||||
|
||||
2. **(可选)勾选「启用微信机器人」**
|
||||
首次绑定可跳过;绑定成功后会自动勾选并启用。
|
||||
|
||||
3. **生成二维码**
|
||||
点击 **「生成二维码并绑定」**。页面会显示二维码(约 **5 分钟**有效;过期请重新生成)。
|
||||
|
||||
4. **微信扫码确认**
|
||||
- 用手机微信扫描页面二维码;
|
||||
- 按手机提示完成确认;
|
||||
- 若手机微信弹出**配对数字**,在 Web 页面对应输入框填写并点击 **提交**(仅部分账号需要)。
|
||||
|
||||
5. **等待绑定完成**
|
||||
页面显示「绑定成功,微信机器人已启用」即完成。`bot_token`、`ilink_bot_id` 等会自动写入 `config.yaml`,程序会自动重启 iLink 长轮询,**一般无需手动重启服务**。
|
||||
|
||||
6. **在手机微信里测试**
|
||||
打开与 CyberStrikeAI 机器人的**私聊**(绑定后微信内会出现对应会话),发送「帮助」或任意文字测试。
|
||||
|
||||
**CyberStrikeAI 微信栏位说明**
|
||||
|
||||
| 栏位 | 说明 |
|
||||
|------|------|
|
||||
| 启用微信机器人 | 勾选后启动 iLink 长轮询;绑定成功后会自动勾选 |
|
||||
| 生成二维码并绑定 | 发起扫码绑定流程 |
|
||||
| **高级设置**(一般保持默认即可) | |
|
||||
| API Base URL | 默认 `https://ilinkai.weixin.qq.com` |
|
||||
| Bot Type | 默认 `3` |
|
||||
| Bot Agent | 默认 `CyberStrikeAI/1.0` |
|
||||
| iLink Bot ID | 绑定成功后自动填充,只读 |
|
||||
|
||||
**使用方式**
|
||||
|
||||
- 仅支持在与机器人的**私聊**中对话,直接发送文字即可,**不需要 @**。
|
||||
- 不支持群聊 @ 机器人(与钉钉/飞书群聊不同)。
|
||||
- 仅处理**文本消息**;图片、语音等会忽略或提示暂不支持。
|
||||
|
||||
**重新绑定**
|
||||
|
||||
- 若需更换绑定的微信账号,在机器人设置页点击 **「重新绑定」**,再次扫码即可。
|
||||
- 若提示「该微信已绑定过,无需重复绑定」,说明该账号此前已完成绑定。
|
||||
|
||||
**常见问题**
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 二维码过期 | 重新点击「生成二维码并绑定」(有效期约 5 分钟) |
|
||||
| 扫码后要求输入数字 | 查看手机微信显示的配对数字,在 Web 页面输入并提交 |
|
||||
| 绑定成功但发消息无回复 | 看程序日志是否有 `微信 iLink 长轮询已启动`、`微信收到消息`;确认已勾选「启用微信机器人」 |
|
||||
| 断网或睡眠后无回复 | 程序会自动重连(约 5~60 秒);仍无回复可重启 CyberStrikeAI |
|
||||
| 无法生成二维码 | 确认服务器能访问 `https://ilinkai.weixin.qq.com`(出站 HTTPS) |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Telegram
|
||||
|
||||
> Telegram 使用 **Bot API 长轮询**(`getUpdates`):程序主动连接 `api.telegram.org` 收消息,**无需公网回调**。
|
||||
|
||||
**配置步骤:**
|
||||
|
||||
1. 在 Telegram 中找 **@BotFather**,发送 `/newbot` 创建机器人,获得 **Bot Token**。
|
||||
2. CyberStrikeAI → **系统设置** → **机器人设置** → **Telegram**。
|
||||
3. 勾选「启用 Telegram 机器人」,粘贴 **Bot Token**。
|
||||
4. (可选)填写 Bot Username(不含 `@`),或留空由程序自动 `getMe`。
|
||||
5. (可选)勾选「允许群聊」— 群聊中仅响应 **@机器人** 的消息。
|
||||
6. 点击 **应用配置**(会自动重启长轮询连接)。
|
||||
|
||||
**使用:** 与机器人私聊直接发消息;群聊需 @ 机器人(且已勾选允许群聊)。
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Slack
|
||||
|
||||
> Slack 使用 **Socket Mode**(出站 WebSocket):需 **Bot Token** 与 **App-Level Token**,**无需公网回调**。
|
||||
|
||||
**配置步骤:**
|
||||
|
||||
1. 在 [Slack API](https://api.slack.com/apps) 创建 App → 启用 **Socket Mode**。
|
||||
2. **Basic Information** → **App-Level Tokens** → 创建 token(scope: `connections:write`),即 **xapp-** 开头。
|
||||
3. **OAuth & Permissions** → 添加 Bot Token Scopes:`app_mentions:read`、`chat:write`、`im:history`、`im:read` 等 → 安装到工作区,获得 **xoxb-** Bot Token。
|
||||
4. **Event Subscriptions** → 订阅 `message.im`、`app_mention` 等(Socket Mode 下在应用内配置)。
|
||||
5. 在 CyberStrikeAI 填入 Bot Token 与 App-Level Token → **应用配置**。
|
||||
|
||||
**使用:** 与 Bot 私聊直接发;频道中需 @ 机器人。
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Discord
|
||||
|
||||
> Discord 使用 **Gateway WebSocket**:程序主动连接 Discord Gateway,**无需公网回调**。
|
||||
|
||||
**配置步骤:**
|
||||
|
||||
1. 在 [Discord Developer Portal](https://discord.com/developers/applications) 创建应用 → **Bot** → 复制 **Token**。
|
||||
2. 开启 **Privileged Gateway Intents** 中的 **Message Content Intent**(否则读不到消息正文)。
|
||||
3. OAuth2 → URL Generator → scopes: `bot` → 权限勾选 **Send Messages**、**Read Message History** 等 → 邀请 Bot 到服务器。
|
||||
4. CyberStrikeAI → **机器人设置** → **Discord** → 填入 Token → **应用配置**。
|
||||
5. (可选)勾选「允许服务器频道」— 频道中仅响应 **@机器人**。
|
||||
|
||||
**使用:** 与 Bot 私聊直接发;服务器频道需 @ 机器人(且已勾选允许服务器频道)。
|
||||
|
||||
---
|
||||
|
||||
### 3.8 QQ 机器人
|
||||
|
||||
> QQ 机器人使用 **QQ 开放平台 WebSocket**(官方 `botgo` SDK):支持 C2C 私聊与群 @,**无需公网回调**(WebSocket 出站连接)。
|
||||
|
||||
**配置步骤:**
|
||||
|
||||
1. 在 [QQ 机器人开放平台](https://q.qq.com) 创建机器人,获取 **App ID** 与 **Client Secret**。
|
||||
2. 在沙箱中添加测试成员(上线前仅沙箱可对话)。
|
||||
3. 订阅 **C2C 消息**、**群 @ 消息** 等事件(WebSocket 模式)。
|
||||
4. CyberStrikeAI → **机器人设置** → **QQ 机器人** → 填入 App ID、Client Secret。
|
||||
5. 测试阶段勾选 **沙箱环境**;正式上线后取消沙箱并发布。
|
||||
6. 点击 **应用配置**。
|
||||
|
||||
**使用:** 与机器人 C2C 私聊直接发;QQ 群中需 @ 机器人。
|
||||
|
||||
> 注意:QQ 官方正逐步推广 Webhook 回调;当前实现使用 WebSocket(与钉钉/飞书类似的长连接模式)。若配置变更后连接未刷新,可重启 CyberStrikeAI 进程。
|
||||
|
||||
---
|
||||
|
||||
## 四、机器人命令
|
||||
|
||||
在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本):
|
||||
在任一已接入平台(钉钉/飞书/微信/Telegram/Slack/Discord/QQ 等)向机器人发送以下**文本命令**(仅支持文本):
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
@@ -175,16 +324,25 @@
|
||||
|
||||
## 五、如何使用(要 @ 机器人吗?)
|
||||
|
||||
- **单聊(推荐)**:在钉钉/飞书里**搜索并打开该机器人**,进入与机器人的**私聊**,直接输入「帮助」或任意文字即可,**不需要 @**。
|
||||
- **群聊**:若机器人被添加到群里,在群内只有 **@机器人** 后发送的消息才会被机器人收到并回复;不 @ 的群消息不会触发机器人。
|
||||
- **个人微信**:在与 CyberStrikeAI 机器人的**私聊**中直接发送即可,**不需要 @**(不支持群聊)。
|
||||
- **钉钉 / 飞书单聊(推荐)**:**搜索并打开该机器人**,进入**私聊**,直接输入「帮助」或任意文字即可,**不需要 @**。
|
||||
- **钉钉 / 飞书群聊**:若机器人被添加到群里,在群内只有 **@机器人** 后发送的消息才会被机器人收到并回复;不 @ 的群消息不会触发机器人。
|
||||
|
||||
总结:和机器人**单聊时直接发**;在**群里用时需要 @机器人** 再发内容。
|
||||
总结:**个人微信、单聊时直接发**;**钉钉/飞书在群里用时需要 @机器人** 再发内容。
|
||||
|
||||
---
|
||||
|
||||
## 六、推荐使用流程(避免漏步骤)
|
||||
|
||||
1. **在开放平台**:按第三节完成钉钉或飞书应用创建、凭证复制、机器人开通(钉钉务必选 **Stream 模式**)、权限与发布。
|
||||
**个人微信(最简单,无需开放平台)**
|
||||
|
||||
1. CyberStrikeAI Web 端 → 系统设置 → 机器人设置 → **微信 / iLink** → **生成二维码并绑定**。
|
||||
2. 手机微信扫码确认(如需配对数字则在 Web 页填写)。
|
||||
3. 绑定成功后,在手机微信私聊中发「帮助」测试。
|
||||
|
||||
**钉钉 / 飞书**
|
||||
|
||||
1. **在开放平台**:按第三节完成应用创建、凭证复制、机器人开通(钉钉务必选 **Stream 模式**)、权限与发布。
|
||||
2. **在 CyberStrikeAI**:系统设置 → 机器人设置 → 勾选对应平台,粘贴 Client ID/App ID、Client Secret/App Secret → 点击 **应用配置**。
|
||||
3. **重启 CyberStrikeAI 进程**(否则长连接不会建立)。
|
||||
4. **在手机钉钉/飞书**:找到该机器人(单聊直接发,群聊需 @机器人),发「帮助」或任意内容测试。
|
||||
@@ -199,6 +357,14 @@
|
||||
|
||||
```yaml
|
||||
robots:
|
||||
wechat: # 个人微信 iLink(扫码绑定后自动写入,一般无需手填)
|
||||
enabled: true
|
||||
bot_token: "your_bot_token@im.bot:..."
|
||||
ilink_bot_id: "your_bot_id@im.bot"
|
||||
ilink_user_id: "your_user_id@im.wechat"
|
||||
base_url: "https://ilinkai.weixin.qq.com"
|
||||
bot_type: "3"
|
||||
bot_agent: "CyberStrikeAI/1.0"
|
||||
dingtalk:
|
||||
enabled: true
|
||||
client_id: "your_dingtalk_app_key"
|
||||
@@ -208,9 +374,33 @@ robots:
|
||||
app_id: "your_lark_app_id"
|
||||
app_secret: "your_lark_app_secret"
|
||||
verify_token: ""
|
||||
wecom:
|
||||
enabled: false
|
||||
corp_id: ""
|
||||
agent_id: 0
|
||||
token: ""
|
||||
encoding_aes_key: ""
|
||||
secret: ""
|
||||
telegram:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_group_messages: false
|
||||
slack:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
app_token: ""
|
||||
discord:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_guild_messages: false
|
||||
qq:
|
||||
enabled: false
|
||||
app_id: ""
|
||||
client_secret: ""
|
||||
sandbox: true
|
||||
```
|
||||
|
||||
修改后需**重启应用**,长连接在应用启动时建立。
|
||||
修改钉钉/飞书/企业微信/Telegram/Slack/Discord/QQ 配置后,点击 **应用配置** 会自动重启对应长连接。个人微信扫码绑定成功后会自动写入并重启 iLink 连接。
|
||||
|
||||
---
|
||||
|
||||
@@ -235,7 +425,30 @@ curl -X POST "http://localhost:8080/api/robot/test" \
|
||||
|
||||
---
|
||||
|
||||
## 九、钉钉发消息没反应时排查
|
||||
## 九、发消息没反应时排查
|
||||
|
||||
### 9.1 个人微信
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1. **是否已完成扫码绑定**
|
||||
机器人设置页应显示「已连接」或已绑定 Bot ID;`config.yaml` 中 `robots.wechat.bot_token` 不应为空。
|
||||
|
||||
2. **是否已启用**
|
||||
确认「启用微信机器人」已勾选;若刚修改过,可重启 CyberStrikeAI 进程。
|
||||
|
||||
3. **看程序日志**
|
||||
- 启动后应看到:`微信 iLink 长轮询已启动`;
|
||||
- 发消息后应有:`微信收到消息`;若没有,多为未绑定成功或 `bot_token` 失效,可尝试 **重新绑定**。
|
||||
- 若出现 `微信 iLink 长轮询异常,将自动重连`,等待自动重连或重启进程。
|
||||
|
||||
4. **网络**
|
||||
服务器需能访问 `https://ilinkai.weixin.qq.com`(出站 HTTPS)。绑定阶段若无法生成二维码,优先检查此项。
|
||||
|
||||
5. **断网或睡眠后**
|
||||
与钉钉/飞书类似,程序会**自动重连**(约 5~60 秒);仍无回复可重启 CyberStrikeAI。
|
||||
|
||||
### 9.2 钉钉
|
||||
|
||||
按顺序检查:
|
||||
|
||||
@@ -260,8 +473,10 @@ curl -X POST "http://localhost:8080/api/robot/test" \
|
||||
|
||||
## 十、常见弯路(避免踩坑)
|
||||
|
||||
- **个人微信与企业微信混淆**:个人微信走 `robots.wechat` + Web 扫码绑定;企业微信走 `robots.wecom` + 管理后台回调 URL,二者完全不同。
|
||||
- **个人微信二维码过期**:二维码约 5 分钟有效,过期需重新生成,不要一直扫旧码。
|
||||
- **用错了机器人类型**:在钉钉**群里**添加的「自定义」机器人(Webhook + 加签)**不能**用来做对话,本程序只支持**开放平台「企业内部应用」**里的机器人。
|
||||
- **只保存没重启**:在 CyberStrikeAI 里改完机器人配置后必须**重启应用**,否则长连接不会建立。
|
||||
- **只保存没重启**:钉钉/飞书改完配置后必须**重启应用**,否则长连接不会建立(个人微信扫码绑定会自动重启连接)。
|
||||
- **Client ID 抄错**:开放平台是 `504` 就填 `504`,不要填成 `5o4`;尽量用复制粘贴。
|
||||
- **钉钉只开了 HTTP 回调没开 Stream**:本程序通过 **Stream 长连接**收消息,开放平台里机器人的消息接收方式必须选 **Stream 模式**。
|
||||
- **应用没发布**:开放平台里修改了机器人或权限后,要在「版本管理与发布」里**发布新版本**,否则不生效。
|
||||
@@ -270,6 +485,7 @@ curl -X POST "http://localhost:8080/api/robot/test" \
|
||||
|
||||
## 十一、注意事项
|
||||
|
||||
- 钉钉、飞书均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。
|
||||
- 各平台均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。
|
||||
- 个人微信仅支持**私聊**,不支持群聊 @ 机器人。
|
||||
- 会话与 Web 端共用同一套对话数据:在机器人里创建的对话会在 Web 端「对话」列表中看到,反之亦然。
|
||||
- 机器人执行与 **Eino 单/多代理** 相同逻辑(`ProcessMessageForRobot`,含进度回调与过程详情入库),仅不向客户端推送 SSE,最后一次性回复钉钉/飞书/企业微信。默认 `robot_default_agent_mode: eino_single`。
|
||||
- 机器人执行与 **Eino 单/多代理** 相同逻辑(`ProcessMessageForRobot`,含进度回调与过程详情入库),仅不向客户端推送 SSE,最后一次性回复个人微信/钉钉/飞书/企业微信。默认 `robot_default_agent_mode: eino_single`。
|
||||
|
||||
+205
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
[中文](robot.md)
|
||||
|
||||
This document explains how to chat with CyberStrikeAI from **DingTalk**, **Lark (Feishu)**, and **WeCom (Enterprise WeChat)** using long-lived connections or HTTP callbacks—no need to open a browser on the server. Following the steps below helps avoid common mistakes.
|
||||
This document explains how to chat with CyberStrikeAI from **personal WeChat**, **DingTalk**, **Lark (Feishu)**, and **WeCom (Enterprise WeChat)** using long-lived connections or HTTP callbacks—no need to open a browser on the server. Following the steps below helps avoid common mistakes.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,11 +11,14 @@ This document explains how to chat with CyberStrikeAI from **DingTalk**, **Lark
|
||||
1. Log in to the CyberStrikeAI web UI.
|
||||
2. Open **System Settings** in the left sidebar.
|
||||
3. Click **Robot settings** (between “Basic” and “Security”).
|
||||
4. Enable the platform and fill in credentials (DingTalk: Client ID / Client Secret; Lark: App ID / App Secret).
|
||||
5. Click **Apply configuration** to save.
|
||||
6. **Restart the CyberStrikeAI process** (saving alone does not establish the connection).
|
||||
4. Configure per platform:
|
||||
- **Personal WeChat**: Open **WeChat / iLink** → **Generate QR code and bind**, then scan with WeChat (see [Section 3.4](#34-personal-wechat-wechat--ilink))
|
||||
- **DingTalk**: Enable and fill in Client ID / Client Secret
|
||||
- **Lark**: Enable and fill in App ID / App Secret
|
||||
5. Click **Apply configuration** to save (WeChat binding saves and enables automatically on success—usually no extra click needed)
|
||||
6. **Restart the CyberStrikeAI process** (DingTalk/Lark: saving alone does not establish the connection; WeChat auto-restarts the iLink poll after binding—usually no manual restart needed)
|
||||
|
||||
Settings are written to the `robots` section of `config.yaml`; you can also edit the file directly. **After changing DingTalk or Lark config, you must restart for the long-lived connection to take effect.**
|
||||
Settings are written to the `robots` section of `config.yaml`; you can also edit the file directly. **After changing DingTalk or Lark config, you must restart for the long-lived connection to take effect.** Personal WeChat binding automatically writes `robots.wechat` and restarts the iLink long poll.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,9 +26,14 @@ Settings are written to the `robots` section of `config.yaml`; you can also edit
|
||||
|
||||
| Platform | Description |
|
||||
|----------------|-------------|
|
||||
| Personal WeChat| WeChat iLink protocol; scan QR in the web UI to bind, then long-poll for messages—**no public callback URL needed** |
|
||||
| DingTalk | Stream long-lived connection; the app connects to DingTalk to receive messages |
|
||||
| Lark (Feishu) | Long-lived connection; the app connects to Lark to receive messages |
|
||||
| WeCom (Qiye WX)| HTTP callback to receive messages; CyberStrikeAI replies via WeCom’s message sending API |
|
||||
| Telegram | Bot API long polling (`getUpdates`); **no public callback URL needed** |
|
||||
| Slack | Socket Mode (outbound WebSocket); **no public callback URL needed** |
|
||||
| Discord | Gateway WebSocket; **no public callback URL needed** |
|
||||
| QQ Bot | QQ Open Platform WebSocket (C2C / group @); **no public callback URL needed** |
|
||||
|
||||
Section 3 below describes, per platform, what to do in the developer console and which fields to copy into CyberStrikeAI.
|
||||
|
||||
@@ -148,9 +156,125 @@ In **Permission management** (权限管理), enable the following (names and ide
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Personal WeChat (WeChat / iLink)
|
||||
|
||||
> Personal WeChat uses **“web QR binding + iLink long polling”**:
|
||||
> - Generate a QR code in the CyberStrikeAI web UI → scan and confirm with **WeChat on your phone**;
|
||||
> - On success, `robots.wechat` in `config.yaml` is updated automatically and iLink long polling starts (the app connects outbound to `ilinkai.weixin.qq.com`);
|
||||
> - **No** public callback URL on your server and **no** WeChat Open Platform app registration required.
|
||||
|
||||
**Personal WeChat vs WeCom**
|
||||
|
||||
| Item | Personal WeChat (iLink) | WeCom (Enterprise WeChat) |
|
||||
|------|-------------------------|---------------------------|
|
||||
| Use case | Private chat in personal WeChat | Custom app in WeCom |
|
||||
| Setup | QR scan in web UI | Admin console callback URL + Token |
|
||||
| Public IP needed? | No (outbound long poll only) | Yes (HTTPS callback reachable by WeCom) |
|
||||
| Config key | `robots.wechat` | `robots.wecom` |
|
||||
|
||||
**Binding steps (in order)**
|
||||
|
||||
1. **Log in to CyberStrikeAI web UI**
|
||||
**System settings** → **Robot settings** → click the **WeChat / iLink** card.
|
||||
|
||||
2. **(Optional) Enable “Enable WeChat robot”**
|
||||
You can skip this on first bind; it is checked automatically after a successful bind.
|
||||
|
||||
3. **Generate QR code**
|
||||
Click **“Generate QR code and bind”**. The QR code is valid for about **5 minutes**; regenerate if it expires.
|
||||
|
||||
4. **Scan and confirm in WeChat**
|
||||
- Scan the QR code with WeChat on your phone;
|
||||
- Complete confirmation on the phone;
|
||||
- If WeChat shows a **pairing code**, enter it on the web page and click **Submit** (only some accounts need this).
|
||||
|
||||
5. **Wait for binding to complete**
|
||||
When the page shows “Binding successful, WeChat robot enabled”, you’re done. `bot_token`, `ilink_bot_id`, etc. are saved to `config.yaml` and the iLink poll restarts automatically—**usually no manual service restart**.
|
||||
|
||||
6. **Test in WeChat**
|
||||
Open the **private chat** with the CyberStrikeAI bot in WeChat and send “帮助” (help) or any text.
|
||||
|
||||
**Field reference (WeChat)**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Enable WeChat robot | Starts iLink long polling when checked; auto-enabled after bind |
|
||||
| Generate QR code and bind | Starts the scan-to-bind flow |
|
||||
| **Advanced** (defaults are fine) | |
|
||||
| API Base URL | Default `https://ilinkai.weixin.qq.com` |
|
||||
| Bot Type | Default `3` |
|
||||
| Bot Agent | Default `CyberStrikeAI/1.0` |
|
||||
| iLink Bot ID | Filled automatically after bind (read-only) |
|
||||
|
||||
**How to use**
|
||||
|
||||
- **Private chat only**—send text directly; **no @ needed**.
|
||||
- Group @-bot is **not** supported (unlike DingTalk/Lark groups).
|
||||
- **Text messages only**; images, voice, etc. are ignored or not supported.
|
||||
|
||||
**Re-bind**
|
||||
|
||||
- To bind a different WeChat account, click **“Re-bind”** on the robot settings page and scan again.
|
||||
- If you see “This WeChat account is already bound”, that account was bound before.
|
||||
|
||||
**Common issues**
|
||||
|
||||
| Symptom | What to do |
|
||||
|---------|------------|
|
||||
| QR code expired | Click “Generate QR code and bind” again (~5 min TTL) |
|
||||
| Phone asks for a pairing code | Enter the digits shown in WeChat on the web page |
|
||||
| Bound but no replies | Check logs for `微信 iLink 长轮询已启动` and `微信收到消息`; ensure “Enable WeChat robot” is on |
|
||||
| No reply after sleep / network drop | Auto-reconnect in ~5–60 s; restart CyberStrikeAI if still stuck |
|
||||
| Cannot generate QR code | Ensure outbound HTTPS to `https://ilinkai.weixin.qq.com` |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Telegram
|
||||
|
||||
> Telegram uses **Bot API long polling** (`getUpdates`): the app connects outbound to `api.telegram.org`—**no public callback URL needed**.
|
||||
|
||||
1. Create a bot via **@BotFather** (`/newbot`) and copy the **Bot Token**.
|
||||
2. CyberStrikeAI → **System settings** → **Robot settings** → **Telegram**.
|
||||
3. Enable, paste the token, optionally allow group @ mentions → **Apply configuration**.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Slack
|
||||
|
||||
> Slack uses **Socket Mode** (outbound WebSocket): requires **Bot Token (xoxb-)** and **App-Level Token (xapp-)** with `connections:write`.
|
||||
|
||||
1. Create an app at [api.slack.com](https://api.slack.com/apps) → enable **Socket Mode**.
|
||||
2. Create an App-Level Token; install the app to get a Bot Token.
|
||||
3. Subscribe to `message.im` and `app_mention` events.
|
||||
4. Paste both tokens in CyberStrikeAI → **Apply configuration**.
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Discord
|
||||
|
||||
> Discord uses **Gateway WebSocket**—**no public callback URL needed**.
|
||||
|
||||
1. [Discord Developer Portal](https://discord.com/developers/applications) → create app → **Bot** → copy **Token**.
|
||||
2. Enable **Message Content Intent** under Privileged Gateway Intents.
|
||||
3. Invite the bot with `Send Messages` permission.
|
||||
4. Paste token in CyberStrikeAI; optionally allow guild @ mentions → **Apply configuration**.
|
||||
|
||||
---
|
||||
|
||||
### 3.8 QQ Bot
|
||||
|
||||
> QQ Bot uses **QQ Open Platform WebSocket** (official `botgo` SDK) for C2C and group @—**no public callback URL needed**.
|
||||
|
||||
1. Create a bot at [q.qq.com](https://q.qq.com) → get **App ID** and **Client Secret**.
|
||||
2. Add sandbox testers before going live.
|
||||
3. Subscribe to C2C and group @ events (WebSocket).
|
||||
4. Fill in CyberStrikeAI; use **Sandbox** for testing → **Apply configuration**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bot commands
|
||||
|
||||
Send these **text commands** to the bot in DingTalk or Lark (text only):
|
||||
Send these **text commands** to the bot on any connected platform (text only):
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
@@ -172,15 +296,24 @@ Any other text is sent to the AI as a user message, same as in the web UI (e.g.
|
||||
|
||||
## 5. How to use (do I need to @ the bot?)
|
||||
|
||||
- **Direct chat (recommended)**: In DingTalk or Lark, **search for the bot and open a direct chat**. Type “帮助” or any message; **no @ needed**.
|
||||
- **Group chat**: If the bot is in a group, only messages that **@ the bot** are received and answered; other group messages are ignored.
|
||||
- **Personal WeChat**: Send directly in the **private chat** with the bot; **no @ needed** (group chat not supported).
|
||||
- **DingTalk / Lark direct chat (recommended)**: **Search for the bot and open a direct chat**. Type “帮助” or any message; **no @ needed**.
|
||||
- **DingTalk / Lark group chat**: If the bot is in a group, only messages that **@ the bot** are received and answered; other group messages are ignored.
|
||||
|
||||
Summary: **Direct chat** — just send; **in a group** — @ the bot first, then send.
|
||||
Summary: **Personal WeChat and direct chat**—just send; **DingTalk/Lark in a group**—@ the bot first, then send.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended flow (so you don’t skip steps)
|
||||
|
||||
**Personal WeChat (simplest—no open platform)**
|
||||
|
||||
1. CyberStrikeAI web UI → System settings → Robot settings → **WeChat / iLink** → **Generate QR code and bind**.
|
||||
2. Scan with WeChat and confirm (enter pairing code on the web page if prompted).
|
||||
3. After binding, send “帮助” in the WeChat private chat to test.
|
||||
|
||||
**DingTalk / Lark**
|
||||
|
||||
1. **In the open platform**: Complete app creation, copy credentials, enable the bot (DingTalk: **Stream mode**), set permissions, and publish (Section 3).
|
||||
2. **In CyberStrikeAI**: System settings → Robot settings → Enable the platform, paste Client ID/App ID and Client Secret/App Secret → **Apply configuration**.
|
||||
3. **Restart the CyberStrikeAI process** (otherwise the long-lived connection is not established).
|
||||
@@ -196,6 +329,14 @@ Example `robots` section in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
robots:
|
||||
wechat: # Personal WeChat iLink (auto-filled after QR bind; usually no manual edit)
|
||||
enabled: true
|
||||
bot_token: "your_bot_token@im.bot:..."
|
||||
ilink_bot_id: "your_bot_id@im.bot"
|
||||
ilink_user_id: "your_user_id@im.wechat"
|
||||
base_url: "https://ilinkai.weixin.qq.com"
|
||||
bot_type: "3"
|
||||
bot_agent: "CyberStrikeAI/1.0"
|
||||
dingtalk:
|
||||
enabled: true
|
||||
client_id: "your_dingtalk_app_key"
|
||||
@@ -205,9 +346,33 @@ robots:
|
||||
app_id: "your_lark_app_id"
|
||||
app_secret: "your_lark_app_secret"
|
||||
verify_token: ""
|
||||
wecom:
|
||||
enabled: false
|
||||
corp_id: ""
|
||||
agent_id: 0
|
||||
token: ""
|
||||
encoding_aes_key: ""
|
||||
secret: ""
|
||||
telegram:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_group_messages: false
|
||||
slack:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
app_token: ""
|
||||
discord:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_guild_messages: false
|
||||
qq:
|
||||
enabled: false
|
||||
app_id: ""
|
||||
client_secret: ""
|
||||
sandbox: true
|
||||
```
|
||||
|
||||
**Restart the app** after changes; the long-lived connection is created at startup.
|
||||
After changing DingTalk/Lark/WeCom/Telegram/Slack/Discord/QQ settings, **Apply configuration** restarts the corresponding connections. Personal WeChat QR binding saves and restarts automatically.
|
||||
|
||||
---
|
||||
|
||||
@@ -232,7 +397,30 @@ API: `POST /api/robot/test` (requires login). Body: `{"platform":"optional","use
|
||||
|
||||
---
|
||||
|
||||
## 9. DingTalk: no response when sending messages
|
||||
## 9. Troubleshooting: no response when sending messages
|
||||
|
||||
### 9.1 Personal WeChat
|
||||
|
||||
Check in this order:
|
||||
|
||||
1. **Binding completed?**
|
||||
Robot settings should show “Connected” or a bound Bot ID; `robots.wechat.bot_token` in `config.yaml` must not be empty.
|
||||
|
||||
2. **Enabled?**
|
||||
Confirm “Enable WeChat robot” is checked; restart CyberStrikeAI if you just changed settings.
|
||||
|
||||
3. **Application logs**
|
||||
- On startup: `微信 iLink 长轮询已启动`;
|
||||
- After sending a message: `微信收到消息`; if missing, binding may have failed or `bot_token` is invalid—try **Re-bind**.
|
||||
- `微信 iLink 长轮询异常,将自动重连`: wait for auto-reconnect or restart.
|
||||
|
||||
4. **Network**
|
||||
The server must reach `https://ilinkai.weixin.qq.com` (outbound HTTPS). If QR generation fails, check this first.
|
||||
|
||||
5. **After sleep or network drop**
|
||||
Same as DingTalk/Lark: **auto-reconnect** in ~5–60 s; restart if still no response.
|
||||
|
||||
### 9.2 DingTalk
|
||||
|
||||
Check in this order:
|
||||
|
||||
@@ -257,8 +445,10 @@ Check in this order:
|
||||
|
||||
## 10. Common pitfalls
|
||||
|
||||
- **Personal WeChat vs WeCom**: Personal WeChat uses `robots.wechat` + web QR bind; WeCom uses `robots.wecom` + admin callback URL—they are completely different.
|
||||
- **WeChat QR expired**: QR codes last ~5 minutes; regenerate instead of reusing an old one.
|
||||
- **Wrong bot type**: The “Custom” bot added in a DingTalk **group** (Webhook + sign secret) **cannot** be used for two-way chat. Only the **enterprise internal app** bot from the open platform is supported.
|
||||
- **Saved but not restarted**: After changing robot settings in CyberStrikeAI you **must restart** the app, or the long-lived connection will not be established.
|
||||
- **Saved but not restarted**: After changing DingTalk/Lark robot settings you **must restart** the app (WeChat QR bind restarts the connection automatically).
|
||||
- **Client ID typo**: If the platform shows `504`, use `504` (not `5o4`); prefer copy/paste.
|
||||
- **DingTalk: only HTTP callback, no Stream**: This app receives messages via **Stream**. In the open platform, message reception must be **Stream mode**.
|
||||
- **App not published**: After changing the bot or permissions in the open platform, **publish a new version** under “Version management and release”, or changes won’t apply.
|
||||
@@ -267,6 +457,7 @@ Check in this order:
|
||||
|
||||
## 11. Notes
|
||||
|
||||
- DingTalk and Lark: **text messages only**; other types (e.g. image, voice) are not supported and may be ignored.
|
||||
- All platforms: **text messages only**; other types (e.g. image, voice) are not supported and may be ignored.
|
||||
- Personal WeChat: **private chat only**—group @-bot is not supported.
|
||||
- Conversations are shared with the web UI: conversations created from the bot appear in the web “Conversations” list and vice versa.
|
||||
- Bot execution uses the same **Eino single/multi-agent** path as the web UI (`ProcessMessageForRobot`, with progress callbacks and process details stored in the DB); only the final reply is sent back to DingTalk/Lark in one message (no SSE). Default: `robot_default_agent_mode: eino_single`.
|
||||
- Bot execution uses the same **Eino single/multi-agent** path as the web UI (`ProcessMessageForRobot`, with progress callbacks and process details stored in the DB); only the final reply is sent back to personal WeChat/DingTalk/Lark/WeCom in one message (no SSE). Default: `robot_default_agent_mode: eino_single`.
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
# CyberStrikeAI 图编排使用说明
|
||||
|
||||
[English](workflow-graph_en.md)
|
||||
|
||||
本文档说明 **图编排(Graph Orchestration)** 的完整使用方式:如何在画布上搭建流程、配置各类型节点、在节点之间传递数据,以及如何将流程绑定到角色并自动运行。
|
||||
|
||||
---
|
||||
|
||||
## 一、在哪里使用图编排
|
||||
|
||||
1. 登录 CyberStrikeAI Web 端
|
||||
2. 左侧导航进入 **图编排**
|
||||
3. 在左侧列表选择已有流程,或新建流程
|
||||
4. 在中央画布拖拽、连线、配置节点
|
||||
5. 填写流程 **ID**、**名称**、**描述** 后点击 **保存**
|
||||
|
||||
保存后的流程可在 **角色管理** 中绑定到某个角色。绑定后,用户与该角色对话时会按流程图自动执行(`workflow_policy: auto`)。
|
||||
|
||||
---
|
||||
|
||||
## 二、画布基本操作
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| 添加节点 | 点击画布上方节点类型按钮(开始、工具、Agent、条件、审批、输出、结束) |
|
||||
| 连线 | 点击 **连线**,依次点击源节点和目标节点;再次点击 **连线** 退出连线模式 |
|
||||
| 选中元素 | 单击节点或连线,右侧显示 **节点属性** |
|
||||
| 删除选中 | 点击 **删除选中** 删除当前节点或连线 |
|
||||
| 自动布局 | 点击 **自动布局** 整理节点位置 |
|
||||
| 试运行 | 点击 **试运行** 使用安全 dry-run 验证数据流;工具、Agent、审批不会真实执行 |
|
||||
| 删除流程 | 点击 **删除** 删除整个流程定义 |
|
||||
|
||||
**硬性规则:** 每个流程至少包含 **1 个开始节点** 和 **1 个输出节点**;开始节点不能有入边,输出 / 结束节点不能有出边。保存时前端和后端都会执行严格校验。
|
||||
|
||||
---
|
||||
|
||||
## 三、执行模型(先理解再配置)
|
||||
|
||||
图编排按 **有向图** 执行,引擎从 **开始** 节点出发,沿连线依次运行下游节点。
|
||||
|
||||
每次运行会维护一份内部状态,模板变量 `{{...}}` 从这里取值:
|
||||
|
||||
| 内部状态 | 模板前缀 | 含义 |
|
||||
|----------|----------|------|
|
||||
| `inputs` | `{{inputs.xxx}}` | 流程启动时的输入(用户消息、会话 ID 等) |
|
||||
| `lastOutput` | `{{previous.xxx}}` | **上一个刚执行完** 的节点的输出 |
|
||||
| `outputs` | `{{outputs.xxx}}` | 全局 **命名变量池**(由节点的「输出变量名」写入) |
|
||||
| `nodeOutputs` | `{{节点ID.xxx}}` | 指定节点 ID 的完整输出对象 |
|
||||
| `metrics` | 运行详情中查看 | 节点耗时、工具调用数、可收集到的 token / cost 等指标 |
|
||||
|
||||
### 3.1 `previous` 是什么?
|
||||
|
||||
`{{previous.output}}` 表示 **紧邻的上一个执行节点** 的 `output` 字段。
|
||||
|
||||
- 每执行完一个节点,引擎都会更新 `lastOutput`
|
||||
- **不是**「画布上画线的上游」,而是 **实际执行顺序上的上一步**
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
开始 → Agent A → Agent B
|
||||
```
|
||||
|
||||
Agent B 的 `{{previous.output}}` = Agent A 的输出。
|
||||
|
||||
但若中间有条件节点:
|
||||
|
||||
```text
|
||||
开始 → Agent A → 条件 → Agent B
|
||||
```
|
||||
|
||||
Agent B 的 `{{previous.output}}` = **条件节点** 的输出(`true` / `false`),**不是** Agent A 的结果。
|
||||
|
||||
如果一个节点有 **多个上游节点** 同时连入,`previous` 会先按该节点的 **汇聚策略** 生成:
|
||||
|
||||
| 汇聚策略 | 含义 | 适合场景 |
|
||||
|----------|------|----------|
|
||||
| `all_merge` | 合并所有上游输出,`previous.output` 为数组 | 默认推荐,综合多路结果 |
|
||||
| `last_by_canvas` | 按画布顺序取最后一个上游输出 | 明确只采用一路结果 |
|
||||
| `first_non_empty` | 取第一个非空输出 | 多路兜底 |
|
||||
| `fail_fast` | 任一上游失败则中止当前节点 | 关键链路、审批前置、安全检查 |
|
||||
|
||||
### 3.2 `outputs` 是什么?
|
||||
|
||||
`outputs` 是引擎在运行过程中维护的 **命名变量注册表**。
|
||||
|
||||
当 Agent、工具、输出 等节点配置了 **输出变量名**(字段 `output_key`)后,节点执行成功会把结果写入:
|
||||
|
||||
```text
|
||||
outputs["你填的变量名"] = 节点输出内容
|
||||
```
|
||||
|
||||
之后 **任意下游节点** 都可以通过 `{{outputs.变量名}}` 引用,不要求两个节点直接相连。
|
||||
|
||||
示例:
|
||||
|
||||
- Agent A 的 **输出变量名** 填 `agent_result1`
|
||||
- Agent B 的 **输入来源** 填 `{{outputs.agent_result1}}`
|
||||
|
||||
即使 A 和 B 之间隔着条件节点,B 仍能拿到 A 的输出。
|
||||
|
||||
### 3.3 什么时候用 `previous`,什么时候用 `outputs`?
|
||||
|
||||
| 场景 | 推荐写法 |
|
||||
|------|----------|
|
||||
| 两个节点 **直连**,只取上一步结果 | `{{previous.output}}` |
|
||||
| 中间有其他节点(条件、工具、审批等) | `{{outputs.变量名}}` |
|
||||
| 需要引用 **更早** 的某个节点结果 | `{{outputs.变量名}}` 或 `{{节点ID.output}}` |
|
||||
| 条件判断要基于某 Agent 的输出 | `{{outputs.变量名}} != ""` |
|
||||
| 读取用户最初输入 | `{{inputs.message}}` |
|
||||
|
||||
**记忆口诀:**
|
||||
|
||||
- `previous` = 上一步(链式、紧邻)
|
||||
- `outputs` = 按名字取(跨节点、可回溯)
|
||||
|
||||
---
|
||||
|
||||
## 四、模板语法
|
||||
|
||||
### 4.1 基本格式
|
||||
|
||||
```text
|
||||
{{变量路径}}
|
||||
```
|
||||
|
||||
支持字母、数字、下划线、点、连字符,例如:
|
||||
|
||||
```text
|
||||
{{previous.output}}
|
||||
{{outputs.agent_result1}}
|
||||
{{inputs.message}}
|
||||
{{inputs.conversationId}}
|
||||
{{previous.matched}}
|
||||
{{node-abc123.output}}
|
||||
```
|
||||
|
||||
### 4.2 可用路径一览
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `{{inputs.message}}` | 用户消息(开始节点输入) |
|
||||
| `{{inputs.conversationId}}` | 会话 ID |
|
||||
| `{{inputs.projectId}}` | 项目 ID |
|
||||
| `{{previous.output}}` | 上一节点主输出 |
|
||||
| `{{previous.matched}}` | 上一条件节点的匹配结果(`true` / `false`) |
|
||||
| `{{outputs.变量名}}` | 某节点注册过的命名输出 |
|
||||
| `{{节点ID.output}}` | 指定节点 ID 的 `output` 字段 |
|
||||
| `{{previous.kind}}` | 上一节点输出类型,如 `agent` / `tool` / `condition` |
|
||||
| `{{previous.status}}` | 上一节点状态,如 `completed` / `failed` / `simulated` |
|
||||
|
||||
节点输出会保留兼容字段(如 `output`、`matched`),同时带有结构化字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "agent",
|
||||
"node_id": "node-2",
|
||||
"node_type": "agent",
|
||||
"status": "completed",
|
||||
"output": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 条件表达式
|
||||
|
||||
条件节点和连线条件支持比较、文本匹配、正则、逻辑组合与安全 JSONPath/JQ 路径读取:
|
||||
|
||||
```text
|
||||
{{outputs.agent_result1}} != ""
|
||||
{{previous.output}} == "ok"
|
||||
{{outputs.count}} >= 100
|
||||
{{previous.output}} contains "success"
|
||||
{{previous.output}} matches "^ok"
|
||||
{{outputs.risk_score}} >= 8 && {{previous.output}} != ""
|
||||
jsonpath({{previous.output}}, "$.status") == "ok"
|
||||
jq({{outputs.scan}}, ".severity") == "high"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 支持 `==`、`!=`、`>`、`>=`、`<`、`<=`
|
||||
- 支持 `contains` 子串匹配与 `matches` 正则匹配
|
||||
- 支持简单 `&&` / `||`
|
||||
- 支持 `jsonpath(value, "$.path")` 与 `jq(value, ".path")` 的**安全路径子集**,仅做字段读取,不执行任意脚本
|
||||
- 比较两侧会自动去掉首尾空格和引号
|
||||
- 无比较符时,非空且不为 `false` / `0` / `null` 视为真
|
||||
- 保存时会静态校验表达式格式、JSONPath/JQ 路径和正则语法
|
||||
|
||||
### 4.4 嵌套字段绑定
|
||||
|
||||
节点的字段绑定除 `output`、`message` 等普通字段外,也支持 JSONPath/JQ 风格路径:
|
||||
|
||||
| 绑定配置 | 含义 |
|
||||
|----------|------|
|
||||
| `from=previous, field=$.status` | 从上一节点输出对象读取 `status` |
|
||||
| `from=outputs, field=$.scan.severity` | 从命名输出中读取嵌套字段 |
|
||||
| `from=node-1, field=.output.items[0]` | 从指定节点输出读取数组元素 |
|
||||
|
||||
---
|
||||
|
||||
## 五、节点类型与配置
|
||||
|
||||
### 5.1 开始(start)
|
||||
|
||||
流程入口,将用户输入注入 `inputs`。
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 输入变量 | 逗号分隔的输入键名 | `message, conversationId, projectId` |
|
||||
|
||||
开始节点输出包含:`output`、`message`、`conversationId`、`projectId`。
|
||||
|
||||
### 5.2 Agent(agent)
|
||||
|
||||
调用大模型 Agent 处理任务,支持多种运行模式。
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| Agent 模式 | `eino_single` / `deep` / `plan_execute` / `supervisor` | `eino_single` |
|
||||
| 输入来源 | 上游数据的模板表达式 | `{{previous.output}}` |
|
||||
| 节点指令 | 本节点要完成的任务描述 | 空 |
|
||||
| 输出变量名 | 写入 `outputs` 的键名 | `agent_result` |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
**消息拼装规则:**
|
||||
|
||||
- 仅填 **节点指令**:直接把指令发给 Agent
|
||||
- 仅填 **输入来源**:生成「请基于上游节点输出继续处理:…」
|
||||
- 两者都填:合并为「上游输入 + 节点指令」
|
||||
|
||||
Agent 节点执行后:
|
||||
|
||||
- `previous.output` 更新为本节点响应文本
|
||||
- 若配置了 **输出变量名**,同时写入 `outputs[输出变量名]`
|
||||
- Agent 子图在 Eino 中拆为 `prepare → execute → finalize`,便于 trace 与后续局部 checkpoint
|
||||
|
||||
### 5.3 工具(tool)
|
||||
|
||||
调用已启用的 MCP 工具。
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| MCP 工具 | 工具名称(必填) | — |
|
||||
| 参数模板 | JSON,支持 `{{...}}` 模板 | `{}` |
|
||||
| 超时秒数 | 可选 | 空 |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
示例参数模板:
|
||||
|
||||
```json
|
||||
{"target": "{{inputs.message}}", "port": "443"}
|
||||
```
|
||||
|
||||
若配置了 **输出变量名**,工具返回结果会写入 `outputs`。
|
||||
|
||||
### 5.4 条件(condition)
|
||||
|
||||
根据表达式计算分支,输出 `matched`(`true` / `false`)。
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 条件表达式 | 支持 `{{...}}` 与 `==` / `!=` | `{{previous.output}} != ""` |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
**分支规则:**
|
||||
|
||||
- 从条件节点连出的 **第一条线** 默认为 **「是」** 分支(`matched == true`)
|
||||
- **第二条线** 默认为 **「否」** 分支(`matched == false`)
|
||||
- 连线标签可写 `是` / `否`(或 `yes` / `no`、`true` / `false`)辅助识别
|
||||
- 第三条及以后的出边需在 **连线条件** 中自定义表达式
|
||||
|
||||
连线条件示例(选中连线后在右侧配置):
|
||||
|
||||
```text
|
||||
{{previous.matched}} == "true"
|
||||
{{previous.matched}} == "false"
|
||||
```
|
||||
|
||||
### 5.5 审批(hitl)
|
||||
|
||||
人工确认检查点。流程运行到该节点前会通过 Eino interrupt/checkpoint 暂停,等待 API 或监控面板审批后恢复。
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 审批提示 | 支持模板 | `请审批该步骤是否继续执行` |
|
||||
| 提示字段绑定 | 留空审批提示时,从绑定字段读取说明 | `previous.output` |
|
||||
| 审批方 | `human` / `audit_agent` | `human` |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
HITL 等待信息会记录:
|
||||
|
||||
- `checkpointId`
|
||||
- interrupt `beforeNodes`
|
||||
- resume target / address / path
|
||||
- resume payload schema(`approved`、`comment`)
|
||||
|
||||
### 5.6 输出(output)
|
||||
|
||||
将流程最终结果写入 `outputs`,供结束摘要和对话展示使用。
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 输出变量名 | 必填,最终结果的键名 | `result` |
|
||||
| 变量来源 | 模板表达式,决定写入的值 | `{{previous.output}}` |
|
||||
| 固定输出值 | 可选,填写后覆盖变量来源 | 空 |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
**注意:** 输出节点是流程的「出口」,不应再有出边。
|
||||
|
||||
### 5.7 结束(end)
|
||||
|
||||
可选节点,用于生成结束摘要模板(角色绑定流程中较少单独使用)。
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 结束摘要模板 | 支持 `{{outputs.xxx}}` | `{{outputs.result}}` |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
---
|
||||
|
||||
## 六、连线配置
|
||||
|
||||
选中 **连线** 后,右侧可配置 **连线条件**。
|
||||
|
||||
| 场景 | 示例 |
|
||||
|------|------|
|
||||
| 普通节点后的过滤 | `{{previous.output}} == "ok"` |
|
||||
| 条件节点「是」分支 | `{{previous.matched}} == "true"` |
|
||||
| 条件节点「否」分支 | `{{previous.matched}} == "false"` |
|
||||
|
||||
若不填连线条件:
|
||||
|
||||
- 非条件节点:连线始终放行
|
||||
- 条件节点:按出边顺序自动分配是/否分支
|
||||
|
||||
---
|
||||
|
||||
## 七、完整示例:跨条件节点传递 Agent 输出
|
||||
|
||||
### 7.1 流程结构
|
||||
|
||||
```text
|
||||
开始 → Agent(生成初始值)→ 条件 → Agent(加工)→ 输出
|
||||
↘ 否 → 输出
|
||||
```
|
||||
|
||||
### 7.2 节点配置
|
||||
|
||||
**Agent 1(第一个 Agent)**
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 节点指令 | 只输出 `123333333` |
|
||||
| 输出变量名 | `agent_result1` |
|
||||
|
||||
**条件**
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 条件表达式 | `{{outputs.agent_result1}} != ""` |
|
||||
|
||||
**Agent 2(第二个 Agent)**
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 输入来源 | `{{outputs.agent_result1}}` |
|
||||
| 节点指令 | 在输入基础上加 100,然后输出 |
|
||||
| 输出变量名 | `agent_result` |
|
||||
|
||||
**输出**
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 输出变量名 | `result` |
|
||||
| 变量来源 | `{{outputs.agent_result}}` |
|
||||
|
||||
### 7.3 常见错误
|
||||
|
||||
| 错误配置 | 原因 |
|
||||
|----------|------|
|
||||
| Agent 2 输入来源写 `{{previous.output}}` | `previous` 指向条件节点,得到的是 `true`/`false`,不是 Agent 1 的文本 |
|
||||
| 未给 Agent 1 填输出变量名 | `outputs.agent_result1` 不存在,下游取到空值 |
|
||||
| 条件表达式写 `{{previous.output}}` | 判断的是开始节点或上一节点的输出,而非 Agent 1 的命名变量 |
|
||||
|
||||
---
|
||||
|
||||
## 八、绑定角色并运行
|
||||
|
||||
### 8.1 在角色管理中绑定
|
||||
|
||||
1. 进入 **角色管理**,编辑或新建角色
|
||||
2. 选择 **工作流 / 图编排** 绑定的流程 ID
|
||||
3. 策略设为 `auto`(默认:有 `workflow_id` 时自动执行)
|
||||
4. 保存角色
|
||||
|
||||
也可在角色 YAML 中直接配置:
|
||||
|
||||
```yaml
|
||||
name: 工作流测试
|
||||
workflow_id: "1233"
|
||||
workflow_version: latest
|
||||
workflow_policy: auto
|
||||
```
|
||||
|
||||
### 8.2 运行效果
|
||||
|
||||
用户选择该角色并发送消息后:
|
||||
|
||||
1. 引擎加载对应 `graph_json` 并按图执行
|
||||
2. 对话页可看到 `workflow_start`、`workflow_node_start`、Agent 推理等进度事件
|
||||
3. 流程结束后返回摘要,列出 `outputs` 中所有命名输出
|
||||
|
||||
若未配置输出节点或条件未命中,`outputs` 可能为空,摘要会提示检查输出节点与分支。
|
||||
|
||||
---
|
||||
|
||||
## 九、调试、试运行与复盘
|
||||
|
||||
### 9.1 安全试运行(dry-run)
|
||||
|
||||
画布工具栏点击 **试运行**,输入一条测试消息即可模拟执行流程。
|
||||
|
||||
dry-run 的安全边界:
|
||||
|
||||
- `start` / `condition` / `output` / `end` 会按真实逻辑计算
|
||||
- `tool` 不会真实调用 MCP,只返回 `[dry-run] tool call skipped`
|
||||
- `agent` 不会真实调用模型,只返回 `[dry-run] agent execution skipped`
|
||||
- `hitl` 不会暂停,只模拟通过
|
||||
|
||||
相关 API:
|
||||
|
||||
```http
|
||||
POST /api/workflows/dry-run
|
||||
```
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"graph": { "nodes": [], "edges": [], "config": {} },
|
||||
"inputs": { "message": "ping" }
|
||||
}
|
||||
```
|
||||
|
||||
响应包含:
|
||||
|
||||
- `outputs`
|
||||
- `nodeOutputs`
|
||||
- `trace`
|
||||
- `metrics`
|
||||
- `replayScript`
|
||||
|
||||
### 9.2 运行详情与 replay
|
||||
|
||||
运行后可查询完整节点执行轨迹:
|
||||
|
||||
```http
|
||||
GET /api/workflows/runs/{runId}
|
||||
```
|
||||
|
||||
返回 `run` 与 `nodeRuns`,每个节点记录包含:
|
||||
|
||||
- input 快照
|
||||
- output 快照
|
||||
- status / error
|
||||
- started_at / finished_at
|
||||
- `duration_ms`
|
||||
|
||||
复盘接口:
|
||||
|
||||
```http
|
||||
GET /api/workflows/runs/{runId}/replay
|
||||
```
|
||||
|
||||
该接口只根据已保存的 `nodeRuns` 生成步骤,不会重新执行工具或 Agent。
|
||||
|
||||
### 9.3 指标(metrics)
|
||||
|
||||
工作流会尽量累计:
|
||||
|
||||
- `node_count`
|
||||
- `duration_ms`
|
||||
- `tool_call_count`
|
||||
- Agent progress 中可收集到的 `prompt_tokens` / `completion_tokens` / `total_tokens` / `cost`
|
||||
|
||||
token 与成本是否存在取决于底层模型/Agent 事件是否上报 usage。
|
||||
|
||||
---
|
||||
|
||||
## 十、保存前校验规则
|
||||
|
||||
保存时系统会自动检查:
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 必须有开始节点 | 至少 1 个 `start` |
|
||||
| 必须有输出节点 | 至少 1 个 `output`,且填写输出变量名 |
|
||||
| 连线合法 | 源/目标节点存在,不能自环 |
|
||||
| 开始节点无入边 | 开始节点不能被指向 |
|
||||
| 输出 / 结束节点无出边 | 输出 / 结束节点后不应再连线 |
|
||||
| 非开始节点必须有入边 | 避免孤岛节点 |
|
||||
| 非输出 / 结束节点必须有出边 | 避免执行到死路 |
|
||||
| 无环路 | Workflow 编排必须是 DAG |
|
||||
| 可达性 | 所有节点必须能从开始节点到达,并能最终到达 output/end |
|
||||
| 工具节点 | 必须选择 MCP 工具;参数 JSON 必须合法;超时必须为正整数 |
|
||||
| Agent 节点 | 必须填写节点指令或输入绑定;必须填写输出变量名 |
|
||||
| 条件节点 | 必须填写表达式;需要 1~2 条出边;分支必须标记是/否且不能重复 |
|
||||
| 连线条件 | 表达式、正则、JSONPath/JQ 路径必须通过静态校验 |
|
||||
| 汇聚策略 | 必须是 `all_merge` / `last_by_canvas` / `first_non_empty` / `fail_fast` |
|
||||
|
||||
---
|
||||
|
||||
## 十一、排错指南
|
||||
|
||||
| 现象 | 可能原因 | 处理建议 |
|
||||
|------|----------|----------|
|
||||
| 下游拿到空值 | 上游未配置输出变量名 | 给上游 Agent/工具填 **输出变量名**,下游用 `{{outputs.xxx}}` |
|
||||
| 下游拿到 `true`/`false` | 误用 `{{previous.output}}`,上一步是条件节点 | 改用 `{{outputs.xxx}}` |
|
||||
| 条件总走「否」 | 表达式与真实输出格式不一致 | 检查 Agent 输出是否带引号、换行;用 `!= ""` 先验证 |
|
||||
| 流程无最终输出 | 未命中输出节点所在分支 | 检查条件分支连线;确保至少一条路径到达 **输出** 节点 |
|
||||
| 角色对话未跑流程 | 角色未绑定或未启用 | 确认 `workflow_id`、`workflow_policy: auto`、流程 `enabled: true` |
|
||||
| 工具节点失败 | 参数 JSON 不合法或工具未启用 | 检查参数模板;在 MCP 中启用对应工具 |
|
||||
| 保存失败提示分支非法 | 条件节点出边未标记是/否或重复 | 选中连线,设置条件分支为 `true` 或 `false` |
|
||||
| 多上游结果不符合预期 | 汇聚策略不合适 | 根据场景改为 `all_merge` / `first_non_empty` / `last_by_canvas` / `fail_fast` |
|
||||
| 嵌套字段取不到 | JSONPath/JQ 路径不符合安全子集 | 使用 `$.a.b[0]` 或 `.a.b[0]`,不要用通配符/递归/表达式 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、最佳实践
|
||||
|
||||
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result`、`parsed_targets`,避免都叫 `agent_result`。
|
||||
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
|
||||
3. **`previous` 仅用于直连**:A → B 且无中间节点时,`{{previous.output}}` 最简洁。
|
||||
4. **条件判断引用源数据**:判断 Agent 输出时用 `{{outputs.xxx}}`,不要用 `{{previous.output}}`(除非条件紧跟在目标 Agent 之后)。
|
||||
5. **每条路径都要有出口**:确保「是」「否」分支最终都能到达 **输出** 节点(或你期望的终点)。
|
||||
6. **多上游节点显式选择汇聚策略**:综合结果用 `all_merge`,兜底用 `first_non_empty`,关键链路用 `fail_fast`。
|
||||
7. **嵌套 JSON 用 JSONPath/JQ 安全路径**:例如 `jsonpath({{previous.output}}, "$.status") == "ok"`。
|
||||
8. **保存前先 dry-run**:用简单消息验证数据传递和分支,再绑定角色真实执行。
|
||||
|
||||
---
|
||||
|
||||
## 十三、相关代码位置(开发者参考)
|
||||
|
||||
| 模块 | 路径 |
|
||||
|------|------|
|
||||
| 执行引擎 | `internal/workflow/runner.go` |
|
||||
| Eino 编译 / checkpoint / HITL | `internal/workflow/eino_compile.go` |
|
||||
| 图校验 | `internal/workflow/validation.go` |
|
||||
| 表达式 / JSONPath / 汇聚 | `internal/workflow/expression.go`、`jsonpath.go`、`join.go` |
|
||||
| dry-run / replay 数据 | `internal/workflow/dry_run.go`、`internal/handler/workflow_run.go` |
|
||||
| 画布前端 | `web/static/js/workflows.js` |
|
||||
| 流程 API | `internal/handler/workflow.go` |
|
||||
| 角色绑定 | `internal/config/config.go`(`workflow_id` 字段) |
|
||||
@@ -0,0 +1,553 @@
|
||||
# CyberStrikeAI Graph Orchestration Guide
|
||||
|
||||
[中文](workflow-graph.md)
|
||||
|
||||
This document explains how to use **Graph Orchestration**: building workflows on the canvas, configuring node types, passing data between nodes, and binding a graph to a role for automatic execution.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where to find Graph Orchestration
|
||||
|
||||
1. Log in to the CyberStrikeAI web UI.
|
||||
2. Open **Graph Orchestration** in the left sidebar.
|
||||
3. Select an existing workflow from the list, or create a new one.
|
||||
4. Drag nodes, draw edges, and configure properties on the canvas.
|
||||
5. Fill in **ID**, **Name**, and **Description**, then click **Save**.
|
||||
|
||||
Saved workflows can be bound to a role under **Role Management**. When `workflow_policy` is `auto`, chatting with that role runs the bound graph automatically.
|
||||
|
||||
---
|
||||
|
||||
## 2. Canvas basics
|
||||
|
||||
| Action | Description |
|
||||
|--------|-------------|
|
||||
| Add node | Click a node type button above the canvas (Start, Tool, Agent, Condition, HITL, Output, End) |
|
||||
| Connect | Click **Connect**, then click source and target nodes; click **Connect** again to exit connect mode |
|
||||
| Select | Click a node or edge; properties appear in the right panel |
|
||||
| Delete selected | Remove the current node or edge |
|
||||
| Auto layout | Rearrange node positions |
|
||||
| Dry run | Safely simulate data flow; Tool, Agent, and HITL nodes are not executed for real |
|
||||
| Delete workflow | Remove the entire workflow definition |
|
||||
|
||||
**Hard requirements:** Every workflow needs at least **one Start node** and **one Output node**. Start nodes must not have incoming edges; Output / End nodes must not have outgoing edges. Both frontend and backend run strict validation before save.
|
||||
|
||||
---
|
||||
|
||||
## 3. 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.
|
||||
|
||||
During a run, the engine keeps internal state. Template expressions `{{...}}` read from that state:
|
||||
|
||||
| Internal state | Template prefix | Meaning |
|
||||
|----------------|-----------------|---------|
|
||||
| `inputs` | `{{inputs.xxx}}` | Workflow inputs at start (user message, conversation ID, etc.) |
|
||||
| `lastOutput` | `{{previous.xxx}}` | Output of the **most recently executed** node |
|
||||
| `outputs` | `{{outputs.xxx}}` | Global **named variable pool** (written by nodes with an output key) |
|
||||
| `nodeOutputs` | `{{nodeId.xxx}}` | Full output object of a specific node ID |
|
||||
| `metrics` | available in run details | Node duration, tool call count, and usage/cost metrics when reported |
|
||||
|
||||
### 3.1 What is `previous`?
|
||||
|
||||
`{{previous.output}}` is the `output` field of the **immediately preceding executed node**.
|
||||
|
||||
- After every node finishes, the engine updates `lastOutput`.
|
||||
- It is **not** “the node drawn upstream on the canvas”; it is **the previous step in actual execution order**.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Start → Agent A → Agent B
|
||||
```
|
||||
|
||||
For Agent B, `{{previous.output}}` = Agent A’s output.
|
||||
|
||||
With a condition in between:
|
||||
|
||||
```text
|
||||
Start → Agent A → Condition → Agent B
|
||||
```
|
||||
|
||||
For Agent B, `{{previous.output}}` = the **condition node** output (`true` / `false`), **not** Agent A’s result.
|
||||
|
||||
If a node has **multiple upstream nodes**, `previous` is built by that node’s **join strategy** first:
|
||||
|
||||
| Join strategy | Meaning | Use case |
|
||||
|---------------|---------|----------|
|
||||
| `all_merge` | Merge all upstream outputs; `previous.output` is an array | Default; aggregate multiple results |
|
||||
| `last_by_canvas` | Use the last upstream output by canvas order | Explicitly use one branch |
|
||||
| `first_non_empty` | Use the first non-empty output | Fallback chains |
|
||||
| `fail_fast` | Stop the node if any upstream failed | Critical gates, approval prechecks, safety checks |
|
||||
|
||||
### 3.2 What is `outputs`?
|
||||
|
||||
`outputs` is a **named variable registry** maintained by the engine during execution.
|
||||
|
||||
When an Agent, Tool, or Output node sets an **Output variable name** (`output_key`), the result is stored as:
|
||||
|
||||
```text
|
||||
outputs["your_variable_name"] = node_output
|
||||
```
|
||||
|
||||
Any downstream node can then reference it via `{{outputs.variable_name}}`, even if other nodes sit in between.
|
||||
|
||||
Example:
|
||||
|
||||
- Agent A **Output variable name**: `agent_result1`
|
||||
- Agent B **Input source**: `{{outputs.agent_result1}}`
|
||||
|
||||
Agent B still receives Agent A’s output even when a condition node lies between them.
|
||||
|
||||
### 3.3 When to use `previous` vs `outputs`
|
||||
|
||||
| Scenario | Recommended |
|
||||
|----------|-------------|
|
||||
| Two nodes are **directly connected**; you only need the last step | `{{previous.output}}` |
|
||||
| Other nodes sit in between (condition, tool, HITL, etc.) | `{{outputs.variable_name}}` |
|
||||
| Reference output from an **earlier** node | `{{outputs.variable_name}}` or `{{nodeId.output}}` |
|
||||
| Condition should test an Agent’s output | `{{outputs.variable_name}} != ""` |
|
||||
| Read the original user input | `{{inputs.message}}` |
|
||||
|
||||
**Rule of thumb:**
|
||||
|
||||
- `previous` = last step (chained, adjacent)
|
||||
- `outputs` = by name (cross-node, look back)
|
||||
|
||||
---
|
||||
|
||||
## 4. Template syntax
|
||||
|
||||
### 4.1 Basic format
|
||||
|
||||
```text
|
||||
{{path.to.value}}
|
||||
```
|
||||
|
||||
Allowed characters in paths: letters, digits, underscore, dot, hyphen. Examples:
|
||||
|
||||
```text
|
||||
{{previous.output}}
|
||||
{{outputs.agent_result1}}
|
||||
{{inputs.message}}
|
||||
{{inputs.conversationId}}
|
||||
{{previous.matched}}
|
||||
{{node-abc123.output}}
|
||||
```
|
||||
|
||||
### 4.2 Available paths
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `{{inputs.message}}` | User message (Start node input) |
|
||||
| `{{inputs.conversationId}}` | Conversation ID |
|
||||
| `{{inputs.projectId}}` | Project ID |
|
||||
| `{{previous.output}}` | Primary output of the previous node |
|
||||
| `{{previous.matched}}` | Match result of the previous condition node (`true` / `false`) |
|
||||
| `{{outputs.variable_name}}` | Named output registered by a node |
|
||||
| `{{nodeId.output}}` | `output` field of the node with that ID |
|
||||
| `{{previous.kind}}` | Previous node output kind, e.g. `agent` / `tool` / `condition` |
|
||||
| `{{previous.status}}` | Previous node status, e.g. `completed` / `failed` / `simulated` |
|
||||
|
||||
Node outputs keep compatibility fields such as `output` and `matched`, and also include a structured envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "agent",
|
||||
"node_id": "node-2",
|
||||
"node_type": "agent",
|
||||
"status": "completed",
|
||||
"output": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Condition expressions
|
||||
|
||||
Condition nodes and edge conditions support comparisons, text matching, regex, logical operators, and safe JSONPath/JQ path reads:
|
||||
|
||||
```text
|
||||
{{outputs.agent_result1}} != ""
|
||||
{{previous.output}} == "ok"
|
||||
{{outputs.count}} >= 100
|
||||
{{previous.output}} contains "success"
|
||||
{{previous.output}} matches "^ok"
|
||||
{{outputs.risk_score}} >= 8 && {{previous.output}} != ""
|
||||
jsonpath({{previous.output}}, "$.status") == "ok"
|
||||
jq({{outputs.scan}}, ".severity") == "high"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Operators: `==`, `!=`, `>`, `>=`, `<`, `<=`
|
||||
- `contains` checks substrings; `matches` checks regular expressions
|
||||
- Simple `&&` / `||` is supported
|
||||
- `jsonpath(value, "$.path")` and `jq(value, ".path")` support a **safe path-only subset**; no arbitrary script execution
|
||||
- Leading/trailing spaces and quotes are trimmed before comparison
|
||||
- Without a comparator, non-empty values that are not `false`, `0`, or `null` are treated as true
|
||||
- Expressions, regexes, and JSONPath/JQ paths are statically validated before save
|
||||
|
||||
### 4.4 Nested field binding
|
||||
|
||||
Field bindings can read ordinary fields such as `output` or `message`, and also JSONPath/JQ-style paths:
|
||||
|
||||
| Binding | Meaning |
|
||||
|---------|---------|
|
||||
| `from=previous, field=$.status` | Read `status` from previous output |
|
||||
| `from=outputs, field=$.scan.severity` | Read a nested field from named outputs |
|
||||
| `from=node-1, field=.output.items[0]` | Read an array element from a specific node output |
|
||||
|
||||
---
|
||||
|
||||
## 5. Node types and configuration
|
||||
|
||||
### 5.1 Start
|
||||
|
||||
Workflow entry point; injects user input into `inputs`.
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Input keys | Comma-separated input key names | `message, conversationId, projectId` |
|
||||
|
||||
Start node output includes: `output`, `message`, `conversationId`, `projectId`.
|
||||
|
||||
### 5.2 Agent
|
||||
|
||||
Runs an LLM Agent task. Supports multiple modes.
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Agent mode | `eino_single` / `deep` / `plan_execute` / `supervisor` | `eino_single` |
|
||||
| Input source | Template for upstream data | `{{previous.output}}` |
|
||||
| Node instruction | Task description for this node | empty |
|
||||
| Output variable name | Key written into `outputs` | `agent_result` |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
**Message assembly:**
|
||||
|
||||
- Instruction only → send instruction to the Agent
|
||||
- Input source only → “Continue based on upstream output: …”
|
||||
- Both → combined “upstream input + node instruction”
|
||||
|
||||
After execution:
|
||||
|
||||
- `previous.output` becomes this node’s response text
|
||||
- If **Output variable name** is set, the value is also stored in `outputs[variable_name]`
|
||||
- In the Eino graph, the Agent node is split into `prepare → execute → finalize` for clearer trace and future checkpointing
|
||||
|
||||
### 5.3 Tool
|
||||
|
||||
Calls an enabled MCP tool.
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| MCP tool | Tool name (required) | — |
|
||||
| Argument template | JSON with `{{...}}` templates | `{}` |
|
||||
| Timeout (seconds) | Optional | empty |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
Example argument template:
|
||||
|
||||
```json
|
||||
{"target": "{{inputs.message}}", "port": "443"}
|
||||
```
|
||||
|
||||
If an output variable name is configured, the tool result is written to `outputs`.
|
||||
|
||||
### 5.4 Condition
|
||||
|
||||
Evaluates an expression and outputs `matched` (`true` / `false`).
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Expression | Supports `{{...}}` and `==` / `!=` | `{{previous.output}} != ""` |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
**Branching rules:**
|
||||
|
||||
- The **first outgoing edge** defaults to the **“yes”** branch (`matched == true`)
|
||||
- The **second outgoing edge** defaults to the **“no”** branch (`matched == false`)
|
||||
- Edge labels such as `是` / `否` (or `yes` / `no`, `true` / `false`) help identify branches
|
||||
- A third or later edge needs a custom **edge condition**
|
||||
|
||||
Edge condition examples (select an edge, configure in the right panel):
|
||||
|
||||
```text
|
||||
{{previous.matched}} == "true"
|
||||
{{previous.matched}} == "false"
|
||||
```
|
||||
|
||||
### 5.5 HITL (human-in-the-loop)
|
||||
|
||||
Human approval checkpoint. The run pauses before this node through Eino interrupt/checkpoint and resumes after approval via API or the monitor panel.
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Prompt | Supports templates | `Please approve before continuing` |
|
||||
| Prompt binding | If prompt text is empty, read approval text from a bound field | `previous.output` |
|
||||
| Reviewer | `human` / `audit_agent` | `human` |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
Pending HITL metadata records:
|
||||
|
||||
- `checkpointId`
|
||||
- interrupt `beforeNodes`
|
||||
- resume target / address / path
|
||||
- resume payload schema (`approved`, `comment`)
|
||||
|
||||
### 5.6 Output
|
||||
|
||||
Writes the final workflow result into `outputs` for summary and chat display.
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Output variable name | Required key for the final result | `result` |
|
||||
| Variable source | Template deciding what to write | `{{previous.output}}` |
|
||||
| Static output value | Optional; overrides variable source when set | empty |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
**Note:** Output nodes are workflow exits and must not have outgoing edges.
|
||||
|
||||
### 5.7 End
|
||||
|
||||
Optional node for an end summary template (less common in role-bound flows).
|
||||
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Result template | Supports `{{outputs.xxx}}` | `{{outputs.result}}` |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge configuration
|
||||
|
||||
Select an **edge** to configure its **condition** in the right panel.
|
||||
|
||||
| Scenario | Example |
|
||||
|----------|---------|
|
||||
| Filter after a normal node | `{{previous.output}} == "ok"` |
|
||||
| “Yes” branch from a condition | `{{previous.matched}} == "true"` |
|
||||
| “No” branch from a condition | `{{previous.matched}} == "false"` |
|
||||
|
||||
If no edge condition is set:
|
||||
|
||||
- Non-condition nodes: edge is always allowed
|
||||
- Condition nodes: yes/no branches are assigned by edge order automatically
|
||||
|
||||
---
|
||||
|
||||
## 7. Full example: passing Agent output across a condition
|
||||
|
||||
### 7.1 Graph structure
|
||||
|
||||
```text
|
||||
Start → Agent (initial value) → Condition → Agent (transform) → Output
|
||||
↘ no → Output
|
||||
```
|
||||
|
||||
### 7.2 Node configuration
|
||||
|
||||
**Agent 1**
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Node instruction | Output only `123333333` |
|
||||
| Output variable name | `agent_result1` |
|
||||
|
||||
**Condition**
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Expression | `{{outputs.agent_result1}} != ""` |
|
||||
|
||||
**Agent 2**
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Input source | `{{outputs.agent_result1}}` |
|
||||
| Node instruction | Add 100 to the input, then output |
|
||||
| Output variable name | `agent_result` |
|
||||
|
||||
**Output**
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Output variable name | `result` |
|
||||
| Variable source | `{{outputs.agent_result}}` |
|
||||
|
||||
### 7.3 Common mistakes
|
||||
|
||||
| Wrong config | Why it fails |
|
||||
|--------------|--------------|
|
||||
| Agent 2 input source = `{{previous.output}}` | `previous` points to the condition node → `true`/`false`, not Agent 1’s text |
|
||||
| Agent 1 has no output variable name | `outputs.agent_result1` does not exist → empty downstream |
|
||||
| Condition uses `{{previous.output}}` | Tests the wrong upstream value instead of Agent 1’s named output |
|
||||
|
||||
---
|
||||
|
||||
## 8. Bind to a role and run
|
||||
|
||||
### 8.1 Bind in Role Management
|
||||
|
||||
1. Open **Role Management**, edit or create a role.
|
||||
2. Select the workflow / graph ID to bind.
|
||||
3. Set policy to `auto` (default when `workflow_id` is set).
|
||||
4. Save the role.
|
||||
|
||||
You can also configure this in role YAML:
|
||||
|
||||
```yaml
|
||||
name: workflow-test
|
||||
workflow_id: "1233"
|
||||
workflow_version: latest
|
||||
workflow_policy: auto
|
||||
```
|
||||
|
||||
### 8.2 Runtime behavior
|
||||
|
||||
When a user chats with that role:
|
||||
|
||||
1. The engine loads `graph_json` and executes the graph.
|
||||
2. The chat UI shows progress events (`workflow_start`, `workflow_node_start`, Agent reasoning, etc.).
|
||||
3. When finished, a summary lists all named entries in `outputs`.
|
||||
|
||||
If no Output node is reached or no branch matches, `outputs` may be empty and the summary will suggest checking the Output node and branches.
|
||||
|
||||
---
|
||||
|
||||
## 9. Debugging, dry-run, and replay
|
||||
|
||||
### 9.1 Safe dry-run
|
||||
|
||||
Click **Dry run** on the canvas toolbar and enter a test message to simulate the workflow.
|
||||
|
||||
Dry-run safety rules:
|
||||
|
||||
- `start` / `condition` / `output` / `end` use real logic
|
||||
- `tool` does not call MCP; it returns `[dry-run] tool call skipped`
|
||||
- `agent` does not call the model; it returns `[dry-run] agent execution skipped`
|
||||
- `hitl` does not pause; it simulates approval
|
||||
|
||||
API:
|
||||
|
||||
```http
|
||||
POST /api/workflows/dry-run
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"graph": { "nodes": [], "edges": [], "config": {} },
|
||||
"inputs": { "message": "ping" }
|
||||
}
|
||||
```
|
||||
|
||||
Response includes:
|
||||
|
||||
- `outputs`
|
||||
- `nodeOutputs`
|
||||
- `trace`
|
||||
- `metrics`
|
||||
- `replayScript`
|
||||
|
||||
### 9.2 Run details and replay
|
||||
|
||||
Query full node execution traces after a run:
|
||||
|
||||
```http
|
||||
GET /api/workflows/runs/{runId}
|
||||
```
|
||||
|
||||
The response contains `run` and `nodeRuns`. Each node run records:
|
||||
|
||||
- input snapshot
|
||||
- output snapshot
|
||||
- status / error
|
||||
- started_at / finished_at
|
||||
- `duration_ms`
|
||||
|
||||
Replay API:
|
||||
|
||||
```http
|
||||
GET /api/workflows/runs/{runId}/replay
|
||||
```
|
||||
|
||||
This generates replay steps from saved `nodeRuns`; it does not re-execute tools or Agents.
|
||||
|
||||
### 9.3 Metrics
|
||||
|
||||
The workflow accumulates, when available:
|
||||
|
||||
- `node_count`
|
||||
- `duration_ms`
|
||||
- `tool_call_count`
|
||||
- Agent progress usage such as `prompt_tokens` / `completion_tokens` / `total_tokens` / `cost`
|
||||
|
||||
Token and cost metrics depend on whether the underlying model/Agent events report usage.
|
||||
|
||||
---
|
||||
|
||||
## 10. Validation before save
|
||||
|
||||
On save, the system checks:
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| Start node required | At least one `start` node |
|
||||
| Output node required | At least one `output` node with an output variable name |
|
||||
| Valid edges | Source and target exist; no self-loops |
|
||||
| Start has no incoming edges | Start must not be targeted |
|
||||
| Output / End has no outgoing edges | Nothing after Output / End |
|
||||
| Non-start nodes must have incoming edges | Prevent orphan nodes |
|
||||
| Non-output/end nodes must have outgoing edges | Prevent dead ends |
|
||||
| No cycles | Workflow orchestration must be a DAG |
|
||||
| Reachability | Every node must be reachable from Start and eventually reach output/end |
|
||||
| Tool nodes | MCP tool required; argument JSON must be valid; timeout must be a positive integer |
|
||||
| Agent nodes | Must have node instruction or input binding; output variable name required |
|
||||
| Condition nodes | Expression required; 1–2 outgoing edges; branches must be yes/no and unique |
|
||||
| Edge conditions | Expressions, regexes, and JSONPath/JQ paths must pass static validation |
|
||||
| Join strategy | Must be `all_merge` / `last_by_canvas` / `first_non_empty` / `fail_fast` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| Downstream gets empty value | Upstream has no output variable name | Set **Output variable name** on upstream; use `{{outputs.xxx}}` downstream |
|
||||
| Downstream gets `true`/`false` | Used `{{previous.output}}` while previous node is a condition | Use `{{outputs.xxx}}` instead |
|
||||
| Condition always takes “no” | Expression does not match actual output format | Check Agent output for quotes/newlines; try `!= ""` first |
|
||||
| No final output | Output node branch not reached | Verify condition wiring; ensure every path reaches an **Output** node |
|
||||
| Role chat does not run workflow | Role not bound or disabled | Check `workflow_id`, `workflow_policy: auto`, workflow `enabled: true` |
|
||||
| Tool node fails | Invalid JSON in arguments or tool disabled | Fix argument template; enable the tool in MCP settings |
|
||||
| Save fails with invalid branch | Condition outgoing edges are not marked yes/no, or are duplicated | Select the edge and set branch to `true` or `false` |
|
||||
| Multi-upstream result is unexpected | Join strategy does not match the workflow | Switch between `all_merge`, `first_non_empty`, `last_by_canvas`, and `fail_fast` |
|
||||
| Nested field is empty | JSONPath/JQ path is outside the safe subset | Use `$.a.b[0]` or `.a.b[0]`; avoid wildcards, recursion, or expressions |
|
||||
|
||||
---
|
||||
|
||||
## 12. Best practices
|
||||
|
||||
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
|
||||
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
|
||||
3. **Use `previous` only for direct links**: `A → B` with nothing in between is the ideal case for `{{previous.output}}`.
|
||||
4. **Conditions should reference source data**: When testing Agent output, use `{{outputs.xxx}}` unless the condition immediately follows that Agent.
|
||||
5. **Every path needs an exit**: Ensure both yes and no branches eventually reach an **Output** node (or your intended end).
|
||||
6. **Choose join strategy explicitly for multi-upstream nodes**: Use `all_merge` for aggregation, `first_non_empty` for fallback, and `fail_fast` for critical gates.
|
||||
7. **Use JSONPath/JQ safe paths for nested JSON**: e.g. `jsonpath({{previous.output}}, "$.status") == "ok"`.
|
||||
8. **Dry-run before real execution**: Validate data flow and branches with a simple message before binding the workflow to a role.
|
||||
|
||||
---
|
||||
|
||||
## 13. Code references (for developers)
|
||||
|
||||
| Module | Path |
|
||||
|--------|------|
|
||||
| Execution engine | `internal/workflow/runner.go` |
|
||||
| Eino compile / checkpoint / HITL | `internal/workflow/eino_compile.go` |
|
||||
| Graph validation | `internal/workflow/validation.go` |
|
||||
| Expressions / JSONPath / joins | `internal/workflow/expression.go`, `jsonpath.go`, `join.go` |
|
||||
| Dry-run / replay data | `internal/workflow/dry_run.go`, `internal/handler/workflow_run.go` |
|
||||
| Canvas UI | `web/static/js/workflows.js` |
|
||||
| Workflow API | `internal/handler/workflow.go` |
|
||||
| Role binding | `internal/config/config.go` (`workflow_id` field) |
|
||||
@@ -3,11 +3,10 @@ module cyberstrike-ai
|
||||
// 若 go mod download 超时,可执行: go env -w GOPROXY=https://goproxy.cn,direct
|
||||
// 或使用 scripts/bootstrap-go.sh
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/bytedance/sonic v1.15.0
|
||||
github.com/cloudwego/eino v0.8.13
|
||||
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2
|
||||
@@ -21,7 +20,7 @@ require (
|
||||
github.com/eino-contrib/jsonschema v1.0.3
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
|
||||
github.com/mattn/go-sqlite3 v1.14.18
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
@@ -29,6 +28,8 @@ require (
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/slack-go/slack v0.27.0
|
||||
github.com/tencent-connect/botgo v0.2.1
|
||||
go.opentelemetry.io/otel v1.34.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0
|
||||
@@ -60,6 +61,7 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-resty/resty/v2 v2.6.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.3.0 // indirect
|
||||
@@ -78,6 +80,9 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||
github.com/tidwall/gjson v1.9.3 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
@@ -93,6 +98,7 @@ require (
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
@@ -9,6 +10,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
|
||||
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
|
||||
@@ -20,6 +23,8 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.8.13 h1:z5dhaZNN8TWZbP/lgKxGmF26Ii8fPeUlQCGV/NTtms0=
|
||||
@@ -38,11 +43,13 @@ github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQr
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||
@@ -54,6 +61,7 @@ github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGj
|
||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
@@ -76,6 +84,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
@@ -84,21 +98,38 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
|
||||
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
|
||||
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
@@ -114,6 +145,8 @@ github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -145,11 +178,19 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
|
||||
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -159,6 +200,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
|
||||
@@ -167,6 +210,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/slack-go/slack v0.27.0 h1:VWOpUzOK6UAPCCQlFxl79jhv8a/b+GOSJMnWziDJ8B8=
|
||||
github.com/slack-go/slack v0.27.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
|
||||
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
|
||||
@@ -180,14 +225,24 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
|
||||
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
|
||||
github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E=
|
||||
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
@@ -204,6 +259,7 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||
@@ -238,6 +294,9 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
@@ -246,32 +305,71 @@ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
@@ -279,7 +377,10 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -292,14 +393,28 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
|
||||
google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
|
||||
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
+73
-16
@@ -55,11 +55,15 @@ type App struct {
|
||||
knowledgeIndexer *knowledge.Indexer // 知识库索引器(用于动态初始化)
|
||||
knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化)
|
||||
agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器)
|
||||
robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信)
|
||||
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
|
||||
robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信等)
|
||||
robotMu sync.Mutex // 保护机器人长连接的 cancel
|
||||
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
|
||||
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
|
||||
wechatCancel context.CancelFunc // 微信 iLink 长轮询取消函数
|
||||
telegramCancel context.CancelFunc // Telegram 长轮询取消函数
|
||||
slackCancel context.CancelFunc // Slack Socket Mode 取消函数
|
||||
discordCancel context.CancelFunc // Discord Gateway 取消函数
|
||||
qqCancel context.CancelFunc // QQ WebSocket 取消函数
|
||||
c2Manager *c2.Manager // C2 管理器(未启用 C2 时为 nil)
|
||||
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
|
||||
c2WatchdogCancel context.CancelFunc // 看门狗取消函数
|
||||
@@ -207,14 +211,12 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
return nil, fmt.Errorf("初始化知识库嵌入器失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建检索器
|
||||
retrievalConfig := &knowledge.RetrievalConfig{
|
||||
TopK: cfg.Knowledge.Retrieval.TopK,
|
||||
SimilarityThreshold: cfg.Knowledge.Retrieval.SimilarityThreshold,
|
||||
SubIndexFilter: cfg.Knowledge.Retrieval.SubIndexFilter,
|
||||
PostRetrieve: cfg.Knowledge.Retrieval.PostRetrieve,
|
||||
}
|
||||
// 创建检索器(Eino MultiQuery + 重排流水线)
|
||||
retrievalConfig := knowledge.RetrievalConfigFromYAML(cfg.Knowledge.Retrieval)
|
||||
knowledgeRetriever = knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, log.Logger)
|
||||
if err := knowledge.WireRetrieverPipeline(context.Background(), knowledgeRetriever, &cfg.OpenAI); err != nil {
|
||||
return nil, fmt.Errorf("初始化知识库检索流水线失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建索引器(Eino Compose 链)
|
||||
knowledgeIndexer, err = knowledge.NewIndexer(context.Background(), knowledgeDB, embedder, log.Logger, &cfg.Knowledge)
|
||||
@@ -358,6 +360,9 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
||||
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
||||
projectHandler := handler.NewProjectHandler(db, log.Logger)
|
||||
workflowHandler := handler.NewWorkflowHandler(db, log.Logger)
|
||||
workflowHandler.SetAudit(auditSvc)
|
||||
workflowHandler.SetRuntime(agent, cfg)
|
||||
vulnerabilityHandler.SetAudit(auditSvc)
|
||||
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
||||
webshellHandler.SetAudit(auditSvc)
|
||||
@@ -369,6 +374,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
configHandler.SetAudit(auditSvc)
|
||||
agentHandler.SetHitlToolWhitelistSaver(configHandler)
|
||||
agentHandler.SetHitlAuditStrategySaver(configHandler)
|
||||
agentHandler.SetHitlDefaultReviewerSaver(configHandler)
|
||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||
externalMCPHandler.SetAudit(auditSvc)
|
||||
roleHandler := handler.NewRoleHandler(cfg, configPath, log.Logger)
|
||||
@@ -519,6 +525,7 @@ func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error
|
||||
app, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler,
|
||||
projectHandler,
|
||||
workflowHandler,
|
||||
webshellHandler,
|
||||
chatUploadsHandler,
|
||||
roleHandler,
|
||||
@@ -725,6 +732,26 @@ func (a *App) startRobotConnections() {
|
||||
a.wechatCancel = cancel
|
||||
go robot.StartWechat(ctx, cfg.Robots, a.robotHandler, cfg.Version, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.Telegram.Enabled && strings.TrimSpace(cfg.Robots.Telegram.BotToken) != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.telegramCancel = cancel
|
||||
go robot.StartTelegram(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.Slack.Enabled && strings.TrimSpace(cfg.Robots.Slack.BotToken) != "" && strings.TrimSpace(cfg.Robots.Slack.AppToken) != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.slackCancel = cancel
|
||||
go robot.StartSlack(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.Discord.Enabled && strings.TrimSpace(cfg.Robots.Discord.BotToken) != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.discordCancel = cancel
|
||||
go robot.StartDiscord(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.QQ.Enabled && strings.TrimSpace(cfg.Robots.QQ.AppID) != "" && strings.TrimSpace(cfg.Robots.QQ.ClientSecret) != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.qqCancel = cancel
|
||||
go robot.StartQQ(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
}
|
||||
|
||||
// RestartRobotConnections 重启钉钉/飞书/微信长连接,使前端应用配置后立即生效(实现 handler.RobotRestarter)
|
||||
@@ -742,6 +769,22 @@ func (a *App) RestartRobotConnections() {
|
||||
a.wechatCancel()
|
||||
a.wechatCancel = nil
|
||||
}
|
||||
if a.telegramCancel != nil {
|
||||
a.telegramCancel()
|
||||
a.telegramCancel = nil
|
||||
}
|
||||
if a.slackCancel != nil {
|
||||
a.slackCancel()
|
||||
a.slackCancel = nil
|
||||
}
|
||||
if a.discordCancel != nil {
|
||||
a.discordCancel()
|
||||
a.discordCancel = nil
|
||||
}
|
||||
if a.qqCancel != nil {
|
||||
a.qqCancel()
|
||||
a.qqCancel = nil
|
||||
}
|
||||
a.robotMu.Unlock()
|
||||
// 给旧 goroutine 一点时间退出
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
@@ -765,6 +808,7 @@ func setupRoutes(
|
||||
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler *handler.VulnerabilityHandler,
|
||||
projectHandler *handler.ProjectHandler,
|
||||
workflowHandler *handler.WorkflowHandler,
|
||||
webshellHandler *handler.WebShellHandler,
|
||||
chatUploadsHandler *handler.ChatUploadsHandler,
|
||||
roleHandler *handler.RoleHandler,
|
||||
@@ -828,6 +872,8 @@ func setupRoutes(
|
||||
protected.GET("/hitl/tool-whitelist", agentHandler.GetHITLGlobalToolWhitelist)
|
||||
protected.PUT("/hitl/tool-whitelist", agentHandler.SetHITLGlobalToolWhitelist)
|
||||
protected.POST("/hitl/tool-whitelist", agentHandler.MergeHITLGlobalToolWhitelist)
|
||||
protected.GET("/hitl/default-reviewer", agentHandler.GetHITLDefaultReviewer)
|
||||
protected.PUT("/hitl/default-reviewer", agentHandler.UpdateHITLDefaultReviewer)
|
||||
protected.GET("/hitl/audit-strategy", agentHandler.GetHITLAuditStrategy)
|
||||
protected.PUT("/hitl/audit-strategy", agentHandler.UpdateHITLAuditStrategy)
|
||||
// Agent Loop 取消与任务列表
|
||||
@@ -1191,6 +1237,19 @@ func setupRoutes(
|
||||
protected.PUT("/roles/:name", roleHandler.UpdateRole)
|
||||
protected.DELETE("/roles/:name", roleHandler.DeleteRole)
|
||||
|
||||
// 图编排 / 工作流定义(图结构固定,业务字段保存在 graph_json 中)
|
||||
protected.GET("/workflows/runs/pending", workflowHandler.ListPendingRuns)
|
||||
protected.GET("/workflows/runs/:runId/replay", workflowHandler.ReplayRun)
|
||||
protected.GET("/workflows/runs/:runId", workflowHandler.GetRun)
|
||||
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
||||
protected.POST("/workflows/validate", workflowHandler.Validate)
|
||||
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
||||
protected.GET("/workflows", workflowHandler.List)
|
||||
protected.GET("/workflows/:id", workflowHandler.Get)
|
||||
protected.POST("/workflows", workflowHandler.Create)
|
||||
protected.PUT("/workflows/:id", workflowHandler.Update)
|
||||
protected.DELETE("/workflows/:id", workflowHandler.Delete)
|
||||
|
||||
// Skills管理(具体路径需注册在 /skills/:name 之前)
|
||||
protected.GET("/skills", skillsHandler.GetSkills)
|
||||
protected.GET("/skills/stats", skillsHandler.GetSkillStats)
|
||||
@@ -1800,14 +1859,12 @@ func initializeKnowledge(
|
||||
return nil, fmt.Errorf("初始化知识库嵌入器失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建检索器
|
||||
retrievalConfig := &knowledge.RetrievalConfig{
|
||||
TopK: cfg.Knowledge.Retrieval.TopK,
|
||||
SimilarityThreshold: cfg.Knowledge.Retrieval.SimilarityThreshold,
|
||||
SubIndexFilter: cfg.Knowledge.Retrieval.SubIndexFilter,
|
||||
PostRetrieve: cfg.Knowledge.Retrieval.PostRetrieve,
|
||||
}
|
||||
// 创建检索器(Eino MultiQuery + 重排流水线)
|
||||
retrievalConfig := knowledge.RetrievalConfigFromYAML(cfg.Knowledge.Retrieval)
|
||||
knowledgeRetriever := knowledge.NewRetriever(knowledgeDB, embedder, retrievalConfig, logger)
|
||||
if err := knowledge.WireRetrieverPipeline(context.Background(), knowledgeRetriever, &cfg.OpenAI); err != nil {
|
||||
return nil, fmt.Errorf("初始化知识库检索流水线失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建索引器(Eino Compose 链)
|
||||
knowledgeIndexer, err := knowledge.NewIndexer(context.Background(), knowledgeDB, embedder, logger, &cfg.Knowledge)
|
||||
|
||||
@@ -120,9 +120,19 @@ func formatVulnerabilityDetail(v *database.Vulnerability) string {
|
||||
b.WriteString(v.Description)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.Proof != "" {
|
||||
b.WriteString("\n--- 证明(POC) ---\n")
|
||||
b.WriteString(v.Proof)
|
||||
if v.Preconditions != "" {
|
||||
b.WriteString("\n--- 前置条件 ---\n")
|
||||
b.WriteString(v.Preconditions)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.ReproSteps != "" {
|
||||
b.WriteString("\n--- 复现步骤 ---\n")
|
||||
b.WriteString(v.ReproSteps)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.Evidence != "" {
|
||||
b.WriteString("\n--- 证据 / POC ---\n")
|
||||
b.WriteString(v.Evidence)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.Impact != "" {
|
||||
@@ -135,9 +145,36 @@ func formatVulnerabilityDetail(v *database.Vulnerability) string {
|
||||
b.WriteString(v.Recommendation)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.RetestNotes != "" {
|
||||
b.WriteString("\n--- 复测方式 ---\n")
|
||||
b.WriteString(v.RetestNotes)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func missingVulnerabilityReproFields(args map[string]interface{}) []string {
|
||||
required := []struct {
|
||||
key string
|
||||
label string
|
||||
}{
|
||||
{"target", "target(受影响的 URL/IP/服务/接口)"},
|
||||
{"vulnerability_type", "vulnerability_type(漏洞类型)"},
|
||||
{"description", "description(漏洞摘要与触发点)"},
|
||||
{"reproduction_steps", "reproduction_steps(可逐步执行的复现步骤)"},
|
||||
{"evidence", "evidence(POC、原始请求/响应、命令输出或截图/日志证据)"},
|
||||
{"impact", "impact(确认后的实际影响)"},
|
||||
{"recommendation", "recommendation(修复建议)"},
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
for _, item := range required {
|
||||
if strings.TrimSpace(strArg(args, item.key)) == "" {
|
||||
missing = append(missing, item.label)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
@@ -163,18 +200,18 @@ func registerVulnerabilityTools(mcpServer *mcp.Server, db *database.DB, logger *
|
||||
func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
|
||||
tool := mcp.Tool{
|
||||
Name: builtin.ToolRecordVulnerability,
|
||||
Description: "记录发现的漏洞详情到漏洞管理系统。边渗透边记录:每验证出一条可复现漏洞(含 POC/影响)后立即调用,勿等会话结束。包括标题、描述、严重程度、类型、目标、证明、影响和建议等。记录前可先 list_vulnerabilities 避免重复。",
|
||||
ShortDescription: "记录发现的漏洞详情到漏洞管理系统",
|
||||
Description: "记录发现的漏洞详情到漏洞管理系统。必须按“仅看本记录即可复现”的标准填写:目标、触发点、前置条件、复现步骤、证据/POC、实际影响、修复建议和复测方式。边渗透边记录:每验证出一条可复现漏洞后立即调用,勿等会话结束。记录前可先 list_vulnerabilities 避免重复。",
|
||||
ShortDescription: "记录可复现的漏洞详情到漏洞管理系统",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"title": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "漏洞标题(必需)",
|
||||
"description": "漏洞标题(必需)。建议格式:<资产/接口> 存在 <漏洞类型>,例如“/api/login 存在 SQL 注入”。",
|
||||
},
|
||||
"description": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "漏洞详细描述",
|
||||
"description": "漏洞摘要与触发点(必需):说明哪个功能/参数/入口存在问题、为什么可被利用。不要只写结论。",
|
||||
},
|
||||
"severity": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -183,26 +220,38 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
},
|
||||
"vulnerability_type": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "漏洞类型,如:SQL注入、XSS、CSRF、命令注入等",
|
||||
"description": "漏洞类型,如:SQL注入、XSS、CSRF、命令注入等(必需)",
|
||||
},
|
||||
"target": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "受影响的目标(URL、IP地址、服务等)",
|
||||
"description": "受影响的目标(必需):尽量精确到 URL、IP:端口、服务名、接口路径和参数名。",
|
||||
},
|
||||
"proof": map[string]interface{}{
|
||||
"preconditions": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "漏洞证明(POC、截图、请求/响应等)",
|
||||
"description": "前置条件:登录状态、权限、账号、Header/Cookie、特定数据、网络位置、环境/版本等;无前置条件写“无”。",
|
||||
},
|
||||
"reproduction_steps": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "复现步骤(必需):按 1/2/3 编号,写清入口、参数、payload、执行命令、观察点。应让未参与对话的人照做即可复现。",
|
||||
},
|
||||
"evidence": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "证据 / POC(必需):原始 HTTP 请求/响应、curl/工具命令、截图文字说明、日志、DNSLog/回连记录、数据库结果、文件路径、时间戳等。优先放最小可验证证据。",
|
||||
},
|
||||
"impact": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "漏洞影响说明",
|
||||
"description": "漏洞影响说明(必需):结合已验证事实说明可造成什么后果,避免泛泛而谈。",
|
||||
},
|
||||
"recommendation": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "修复建议",
|
||||
"description": "修复建议(必需):给出针对该触发点/参数/组件的具体修复和复测建议。",
|
||||
},
|
||||
"retest_notes": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "复测方式:修复后如何验证漏洞已关闭,包括应返回的状态码、错误信息或访问控制结果。",
|
||||
},
|
||||
},
|
||||
"required": []string{"title", "severity"},
|
||||
"required": []string{"title", "description", "severity", "vulnerability_type", "target", "reproduction_steps", "evidence", "impact", "recommendation"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -231,6 +280,9 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
if !validSeverities[severity] {
|
||||
return textResult(fmt.Sprintf("错误: severity 必须是 critical、high、medium、low 或 info 之一,当前值: %s", severity), true), nil
|
||||
}
|
||||
if missing := missingVulnerabilityReproFields(args); len(missing) > 0 {
|
||||
return textResult("错误: 漏洞记录缺少复现所需信息,请补充后再记录:\n- "+strings.Join(missing, "\n- ")+"\n\n最佳实践:漏洞管理中的单条记录应独立包含目标、前置条件、复现步骤、证据/POC、影响和修复/复测方式。", true), nil
|
||||
}
|
||||
|
||||
projectID := ""
|
||||
if pid, perr := db.GetConversationProjectID(conversationID); perr == nil {
|
||||
@@ -246,9 +298,12 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
Status: "open",
|
||||
Type: strArg(args, "vulnerability_type"),
|
||||
Target: strArg(args, "target"),
|
||||
Proof: strArg(args, "proof"),
|
||||
Preconditions: strArg(args, "preconditions"),
|
||||
ReproSteps: strArg(args, "reproduction_steps"),
|
||||
Evidence: strArg(args, "evidence"),
|
||||
Impact: strArg(args, "impact"),
|
||||
Recommendation: strArg(args, "recommendation"),
|
||||
RetestNotes: strArg(args, "retest_notes"),
|
||||
}
|
||||
|
||||
created, err := db.CreateVulnerability(vuln)
|
||||
@@ -275,8 +330,8 @@ func registerRecordVulnerabilityTool(mcpServer *mcp.Server, db *database.DB, log
|
||||
|
||||
func registerListVulnerabilitiesTool(mcpServer *mcp.Server, db *database.DB, logger *zap.Logger) {
|
||||
tool := mcp.Tool{
|
||||
Name: builtin.ToolListVulnerabilities,
|
||||
Description: "列出当前授权范围内的漏洞(摘要)。默认:对话已绑定项目时列出该项目下全部漏洞;未绑项目时仅列出当前会话漏洞。可用 scope=conversation 仅看本会话。记录新漏洞前建议先调用以避免重复。",
|
||||
Name: builtin.ToolListVulnerabilities,
|
||||
Description: "列出当前授权范围内的漏洞(摘要)。默认:对话已绑定项目时列出该项目下全部漏洞;未绑项目时仅列出当前会话漏洞。可用 scope=conversation 仅看本会话。记录新漏洞前建议先调用以避免重复。",
|
||||
ShortDescription: "列出漏洞(默认当前项目)",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
|
||||
+172
-76
@@ -30,7 +30,7 @@ type Config struct {
|
||||
Monitor MonitorConfig `yaml:"monitor,omitempty" json:"monitor,omitempty"`
|
||||
ExternalMCP ExternalMCPConfig `yaml:"external_mcp,omitempty"`
|
||||
Knowledge KnowledgeConfig `yaml:"knowledge,omitempty"`
|
||||
C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用
|
||||
C2 C2Config `yaml:"c2,omitempty" json:"c2,omitempty"` // 内置 C2 总开关;未配置时默认启用
|
||||
Robots RobotsConfig `yaml:"robots,omitempty" json:"robots,omitempty"` // 企业微信/钉钉/飞书等机器人配置
|
||||
RolesDir string `yaml:"roles_dir,omitempty" json:"roles_dir,omitempty"` // 角色配置文件目录(新方式)
|
||||
Roles map[string]RoleConfig `yaml:"roles,omitempty" json:"roles,omitempty"` // 向后兼容:支持在主配置文件中定义角色
|
||||
@@ -79,7 +79,7 @@ func (c ProjectConfig) FactSummaryMaxRunesEffective() int {
|
||||
type MultiAgentConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
RobotDefaultAgentMode string `yaml:"robot_default_agent_mode,omitempty" json:"robot_default_agent_mode,omitempty"` // eino_single | deep | plan_execute | supervisor
|
||||
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
||||
BatchUseMultiAgent bool `yaml:"batch_use_multi_agent" json:"batch_use_multi_agent"` // 为 true 时批量任务队列中每子任务走 Eino 多代理
|
||||
// Orchestration 已弃用:保留仅兼容旧版 config.yaml;编排由聊天/WebShell 请求体 orchestration 决定,未传时按 deep。
|
||||
Orchestration string `yaml:"orchestration,omitempty" json:"orchestration,omitempty"`
|
||||
// MaxIteration 已废弃:统一使用 agent.max_iterations(YAML 中保留字段仅为兼容旧配置,运行时不读取)。
|
||||
@@ -87,10 +87,10 @@ type MultiAgentConfig struct {
|
||||
// PlanExecuteLoopMaxIterations plan_execute 模式下 execute↔replan 外层循环上限;0 表示用 Eino 默认 10。
|
||||
PlanExecuteLoopMaxIterations int `yaml:"plan_execute_loop_max_iterations,omitempty" json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
// SubAgentMaxIterations 已废弃:子代理与主代理均使用 agent.max_iterations(Markdown max_iterations>0 可覆盖)。
|
||||
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations,omitempty" json:"sub_agent_max_iterations,omitempty"`
|
||||
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
||||
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
||||
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
||||
SubAgentMaxIterations int `yaml:"sub_agent_max_iterations,omitempty" json:"sub_agent_max_iterations,omitempty"`
|
||||
WithoutGeneralSubAgent bool `yaml:"without_general_sub_agent" json:"without_general_sub_agent"`
|
||||
WithoutWriteTodos bool `yaml:"without_write_todos" json:"without_write_todos"`
|
||||
OrchestratorInstruction string `yaml:"orchestrator_instruction" json:"orchestrator_instruction"`
|
||||
// OrchestratorInstructionPlanExecute plan_execute 主代理(规划侧)系统提示;非空且 agents/orchestrator-plan-execute.md 正文为空或未存在时生效。不与 Deep 的 orchestrator_instruction 混用。
|
||||
OrchestratorInstructionPlanExecute string `yaml:"orchestrator_instruction_plan_execute,omitempty" json:"orchestrator_instruction_plan_execute,omitempty"`
|
||||
// OrchestratorInstructionSupervisor supervisor 主代理系统提示(transfer/exit 说明仍由运行追加);非空且 agents/orchestrator-supervisor.md 正文为空或未存在时生效。
|
||||
@@ -99,9 +99,6 @@ type MultiAgentConfig struct {
|
||||
// SubAgentUserContextMaxRunes caps user-context supplement for sub-agent task descriptions.
|
||||
// 0 (default) preserves all user turns verbatim; >0 caps total runes; negative disables injection.
|
||||
SubAgentUserContextMaxRunes int `yaml:"sub_agent_user_context_max_runes,omitempty" json:"sub_agent_user_context_max_runes,omitempty"`
|
||||
// UserVerbatimAnchorMaxRunes injects all user turns verbatim into system prompt (survives summarization refresh).
|
||||
// 0 (default) = no cap; >0 = total rune cap; negative disables anchor injection.
|
||||
UserVerbatimAnchorMaxRunes int `yaml:"user_verbatim_anchor_max_runes,omitempty" json:"user_verbatim_anchor_max_runes,omitempty"`
|
||||
// EinoSkills configures CloudWeGo Eino ADK skill middleware + optional local filesystem/execute on DeepAgent.
|
||||
EinoSkills MultiAgentEinoSkillsConfig `yaml:"eino_skills,omitempty" json:"eino_skills,omitempty"`
|
||||
// EinoMiddleware wires optional ADK middleware (patchtoolcalls, toolsearch, plantask, reduction) and Deep extras.
|
||||
@@ -110,11 +107,6 @@ type MultiAgentConfig struct {
|
||||
EinoCallbacks MultiAgentEinoCallbacksConfig `yaml:"eino_callbacks,omitempty" json:"eino_callbacks,omitempty"`
|
||||
}
|
||||
|
||||
// UserVerbatimAnchorMaxRunesEffective returns max runes for user verbatim anchor; 0 = unlimited; negative = disabled.
|
||||
func (c MultiAgentConfig) UserVerbatimAnchorMaxRunesEffective() int {
|
||||
return c.UserVerbatimAnchorMaxRunes
|
||||
}
|
||||
|
||||
// SubAgentUserContextMaxRunesEffective returns max runes for sub-agent task supplement; 0 = unlimited; negative = disabled.
|
||||
func (c MultiAgentConfig) SubAgentUserContextMaxRunesEffective() int {
|
||||
return c.SubAgentUserContextMaxRunes
|
||||
@@ -138,11 +130,11 @@ type MultiAgentEinoCallbacksConfig struct {
|
||||
|
||||
// MultiAgentEinoCallbacksOtelConfig OpenTelemetry for Eino callback spans (W3C trace in collector / stdout).
|
||||
type MultiAgentEinoCallbacksOtelConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
ServiceName string `yaml:"service_name,omitempty" json:"service_name,omitempty"`
|
||||
Exporter string `yaml:"exporter,omitempty" json:"exporter,omitempty"` // none | stdout | otlphttp
|
||||
OTLPEndpoint string `yaml:"otlp_endpoint,omitempty" json:"otlp_endpoint,omitempty"` // host:port, e.g. localhost:4318 (path /v1/traces)
|
||||
SampleRatio float64 `yaml:"sample_ratio,omitempty" json:"sample_ratio,omitempty"` // 0–1, default 1.0
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
ServiceName string `yaml:"service_name,omitempty" json:"service_name,omitempty"`
|
||||
Exporter string `yaml:"exporter,omitempty" json:"exporter,omitempty"` // none | stdout | otlphttp
|
||||
OTLPEndpoint string `yaml:"otlp_endpoint,omitempty" json:"otlp_endpoint,omitempty"` // host:port, e.g. localhost:4318 (path /v1/traces)
|
||||
SampleRatio float64 `yaml:"sample_ratio,omitempty" json:"sample_ratio,omitempty"` // 0–1, default 1.0
|
||||
}
|
||||
|
||||
// EinoCallbacksModeEffective returns off | log_only | sse | full.
|
||||
@@ -253,12 +245,12 @@ type MultiAgentEinoMiddlewareConfig struct {
|
||||
// PlantaskRelDir relative to skills_dir for per-conversation task boards (default .eino/plantask).
|
||||
PlantaskRelDir string `yaml:"plantask_rel_dir,omitempty" json:"plantask_rel_dir,omitempty"`
|
||||
// Reduction truncates/offloads large tool outputs (requires eino local backend for Write).
|
||||
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
|
||||
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // 非空:落盘根目录(默认 tmp/reduction);其下按 projects/{id} 或 conversations/{id} 隔离
|
||||
ReductionMaxLengthForTrunc int `yaml:"reduction_max_length_for_trunc,omitempty" json:"reduction_max_length_for_trunc,omitempty"` // default 12000
|
||||
ReductionMaxTokensForClear int `yaml:"reduction_max_tokens_for_clear,omitempty" json:"reduction_max_tokens_for_clear,omitempty"` // default 50000
|
||||
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
|
||||
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
|
||||
ReductionEnable bool `yaml:"reduction_enable,omitempty" json:"reduction_enable,omitempty"`
|
||||
ReductionRootDir string `yaml:"reduction_root_dir,omitempty" json:"reduction_root_dir,omitempty"` // 非空:落盘根目录(默认 tmp/reduction);其下按 projects/{id} 或 conversations/{id} 隔离
|
||||
ReductionMaxLengthForTrunc int `yaml:"reduction_max_length_for_trunc,omitempty" json:"reduction_max_length_for_trunc,omitempty"` // default 12000
|
||||
ReductionMaxTokensForClear int `yaml:"reduction_max_tokens_for_clear,omitempty" json:"reduction_max_tokens_for_clear,omitempty"` // default 50000
|
||||
ReductionClearExclude []string `yaml:"reduction_clear_exclude,omitempty" json:"reduction_clear_exclude,omitempty"`
|
||||
ReductionSubAgents bool `yaml:"reduction_sub_agents,omitempty" json:"reduction_sub_agents,omitempty"` // also attach to sub-agents
|
||||
// SummarizationTriggerRatio controls summarization trigger threshold as max_total_tokens * ratio (default 0.8).
|
||||
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
||||
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
||||
@@ -406,13 +398,13 @@ type MultiAgentSubConfig struct {
|
||||
|
||||
// MultiAgentPublic 返回给前端的精简信息(不含子代理指令全文)。
|
||||
type MultiAgentPublic struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
SubAgentCount int `json:"sub_agent_count"`
|
||||
Orchestration string `json:"orchestration,omitempty"`
|
||||
PlanExecuteLoopMaxIterations int `json:"plan_execute_loop_max_iterations"`
|
||||
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
SubAgentCount int `json:"sub_agent_count"`
|
||||
Orchestration string `json:"orchestration,omitempty"`
|
||||
PlanExecuteLoopMaxIterations int `json:"plan_execute_loop_max_iterations"`
|
||||
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
|
||||
ToolSearchAlwaysVisibleEffectiveTools []string `json:"tool_search_always_visible_effective_tools,omitempty"`
|
||||
}
|
||||
|
||||
@@ -453,33 +445,37 @@ func NormalizeMultiAgentOrchestration(s string) string {
|
||||
|
||||
// MultiAgentAPIUpdate 设置页/API 仅更新多代理标量字段;写入 YAML 时不覆盖 sub_agents 等块。
|
||||
type MultiAgentAPIUpdate struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
RobotDefaultAgentMode string `json:"robot_default_agent_mode,omitempty"`
|
||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
||||
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
|
||||
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
||||
}
|
||||
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink 等)
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink、Telegram、Slack、Discord、QQ 等)
|
||||
type RobotsConfig struct {
|
||||
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
|
||||
Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定)
|
||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
|
||||
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
||||
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
|
||||
Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定)
|
||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
|
||||
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
||||
Telegram RobotTelegramConfig `yaml:"telegram,omitempty" json:"telegram,omitempty"` // Telegram
|
||||
Slack RobotSlackConfig `yaml:"slack,omitempty" json:"slack,omitempty"` // Slack
|
||||
Discord RobotDiscordConfig `yaml:"discord,omitempty" json:"discord,omitempty"` // Discord
|
||||
QQ RobotQQConfig `yaml:"qq,omitempty" json:"qq,omitempty"` // QQ 机器人
|
||||
}
|
||||
|
||||
// RobotWechatConfig 微信 iLink 机器人配置(个人微信 ClawBot / iLink 协议)
|
||||
type RobotWechatConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
BotToken string `yaml:"bot_token,omitempty" json:"bot_token,omitempty"`
|
||||
ILinkBotID string `yaml:"ilink_bot_id,omitempty" json:"ilink_bot_id,omitempty"`
|
||||
ILinkUserID string `yaml:"ilink_user_id,omitempty" json:"ilink_user_id,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://ilinkai.weixin.qq.com
|
||||
BotType string `yaml:"bot_type,omitempty" json:"bot_type,omitempty"` // get_bot_qrcode 参数,默认 3
|
||||
BotAgent string `yaml:"bot_agent,omitempty" json:"bot_agent,omitempty"` // base_info.bot_agent
|
||||
GetUpdatesBuf string `yaml:"get_updates_buf,omitempty" json:"get_updates_buf,omitempty"` // 长轮询游标(运行时)
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
BotToken string `yaml:"bot_token,omitempty" json:"bot_token,omitempty"`
|
||||
ILinkBotID string `yaml:"ilink_bot_id,omitempty" json:"ilink_bot_id,omitempty"`
|
||||
ILinkUserID string `yaml:"ilink_user_id,omitempty" json:"ilink_user_id,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://ilinkai.weixin.qq.com
|
||||
BotType string `yaml:"bot_type,omitempty" json:"bot_type,omitempty"` // get_bot_qrcode 参数,默认 3
|
||||
BotAgent string `yaml:"bot_agent,omitempty" json:"bot_agent,omitempty"` // base_info.bot_agent
|
||||
GetUpdatesBuf string `yaml:"get_updates_buf,omitempty" json:"get_updates_buf,omitempty"` // 长轮询游标(运行时)
|
||||
}
|
||||
|
||||
// RobotSessionConfig 机器人会话隔离策略
|
||||
@@ -518,19 +514,50 @@ func ValidateWecomConfig(w RobotWecomConfig) error {
|
||||
|
||||
// RobotDingtalkConfig 钉钉机器人配置
|
||||
type RobotDingtalkConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
|
||||
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
ClientID string `yaml:"client_id" json:"client_id"` // 应用 Key (AppKey)
|
||||
ClientSecret string `yaml:"client_secret" json:"client_secret"` // 应用 Secret
|
||||
AllowConversationIDFallback bool `yaml:"allow_conversation_id_fallback" json:"allow_conversation_id_fallback"` // sender_id 缺失时是否允许回退到会话 ID
|
||||
}
|
||||
|
||||
// RobotLarkConfig 飞书机器人配置
|
||||
type RobotLarkConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
|
||||
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
|
||||
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
|
||||
AllowChatIDFallback bool `yaml:"allow_chat_id_fallback" json:"allow_chat_id_fallback"` // 用户 ID 缺失时是否允许回退到 chat_id
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
AppID string `yaml:"app_id" json:"app_id"` // 应用 App ID
|
||||
AppSecret string `yaml:"app_secret" json:"app_secret"` // 应用 App Secret
|
||||
VerifyToken string `yaml:"verify_token" json:"verify_token"` // 事件订阅 Verification Token(可选)
|
||||
AllowChatIDFallback bool `yaml:"allow_chat_id_fallback" json:"allow_chat_id_fallback"` // 用户 ID 缺失时是否允许回退到 chat_id
|
||||
}
|
||||
|
||||
// RobotTelegramConfig Telegram 机器人配置(Bot API 长轮询)
|
||||
type RobotTelegramConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
BotToken string `yaml:"bot_token" json:"bot_token"`
|
||||
BotUsername string `yaml:"bot_username,omitempty" json:"bot_username,omitempty"` // 可选,用于群聊 @ 识别;留空则启动时 getMe
|
||||
AllowGroupMessages bool `yaml:"allow_group_messages" json:"allow_group_messages"` // 群聊中仅响应 @ 机器人
|
||||
UpdateOffset int64 `yaml:"update_offset,omitempty" json:"update_offset,omitempty"`
|
||||
}
|
||||
|
||||
// RobotSlackConfig Slack 机器人配置(Socket Mode,无需公网回调)
|
||||
type RobotSlackConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
BotToken string `yaml:"bot_token" json:"bot_token"` // xoxb-
|
||||
AppToken string `yaml:"app_token" json:"app_token"` // xapp-(connections:write)
|
||||
}
|
||||
|
||||
// RobotDiscordConfig Discord 机器人配置(Gateway WebSocket)
|
||||
type RobotDiscordConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
BotToken string `yaml:"bot_token" json:"bot_token"`
|
||||
AllowGuildMessages bool `yaml:"allow_guild_messages" json:"allow_guild_messages"` // 服务器频道中仅响应 @ 机器人
|
||||
}
|
||||
|
||||
// RobotQQConfig QQ 机器人配置(QQ 开放平台 WebSocket)
|
||||
type RobotQQConfig struct {
|
||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||
AppID string `yaml:"app_id" json:"app_id"`
|
||||
ClientSecret string `yaml:"client_secret" json:"client_secret"`
|
||||
Sandbox bool `yaml:"sandbox" json:"sandbox"` // 沙箱环境(上线前测试)
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -629,8 +656,8 @@ type DatabaseConfig struct {
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
||||
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
|
||||
ToolTimeoutMinutes int `yaml:"tool_timeout_minutes" json:"tool_timeout_minutes"` // 单次工具执行最大时长(分钟),超时自动终止,防止长时间挂起;0 表示不限制(不推荐)
|
||||
// ShellNoOutputTimeoutSeconds execute/exec 无任何 stdout/stderr 时的空闲终止秒数(通用防挂死,不维护命令黑名单);0=默认 300(5 分钟);-1=关闭。
|
||||
ShellNoOutputTimeoutSeconds int `yaml:"shell_no_output_timeout_seconds" json:"shell_no_output_timeout_seconds"`
|
||||
// WorkspaceRootDir 会话工作目录根路径(curl/wget 下载、read_file/glob/grep 本地分析);空=tmp/workspace,其下按 projects/{id} 或 conversations/{id} 隔离。
|
||||
@@ -651,6 +678,18 @@ type HitlConfig struct {
|
||||
AuditAgentPromptReviewEdit string `yaml:"audit_agent_prompt_review_edit,omitempty" json:"audit_agent_prompt_review_edit,omitempty"`
|
||||
// RetentionDays 已决策审计日志(hitl_interrupts 非 pending)保留天数;省略时默认 90;0 表示不自动清理。
|
||||
RetentionDays *int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
|
||||
// DefaultReviewer 全局默认审批方(human | audit_agent);未选会话时切换会写入 config.yaml;新建会话无独立配置时沿用。
|
||||
DefaultReviewer string `yaml:"default_reviewer,omitempty" json:"default_reviewer,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveDefaultReviewer returns human or audit_agent; omitted or unknown values default to human.
|
||||
func (h HitlConfig) EffectiveDefaultReviewer() string {
|
||||
switch strings.ToLower(strings.TrimSpace(h.DefaultReviewer)) {
|
||||
case "audit_agent", "agent", "ai":
|
||||
return "audit_agent"
|
||||
default:
|
||||
return "human"
|
||||
}
|
||||
}
|
||||
|
||||
// RetentionDaysEffective returns retention; 0 means keep forever; omitted defaults to 90.
|
||||
@@ -764,9 +803,9 @@ func (m MonitorConfig) RetentionDaysEffective() int {
|
||||
// AuditConfig platform operation audit log settings (not chat/tool execution bodies).
|
||||
type AuditConfig struct {
|
||||
// Enabled nil or true enables persistence; explicit false disables.
|
||||
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||
RetentionDays int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
|
||||
MaxDetailBytes int `yaml:"max_detail_bytes,omitempty" json:"max_detail_bytes,omitempty"`
|
||||
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
||||
RetentionDays int `yaml:"retention_days,omitempty" json:"retention_days,omitempty"`
|
||||
MaxDetailBytes int `yaml:"max_detail_bytes,omitempty" json:"max_detail_bytes,omitempty"`
|
||||
// AuthFailureCooldownSeconds: per-IP cooldown for auth login/change_password failure audit rows; -1 disables; 0 uses default 60.
|
||||
AuthFailureCooldownSeconds int `yaml:"auth_failure_cooldown_seconds,omitempty" json:"auth_failure_cooldown_seconds,omitempty"`
|
||||
}
|
||||
@@ -1444,8 +1483,8 @@ func Default() *Config {
|
||||
},
|
||||
Agent: AgentConfig{
|
||||
MaxIterations: 30, // 默认最大迭代次数
|
||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
||||
ShellNoOutputTimeoutSeconds: 300, // execute/exec 无新输出空闲终止(秒);-1 关闭
|
||||
ToolTimeoutMinutes: 10, // 单次工具执行默认最多 10 分钟,避免异常长时间占用
|
||||
ShellNoOutputTimeoutSeconds: 300, // execute/exec 无新输出空闲终止(秒);-1 关闭
|
||||
},
|
||||
Security: SecurityConfig{
|
||||
Tools: []ToolConfig{}, // 工具配置应该从 config.yaml 或 tools/ 目录加载
|
||||
@@ -1485,7 +1524,12 @@ func Default() *Config {
|
||||
},
|
||||
Retrieval: RetrievalConfig{
|
||||
TopK: 5,
|
||||
SimilarityThreshold: 0.65, // 降低阈值到 0.65,减少漏检
|
||||
SimilarityThreshold: 0.65,
|
||||
MultiQuery: MultiQueryConfig{MaxQueries: 4},
|
||||
Rerank: RerankConfig{},
|
||||
PostRetrieve: PostRetrieveConfig{
|
||||
PrefetchTopK: 20,
|
||||
},
|
||||
},
|
||||
Indexing: IndexingConfig{
|
||||
ChunkStrategy: "markdown_then_recursive",
|
||||
@@ -1581,7 +1625,7 @@ type EmbeddingConfig struct {
|
||||
|
||||
// PostRetrieveConfig 检索后处理:固定对正文做规范化去重(最佳实践)、上下文预算截断;PrefetchTopK 用于多取候选再收敛到 top_k。
|
||||
type PostRetrieveConfig struct {
|
||||
// PrefetchTopK 向量检索阶段最多保留的候选数(余弦序),应 ≥ top_k,0 表示与 top_k 相同;上限见知识库包内常量。
|
||||
// PrefetchTopK 向量检索阶段每条 MultiQuery 变体最多保留的候选数;0 表示使用内置默认 max(top_k*4, 20)。
|
||||
PrefetchTopK int `yaml:"prefetch_top_k,omitempty" json:"prefetch_top_k,omitempty"`
|
||||
// MaxContextChars 返回文档内容总 Unicode 字符数上限(整段 chunk,不截断半段);0 表示不限制。
|
||||
MaxContextChars int `yaml:"max_context_chars,omitempty" json:"max_context_chars,omitempty"`
|
||||
@@ -1589,13 +1633,62 @@ type PostRetrieveConfig struct {
|
||||
MaxContextTokens int `yaml:"max_context_tokens,omitempty" json:"max_context_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// MultiQueryConfig Eino MultiQuery 查询改写(始终启用,无关闭开关)。
|
||||
type MultiQueryConfig struct {
|
||||
// MaxQueries LLM 生成的检索变体上限(含原问语义覆盖);0 表示默认 4。
|
||||
MaxQueries int `yaml:"max_queries,omitempty" json:"max_queries,omitempty"`
|
||||
}
|
||||
|
||||
func (c MultiQueryConfig) MaxQueriesEffective() int {
|
||||
if c.MaxQueries <= 0 {
|
||||
return 4
|
||||
}
|
||||
if c.MaxQueries > 8 {
|
||||
return 8
|
||||
}
|
||||
return c.MaxQueries
|
||||
}
|
||||
|
||||
// RerankConfig 检索精排(始终启用);支持 dashscope 与 Cohere 兼容 HTTP API。
|
||||
type RerankConfig struct {
|
||||
// Provider: dashscope | cohere;空则按 base_url 自动推断。
|
||||
Provider string `yaml:"provider,omitempty" json:"provider,omitempty"`
|
||||
Model string `yaml:"model,omitempty" json:"model,omitempty"`
|
||||
BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"`
|
||||
APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
func (c RerankConfig) ProviderEffective(baseURL string) string {
|
||||
p := strings.TrimSpace(strings.ToLower(c.Provider))
|
||||
if p != "" {
|
||||
return p
|
||||
}
|
||||
u := strings.ToLower(baseURL)
|
||||
if strings.Contains(u, "dashscope") {
|
||||
return "dashscope"
|
||||
}
|
||||
return "cohere"
|
||||
}
|
||||
|
||||
func (c RerankConfig) ModelEffective(provider string) string {
|
||||
if m := strings.TrimSpace(c.Model); m != "" {
|
||||
return m
|
||||
}
|
||||
if provider == "dashscope" {
|
||||
return "gte-rerank"
|
||||
}
|
||||
return "rerank-multilingual-v3.0"
|
||||
}
|
||||
|
||||
// RetrievalConfig 检索配置
|
||||
type RetrievalConfig struct {
|
||||
TopK int `yaml:"top_k" json:"top_k"` // 检索Top-K
|
||||
SimilarityThreshold float64 `yaml:"similarity_threshold" json:"similarity_threshold"` // 余弦相似度阈值
|
||||
// SubIndexFilter 非空时仅保留 sub_indexes 含该标签(逗号分隔之一)的行;sub_indexes 为空的旧行仍返回。
|
||||
SubIndexFilter string `yaml:"sub_index_filter,omitempty" json:"sub_index_filter,omitempty"`
|
||||
// PostRetrieve 检索后处理(去重、预算截断);重排通过代码注入 [knowledge.DocumentReranker]。
|
||||
SubIndexFilter string `yaml:"sub_index_filter,omitempty" json:"sub_index_filter,omitempty"`
|
||||
MultiQuery MultiQueryConfig `yaml:"multi_query" json:"multi_query"`
|
||||
Rerank RerankConfig `yaml:"rerank" json:"rerank"`
|
||||
// PostRetrieve 检索后处理(去重、预算截断);精排在 MultiQuery 融合后执行。
|
||||
PostRetrieve PostRetrieveConfig `yaml:"post_retrieve,omitempty" json:"post_retrieve,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1607,11 +1700,14 @@ type RolesConfig struct {
|
||||
|
||||
// RoleConfig 单个角色配置
|
||||
type RoleConfig struct {
|
||||
Name string `yaml:"name" json:"name"` // 角色名称
|
||||
Description string `yaml:"description" json:"description"` // 角色描述
|
||||
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
|
||||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
|
||||
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName")
|
||||
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
|
||||
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
|
||||
Name string `yaml:"name" json:"name"` // 角色名称
|
||||
Description string `yaml:"description" json:"description"` // 角色描述
|
||||
UserPrompt string `yaml:"user_prompt" json:"user_prompt"` // 用户提示词(追加到用户消息前)
|
||||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"` // 角色图标(可选)
|
||||
Tools []string `yaml:"tools,omitempty" json:"tools,omitempty"` // 关联的工具列表(toolKey格式,如 "toolName" 或 "mcpName::toolName")
|
||||
MCPs []string `yaml:"mcps,omitempty" json:"mcps,omitempty"` // 向后兼容:关联的MCP服务器列表(已废弃,使用tools替代)
|
||||
WorkflowID string `yaml:"workflow_id,omitempty" json:"workflow_id,omitempty"` // 可选:绑定图编排流程 ID
|
||||
WorkflowVersion string `yaml:"workflow_version,omitempty" json:"workflow_version,omitempty"` // latest 或具体版本号;空等同 latest
|
||||
WorkflowPolicy string `yaml:"workflow_policy,omitempty" json:"workflow_policy,omitempty"` // auto | off;空且 workflow_id 非空时按 auto
|
||||
Enabled bool `yaml:"enabled" json:"enabled"` // 是否启用
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
@@ -388,9 +388,12 @@ func (db *DB) initTables() error {
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
vulnerability_type TEXT,
|
||||
target TEXT,
|
||||
proof TEXT,
|
||||
preconditions TEXT,
|
||||
reproduction_steps TEXT,
|
||||
evidence TEXT,
|
||||
impact TEXT,
|
||||
recommendation TEXT,
|
||||
retest_notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
project_id TEXT,
|
||||
@@ -584,6 +587,53 @@ func (db *DB) initTables() error {
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
|
||||
createWorkflowDefinitionsTable := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_definitions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
graph_json TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);`
|
||||
|
||||
createWorkflowRunsTable := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
workflow_id TEXT NOT NULL,
|
||||
workflow_version INTEGER NOT NULL DEFAULT 1,
|
||||
conversation_id TEXT,
|
||||
project_id TEXT,
|
||||
role_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
input_json TEXT,
|
||||
output_json TEXT,
|
||||
error TEXT,
|
||||
pending_hitl_node_id TEXT,
|
||||
pending_hitl_json TEXT,
|
||||
started_at DATETIME NOT NULL,
|
||||
finished_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE SET NULL
|
||||
);`
|
||||
|
||||
createWorkflowNodeRunsTable := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_node_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
node_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
input_json TEXT,
|
||||
output_json TEXT,
|
||||
error TEXT,
|
||||
started_at DATETIME NOT NULL,
|
||||
finished_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
|
||||
);`
|
||||
|
||||
// 创建索引
|
||||
createIndexes := `
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
||||
@@ -642,6 +692,12 @@ func (db *DB) initTables() error {
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit_logs(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_result ON audit_logs(result);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_definitions_updated_at ON workflow_definitions(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_definitions_enabled ON workflow_definitions(enabled);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_workflow ON workflow_runs(workflow_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_conversation ON workflow_runs(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_runs_status ON workflow_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_node_runs_run ON workflow_node_runs(run_id);
|
||||
`
|
||||
|
||||
if _, err := db.Exec(createConversationsTable); err != nil {
|
||||
@@ -727,6 +783,16 @@ func (db *DB) initTables() error {
|
||||
return fmt.Errorf("创建audit_logs表失败: %w", err)
|
||||
}
|
||||
|
||||
for tableName, ddl := range map[string]string{
|
||||
"workflow_definitions": createWorkflowDefinitionsTable,
|
||||
"workflow_runs": createWorkflowRunsTable,
|
||||
"workflow_node_runs": createWorkflowNodeRunsTable,
|
||||
} {
|
||||
if _, err := db.Exec(ddl); err != nil {
|
||||
return fmt.Errorf("创建%s表失败: %w", tableName, err)
|
||||
}
|
||||
}
|
||||
|
||||
for tableName, ddl := range map[string]string{
|
||||
"c2_listeners": createC2ListenersTable,
|
||||
"c2_sessions": createC2SessionsTable,
|
||||
@@ -784,6 +850,9 @@ func (db *DB) initTables() error {
|
||||
db.logger.Warn("迁移webshell_connections表失败", zap.Error(err))
|
||||
// 不返回错误,允许继续运行
|
||||
}
|
||||
if err := db.migrateWorkflowRunsTable(); err != nil {
|
||||
db.logger.Warn("迁移workflow_runs表失败", zap.Error(err))
|
||||
}
|
||||
|
||||
if _, err := db.Exec(createIndexes); err != nil {
|
||||
return fmt.Errorf("创建索引失败: %w", err)
|
||||
@@ -1224,9 +1293,12 @@ func (db *DB) migrateVulnerabilitiesConversationFK() error {
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
vulnerability_type TEXT,
|
||||
target TEXT,
|
||||
proof TEXT,
|
||||
preconditions TEXT,
|
||||
reproduction_steps TEXT,
|
||||
evidence TEXT,
|
||||
impact TEXT,
|
||||
recommendation TEXT,
|
||||
retest_notes TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
project_id TEXT,
|
||||
@@ -1239,12 +1311,15 @@ func (db *DB) migrateVulnerabilitiesConversationFK() error {
|
||||
const copyRows = `
|
||||
INSERT INTO vulnerabilities_new (
|
||||
id, conversation_id, conversation_tag, task_tag, title, description,
|
||||
severity, status, vulnerability_type, target, proof, impact, recommendation,
|
||||
severity, status, vulnerability_type, target, preconditions, reproduction_steps,
|
||||
evidence, impact, recommendation, retest_notes,
|
||||
created_at, updated_at, project_id
|
||||
)
|
||||
SELECT
|
||||
id, conversation_id, conversation_tag, task_tag, title, description,
|
||||
severity, status, vulnerability_type, target, proof, impact, recommendation,
|
||||
severity, status, vulnerability_type, target,
|
||||
COALESCE(preconditions, ''), COALESCE(reproduction_steps, ''),
|
||||
COALESCE(evidence, ''), impact, recommendation, COALESCE(retest_notes, ''),
|
||||
created_at, updated_at, project_id
|
||||
FROM vulnerabilities;`
|
||||
if _, err := tx.Exec(copyRows); err != nil {
|
||||
@@ -1315,6 +1390,10 @@ func (db *DB) migrateVulnerabilitiesTable() error {
|
||||
{name: "conversation_tag", stmt: "ALTER TABLE vulnerabilities ADD COLUMN conversation_tag TEXT"},
|
||||
{name: "task_tag", stmt: "ALTER TABLE vulnerabilities ADD COLUMN task_tag TEXT"},
|
||||
{name: "project_id", stmt: "ALTER TABLE vulnerabilities ADD COLUMN project_id TEXT"},
|
||||
{name: "preconditions", stmt: "ALTER TABLE vulnerabilities ADD COLUMN preconditions TEXT"},
|
||||
{name: "reproduction_steps", stmt: "ALTER TABLE vulnerabilities ADD COLUMN reproduction_steps TEXT"},
|
||||
{name: "evidence", stmt: "ALTER TABLE vulnerabilities ADD COLUMN evidence TEXT"},
|
||||
{name: "retest_notes", stmt: "ALTER TABLE vulnerabilities ADD COLUMN retest_notes TEXT"},
|
||||
}
|
||||
|
||||
for _, col := range columns {
|
||||
|
||||
@@ -111,19 +111,43 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// CountProjects 统计项目数量。
|
||||
func (db *DB) CountProjects(status, search string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
func projectListSearchPattern(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteByte('%')
|
||||
for _, r := range q {
|
||||
switch r {
|
||||
case '%', '_', '\\':
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('%')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendProjectListFilters(query string, args []interface{}, status, search string) (string, []interface{}) {
|
||||
if s := strings.TrimSpace(status); s != "" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, s)
|
||||
}
|
||||
if q := strings.TrimSpace(search); q != "" {
|
||||
pattern := "%" + q + "%"
|
||||
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
||||
args = append(args, pattern, pattern)
|
||||
if pattern := projectListSearchPattern(search); pattern != "" {
|
||||
query += ` AND (LOWER(name) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(description,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(id) LIKE LOWER(?) ESCAPE '\')`
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
|
||||
// CountProjects 统计项目数量。
|
||||
func (db *DB) CountProjects(status, search string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
query, args = appendProjectListFilters(query, args, status, search)
|
||||
var count int
|
||||
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("统计项目失败: %w", err)
|
||||
@@ -139,15 +163,7 @@ func (db *DB) ListProjects(status, search string, limit, offset int) ([]*Project
|
||||
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||
FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
if s := strings.TrimSpace(status); s != "" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, s)
|
||||
}
|
||||
if q := strings.TrimSpace(search); q != "" {
|
||||
pattern := "%" + q + "%"
|
||||
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
||||
args = append(args, pattern, pattern)
|
||||
}
|
||||
query, args = appendProjectListFilters(query, args, status, search)
|
||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestListProjectsSearchCaseInsensitive(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "projects-search.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p1, err := db.CreateProject(&Project{Name: "Alpha Security Review", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, err := db.CreateProject(&Project{Name: "beta-scan", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.CreateProject(&Project{Name: "Other", Status: "archived"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
search string
|
||||
status string
|
||||
want []string
|
||||
}{
|
||||
{name: "case insensitive name", search: "alpha", status: "active", want: []string{p1.ID}},
|
||||
{name: "upper query", search: "BETA", status: "active", want: []string{p2.ID}},
|
||||
{name: "search by id substring", search: p1.ID[:8], status: "", want: []string{p1.ID}},
|
||||
{name: "status filter", search: "alpha", status: "archived", want: nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
list, err := db.ListProjects(tc.status, tc.search, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]string, 0, len(list))
|
||||
for _, p := range list {
|
||||
got = append(got, p.ID)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectListSearchPatternEscapesWildcards(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "projects-like.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p, err := db.CreateProject(&Project{Name: "100% coverage", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := db.ListProjects("active", "100%", 50, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != p.ID {
|
||||
t.Fatalf("expected exact match for literal %% query, got %#v", list)
|
||||
}
|
||||
}
|
||||
@@ -72,14 +72,17 @@ func (f VulnerabilityListFilter) appendWhere(query string, args []interface{}) (
|
||||
LOWER(COALESCE(description, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(vulnerability_type, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(target, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(proof, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(preconditions, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(reproduction_steps, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(evidence, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(impact, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(recommendation, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(retest_notes, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(conversation_id, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(conversation_tag, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(task_tag, '')) LIKE LOWER(?)
|
||||
)`
|
||||
for i := 0; i < 11; i++ {
|
||||
for i := 0; i < 14; i++ {
|
||||
args = append(args, pattern)
|
||||
}
|
||||
}
|
||||
@@ -101,9 +104,12 @@ type Vulnerability struct {
|
||||
Status string `json:"status"` // open, confirmed, fixed, false_positive, ignored
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
Proof string `json:"proof"`
|
||||
Preconditions string `json:"preconditions"`
|
||||
ReproSteps string `json:"reproduction_steps"`
|
||||
Evidence string `json:"evidence"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
RetestNotes string `json:"retest_notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -131,16 +137,16 @@ func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
||||
query := `
|
||||
INSERT INTO vulnerabilities (
|
||||
id, conversation_id, project_id, conversation_tag, task_tag, title, description, severity, status,
|
||||
vulnerability_type, target, proof, impact, recommendation,
|
||||
vulnerability_type, target, preconditions, reproduction_steps, evidence, impact, recommendation, retest_notes,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := db.Exec(
|
||||
query,
|
||||
vuln.ID, nullIfEmpty(vuln.ConversationID), nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
|
||||
vuln.Severity, vuln.Status, vuln.Type, vuln.Target,
|
||||
vuln.Proof, vuln.Impact, vuln.Recommendation,
|
||||
vuln.Preconditions, vuln.ReproSteps, vuln.Evidence, vuln.Impact, vuln.Recommendation, vuln.RetestNotes,
|
||||
vuln.CreatedAt, vuln.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -155,7 +161,9 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
||||
var vuln Vulnerability
|
||||
query := `
|
||||
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status,
|
||||
conversation_tag, task_tag, vulnerability_type, target, proof, impact, recommendation,
|
||||
conversation_tag, task_tag, vulnerability_type, target,
|
||||
COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''),
|
||||
impact, recommendation, COALESCE(retest_notes,''),
|
||||
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
|
||||
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
|
||||
created_at, updated_at
|
||||
@@ -166,7 +174,7 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
||||
err := db.QueryRow(query, id).Scan(
|
||||
&vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description,
|
||||
&vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target,
|
||||
&vuln.Proof, &vuln.Impact, &vuln.Recommendation,
|
||||
&vuln.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes,
|
||||
&vuln.TaskID, &vuln.TaskQueueID,
|
||||
&vuln.CreatedAt, &vuln.UpdatedAt,
|
||||
)
|
||||
@@ -184,7 +192,9 @@ func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
||||
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
|
||||
query := `
|
||||
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
|
||||
vulnerability_type, target, proof, impact, recommendation,
|
||||
vulnerability_type, target,
|
||||
COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''),
|
||||
impact, recommendation, COALESCE(retest_notes,''),
|
||||
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
|
||||
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
|
||||
created_at, updated_at
|
||||
@@ -209,7 +219,7 @@ func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFil
|
||||
err := rows.Scan(
|
||||
&vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description,
|
||||
&vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target,
|
||||
&vuln.Proof, &vuln.Impact, &vuln.Recommendation,
|
||||
&vuln.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes,
|
||||
&vuln.TaskID, &vuln.TaskQueueID,
|
||||
&vuln.CreatedAt, &vuln.UpdatedAt,
|
||||
)
|
||||
@@ -245,16 +255,16 @@ func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
||||
query := `
|
||||
UPDATE vulnerabilities
|
||||
SET project_id = ?, conversation_tag = ?, task_tag = ?, title = ?, description = ?, severity = ?, status = ?,
|
||||
vulnerability_type = ?, target = ?, proof = ?, impact = ?,
|
||||
recommendation = ?, updated_at = ?
|
||||
vulnerability_type = ?, target = ?, preconditions = ?, reproduction_steps = ?, evidence = ?, impact = ?,
|
||||
recommendation = ?, retest_notes = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
_, err := db.Exec(
|
||||
query,
|
||||
nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description, vuln.Severity, vuln.Status,
|
||||
vuln.Type, vuln.Target, vuln.Proof, vuln.Impact,
|
||||
vuln.Recommendation, vuln.UpdatedAt, id,
|
||||
vuln.Type, vuln.Target, vuln.Preconditions, vuln.ReproSteps, vuln.Evidence, vuln.Impact,
|
||||
vuln.Recommendation, vuln.RetestNotes, vuln.UpdatedAt, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新漏洞失败: %w", err)
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowDefinition is a persisted user-defined graph/workflow template.
|
||||
// graph_json intentionally remains opaque so users can define their own fields.
|
||||
type WorkflowDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkflowRun struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
RoleID string `json:"role_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
InputJSON string `json:"input_json,omitempty"`
|
||||
OutputJSON string `json:"output_json,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PendingHITLNodeID string `json:"pending_hitl_node_id,omitempty"`
|
||||
PendingHITLJSON string `json:"pending_hitl_json,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
type WorkflowNodeRun struct {
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Status string `json:"status"`
|
||||
InputJSON string `json:"input_json,omitempty"`
|
||||
OutputJSON string `json:"output_json,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
func scanWorkflowNodeRun(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowNodeRun, error) {
|
||||
var row WorkflowNodeRun
|
||||
var inputJSON, outputJSON, errText sql.NullString
|
||||
var finishedAt sql.NullTime
|
||||
if err := scanner.Scan(&row.ID, &row.RunID, &row.NodeID, &row.Status, &inputJSON, &outputJSON, &errText, &row.StartedAt, &finishedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.InputJSON = inputJSON.String
|
||||
row.OutputJSON = outputJSON.String
|
||||
row.Error = errText.String
|
||||
if finishedAt.Valid {
|
||||
t := finishedAt.Time
|
||||
row.FinishedAt = &t
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func scanWorkflowDefinition(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowDefinition, error) {
|
||||
var row WorkflowDefinition
|
||||
var desc sql.NullString
|
||||
var enabled int
|
||||
if err := scanner.Scan(&row.ID, &row.Name, &desc, &row.Version, &row.GraphJSON, &enabled, &row.CreatedAt, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Description = desc.String
|
||||
row.Enabled = enabled != 0
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
const workflowDefinitionColumns = `id, name, description, version, graph_json, enabled, created_at, updated_at`
|
||||
|
||||
func (db *DB) ListWorkflowDefinitions(includeDisabled bool) ([]*WorkflowDefinition, error) {
|
||||
query := "SELECT " + workflowDefinitionColumns + " FROM workflow_definitions"
|
||||
if !includeDisabled {
|
||||
query += " WHERE enabled = 1"
|
||||
}
|
||||
query += " ORDER BY updated_at DESC"
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*WorkflowDefinition
|
||||
for rows.Next() {
|
||||
wf, err := scanWorkflowDefinition(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描工作流失败: %w", err)
|
||||
}
|
||||
out = append(out, wf)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkflowDefinition(id string) (*WorkflowDefinition, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
wf, err := scanWorkflowDefinition(db.QueryRow("SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id = ?", id))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流失败: %w", err)
|
||||
}
|
||||
return wf, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpsertWorkflowDefinition(wf *WorkflowDefinition) error {
|
||||
if wf == nil {
|
||||
return fmt.Errorf("工作流为空")
|
||||
}
|
||||
wf.ID = strings.TrimSpace(wf.ID)
|
||||
wf.Name = strings.TrimSpace(wf.Name)
|
||||
if wf.ID == "" || wf.Name == "" {
|
||||
return fmt.Errorf("工作流 id 和 name 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(wf.GraphJSON) == "" {
|
||||
wf.GraphJSON = `{"nodes":[],"edges":[],"config":{}}`
|
||||
}
|
||||
if wf.Version <= 0 {
|
||||
wf.Version = 1
|
||||
}
|
||||
now := time.Now()
|
||||
existing, err := db.GetWorkflowDefinition(wf.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO workflow_definitions (id, name, description, version, graph_json, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
wf.ID, wf.Name, wf.Description, wf.Version, wf.GraphJSON, boolToInt(wf.Enabled), now, now,
|
||||
)
|
||||
} else {
|
||||
nextVersion := existing.Version + 1
|
||||
if wf.Version > existing.Version {
|
||||
nextVersion = wf.Version
|
||||
}
|
||||
_, err = db.Exec(
|
||||
`UPDATE workflow_definitions
|
||||
SET name = ?, description = ?, version = ?, graph_json = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
wf.Name, wf.Description, nextVersion, wf.GraphJSON, boolToInt(wf.Enabled), now, wf.ID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存工作流失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteWorkflowDefinition(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("工作流 id 不能为空")
|
||||
}
|
||||
if _, err := db.Exec("DELETE FROM workflow_definitions WHERE id = ?", id); err != nil {
|
||||
return fmt.Errorf("删除工作流失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateWorkflowRun(run *WorkflowRun) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("工作流运行为空")
|
||||
}
|
||||
if strings.TrimSpace(run.ID) == "" || strings.TrimSpace(run.WorkflowID) == "" {
|
||||
return fmt.Errorf("工作流运行 id 和 workflow_id 不能为空")
|
||||
}
|
||||
if run.WorkflowVersion <= 0 {
|
||||
run.WorkflowVersion = 1
|
||||
}
|
||||
if strings.TrimSpace(run.Status) == "" {
|
||||
run.Status = "running"
|
||||
}
|
||||
if run.StartedAt.IsZero() {
|
||||
run.StartedAt = time.Now()
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO workflow_runs (id, workflow_id, workflow_version, conversation_id, project_id, role_id, status, input_json, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
run.ID, run.WorkflowID, run.WorkflowVersion, nullString(run.ConversationID), nullString(run.ProjectID), nullString(run.RoleID), run.Status, run.InputJSON, run.StartedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建工作流运行失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FinishWorkflowRun(runID, status, outputJSON, errText string) error {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = "completed"
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := db.Exec(
|
||||
`UPDATE workflow_runs SET status = ?, output_json = ?, error = ?, finished_at = ? WHERE id = ?`,
|
||||
status, outputJSON, errText, now, runID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新工作流运行失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateWorkflowNodeRun(n *WorkflowNodeRun) error {
|
||||
if n == nil {
|
||||
return fmt.Errorf("工作流节点运行为空")
|
||||
}
|
||||
if strings.TrimSpace(n.ID) == "" || strings.TrimSpace(n.RunID) == "" || strings.TrimSpace(n.NodeID) == "" {
|
||||
return fmt.Errorf("节点运行 id、run_id 和 node_id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(n.Status) == "" {
|
||||
n.Status = "running"
|
||||
}
|
||||
if n.StartedAt.IsZero() {
|
||||
n.StartedAt = time.Now()
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO workflow_node_runs (id, run_id, node_id, status, input_json, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
n.ID, n.RunID, n.NodeID, n.Status, n.InputJSON, n.StartedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建工作流节点运行失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FinishWorkflowNodeRun(nodeRunID, status, outputJSON, errText string) error {
|
||||
nodeRunID = strings.TrimSpace(nodeRunID)
|
||||
if nodeRunID == "" {
|
||||
return fmt.Errorf("节点运行 id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = "completed"
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := db.Exec(
|
||||
`UPDATE workflow_node_runs SET status = ?, output_json = ?, error = ?, finished_at = ? WHERE id = ?`,
|
||||
status, outputJSON, errText, now, nodeRunID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新工作流节点运行失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ListWorkflowNodeRuns(runID string) ([]*WorkflowNodeRun, error) {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return nil, fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT id, run_id, node_id, status, input_json, output_json, error, started_at, finished_at
|
||||
FROM workflow_node_runs WHERE run_id = ? ORDER BY started_at ASC`,
|
||||
runID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流节点运行失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*WorkflowNodeRun
|
||||
for rows.Next() {
|
||||
row, err := scanWorkflowNodeRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanWorkflowRun(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowRun, error) {
|
||||
var row WorkflowRun
|
||||
var convID, projectID, roleID, inputJSON, outputJSON, errText, pendingNode, pendingJSON sql.NullString
|
||||
var finishedAt sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&row.ID, &row.WorkflowID, &row.WorkflowVersion,
|
||||
&convID, &projectID, &roleID, &row.Status,
|
||||
&inputJSON, &outputJSON, &errText,
|
||||
&pendingNode, &pendingJSON,
|
||||
&row.StartedAt, &finishedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.ConversationID = convID.String
|
||||
row.ProjectID = projectID.String
|
||||
row.RoleID = roleID.String
|
||||
row.InputJSON = inputJSON.String
|
||||
row.OutputJSON = outputJSON.String
|
||||
row.Error = errText.String
|
||||
row.PendingHITLNodeID = pendingNode.String
|
||||
row.PendingHITLJSON = pendingJSON.String
|
||||
if finishedAt.Valid {
|
||||
t := finishedAt.Time
|
||||
row.FinishedAt = &t
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
const workflowRunColumns = `id, workflow_id, workflow_version, conversation_id, project_id, role_id, status, input_json, output_json, error, pending_hitl_node_id, pending_hitl_json, started_at, finished_at`
|
||||
|
||||
func (db *DB) GetWorkflowRun(runID string) (*WorkflowRun, error) {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
row, err := scanWorkflowRun(db.QueryRow("SELECT "+workflowRunColumns+" FROM workflow_runs WHERE id = ?", runID))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流运行失败: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (db *DB) SetWorkflowRunStatus(runID, status string) error {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
_, err := db.Exec(`UPDATE workflow_runs SET status = ? WHERE id = ?`, strings.TrimSpace(status), runID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新工作流运行状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) SetWorkflowRunAwaitingHITL(runID, nodeID, pendingJSON string) error {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`UPDATE workflow_runs SET status = 'awaiting_hitl', pending_hitl_node_id = ?, pending_hitl_json = ?, finished_at = NULL WHERE id = ?`,
|
||||
strings.TrimSpace(nodeID), pendingJSON, runID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新工作流 HITL 等待状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordWorkflowRunHITLDecision stores a human decision on a paused workflow run.
|
||||
func (db *DB) RecordWorkflowRunHITLDecision(runID string, approved bool, comment string) error {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
run, err := db.GetWorkflowRun(runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run == nil {
|
||||
return fmt.Errorf("工作流运行不存在")
|
||||
}
|
||||
pending := map[string]interface{}{}
|
||||
if strings.TrimSpace(run.PendingHITLJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(run.PendingHITLJSON), &pending)
|
||||
}
|
||||
if approved {
|
||||
pending["decision"] = "approved"
|
||||
} else {
|
||||
pending["decision"] = "rejected"
|
||||
}
|
||||
pending["comment"] = strings.TrimSpace(comment)
|
||||
raw, _ := json.Marshal(pending)
|
||||
_, err = db.Exec(
|
||||
`UPDATE workflow_runs SET pending_hitl_json = ? WHERE id = ? AND status = 'awaiting_hitl'`,
|
||||
string(raw), runID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("记录工作流审批决定失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ListWorkflowRunsAwaitingHITL(limit int) ([]*WorkflowRun, error) {
|
||||
return db.ListWorkflowRunsAwaitingHITLFiltered("", limit)
|
||||
}
|
||||
|
||||
// ListWorkflowRunsAwaitingHITLFiltered returns awaiting_hitl runs, optionally scoped to a conversation.
|
||||
func (db *DB) ListWorkflowRunsAwaitingHITLFiltered(conversationID string, limit int) ([]*WorkflowRun, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if conversationID != "" {
|
||||
rows, err = db.Query(
|
||||
`SELECT `+workflowRunColumns+` FROM workflow_runs WHERE status = 'awaiting_hitl' AND conversation_id = ? ORDER BY started_at DESC LIMIT ?`,
|
||||
conversationID, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = db.Query(
|
||||
`SELECT `+workflowRunColumns+` FROM workflow_runs WHERE status = 'awaiting_hitl' ORDER BY started_at DESC LIMIT ?`,
|
||||
limit,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询等待审批的工作流运行失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*WorkflowRun
|
||||
for rows.Next() {
|
||||
row, err := scanWorkflowRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) migrateWorkflowRunsTable() error {
|
||||
cols := []struct{ name, ddl string }{
|
||||
{"pending_hitl_node_id", "ALTER TABLE workflow_runs ADD COLUMN pending_hitl_node_id TEXT"},
|
||||
{"pending_hitl_json", "ALTER TABLE workflow_runs ADD COLUMN pending_hitl_json TEXT"},
|
||||
}
|
||||
for _, col := range cols {
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('workflow_runs') WHERE name=?", col.name).Scan(&count)
|
||||
if err != nil || count > 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(col.ddl); err != nil {
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullString(v string) interface{} {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -185,8 +185,9 @@ type AgentHandler struct {
|
||||
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
|
||||
batchCronParser cron.Parser
|
||||
// hitlWhitelistSaver 侧栏「应用」HITL 时将会话增量白名单合并写入 config.yaml(可选)
|
||||
hitlWhitelistSaver HitlToolWhitelistSaver
|
||||
hitlStrategySaver HitlAuditStrategySaver
|
||||
hitlWhitelistSaver HitlToolWhitelistSaver
|
||||
hitlStrategySaver HitlAuditStrategySaver
|
||||
hitlDefaultReviewerSaver HitlDefaultReviewerSaver
|
||||
auditLLM *openai.Client
|
||||
audit *audit.Service
|
||||
}
|
||||
@@ -288,6 +289,23 @@ func (h *AgentHandler) SetHitlToolWhitelistSaver(s HitlToolWhitelistSaver) {
|
||||
h.hitlWhitelistSaver = s
|
||||
}
|
||||
|
||||
// HitlDefaultReviewerSaver 持久化全局默认审批方到 config.yaml。
|
||||
type HitlDefaultReviewerSaver interface {
|
||||
UpdateHitlDefaultReviewer(reviewer string) error
|
||||
}
|
||||
|
||||
// SetHitlDefaultReviewerSaver 设置 HITL 默认审批方落盘。
|
||||
func (h *AgentHandler) SetHitlDefaultReviewerSaver(s HitlDefaultReviewerSaver) {
|
||||
h.hitlDefaultReviewerSaver = s
|
||||
}
|
||||
|
||||
func (h *AgentHandler) hitlEffectiveDefaultReviewer() string {
|
||||
if h != nil && h.config != nil {
|
||||
return normalizeHitlReviewer(h.config.Hitl.EffectiveDefaultReviewer())
|
||||
}
|
||||
return "human"
|
||||
}
|
||||
|
||||
// HITLNeedsToolApproval 供 C2 危险任务门控:与会话侧人机协同及免审批白名单判定一致。
|
||||
func (h *AgentHandler) HITLNeedsToolApproval(conversationID, toolName string) bool {
|
||||
if h == nil || h.hitlManager == nil {
|
||||
@@ -1511,6 +1529,10 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
|
||||
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
ctx := c.Request.Context()
|
||||
var writeMu sync.Mutex
|
||||
stopKeepalive := make(chan struct{})
|
||||
go sseKeepalive(c, stopKeepalive, &writeMu)
|
||||
defer close(stopKeepalive)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -1520,12 +1542,15 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeMu.Lock()
|
||||
if _, err := c.Writer.Write(chunk); err != nil {
|
||||
writeMu.Unlock()
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
writeMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,6 +808,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled),
|
||||
zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled),
|
||||
zap.Bool("lark_enabled", h.config.Robots.Lark.Enabled),
|
||||
zap.Bool("telegram_enabled", h.config.Robots.Telegram.Enabled),
|
||||
zap.Bool("slack_enabled", h.config.Robots.Slack.Enabled),
|
||||
zap.Bool("discord_enabled", h.config.Robots.Discord.Enabled),
|
||||
zap.Bool("qq_enabled", h.config.Robots.QQ.Enabled),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1432,12 +1436,7 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
|
||||
// 更新检索器配置(如果知识库启用)
|
||||
if h.config.Knowledge.Enabled && h.retrieverUpdater != nil {
|
||||
retrievalConfig := &knowledge.RetrievalConfig{
|
||||
TopK: h.config.Knowledge.Retrieval.TopK,
|
||||
SimilarityThreshold: h.config.Knowledge.Retrieval.SimilarityThreshold,
|
||||
SubIndexFilter: h.config.Knowledge.Retrieval.SubIndexFilter,
|
||||
PostRetrieve: h.config.Knowledge.Retrieval.PostRetrieve,
|
||||
}
|
||||
retrievalConfig := knowledge.RetrievalConfigFromYAML(h.config.Knowledge.Retrieval)
|
||||
h.retrieverUpdater.UpdateConfig(retrievalConfig)
|
||||
h.logger.Info("检索器配置已更新",
|
||||
zap.Int("top_k", retrievalConfig.TopK),
|
||||
@@ -1720,6 +1719,13 @@ func updateKnowledgeConfig(doc *yaml.Node, cfg config.KnowledgeConfig) {
|
||||
setIntInMap(retrievalNode, "top_k", cfg.Retrieval.TopK)
|
||||
setFloatInMap(retrievalNode, "similarity_threshold", cfg.Retrieval.SimilarityThreshold)
|
||||
setStringInMap(retrievalNode, "sub_index_filter", cfg.Retrieval.SubIndexFilter)
|
||||
mqNode := ensureMap(retrievalNode, "multi_query")
|
||||
setIntInMap(mqNode, "max_queries", cfg.Retrieval.MultiQuery.MaxQueries)
|
||||
rerankNode := ensureMap(retrievalNode, "rerank")
|
||||
setStringInMap(rerankNode, "provider", cfg.Retrieval.Rerank.Provider)
|
||||
setStringInMap(rerankNode, "model", cfg.Retrieval.Rerank.Model)
|
||||
setStringInMap(rerankNode, "base_url", cfg.Retrieval.Rerank.BaseURL)
|
||||
setStringInMap(rerankNode, "api_key", cfg.Retrieval.Rerank.APIKey)
|
||||
postNode := ensureMap(retrievalNode, "post_retrieve")
|
||||
setIntInMap(postNode, "prefetch_top_k", cfg.Retrieval.PostRetrieve.PrefetchTopK)
|
||||
setIntInMap(postNode, "max_context_chars", cfg.Retrieval.PostRetrieve.MaxContextChars)
|
||||
@@ -1800,10 +1806,23 @@ func updateHitlConfig(doc *yaml.Node, cfg config.HitlConfig) {
|
||||
hitlNode := ensureMap(root, "hitl")
|
||||
// flow 样式 [a, b, c] 单行展示,工具多时比块序列省行数
|
||||
setFlowStringSliceInMap(hitlNode, "tool_whitelist", cfg.ToolWhitelist)
|
||||
setStringInMap(hitlNode, "default_reviewer", cfg.EffectiveDefaultReviewer())
|
||||
setStringInMap(hitlNode, "audit_agent_prompt", cfg.AuditAgentPrompt)
|
||||
setStringInMap(hitlNode, "audit_agent_prompt_review_edit", cfg.AuditAgentPromptReviewEdit)
|
||||
}
|
||||
|
||||
// UpdateHitlDefaultReviewer 更新全局默认审批方并写入 config.yaml。
|
||||
func (h *ConfigHandler) UpdateHitlDefaultReviewer(reviewer string) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.config.Hitl.DefaultReviewer = config.HitlConfig{DefaultReviewer: reviewer}.EffectiveDefaultReviewer()
|
||||
if err := h.saveConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.logger.Info("HITL 全局默认审批方已写入配置文件", zap.String("default_reviewer", h.config.Hitl.DefaultReviewer))
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateHitlAuditAgentStrategy 更新审批/审查编辑两套审计 Agent 提示词并写入 config.yaml。
|
||||
func (h *ConfigHandler) UpdateHitlAuditAgentStrategy(approvalPrompt, reviewEditPrompt string) error {
|
||||
h.mu.Lock()
|
||||
@@ -1855,6 +1874,28 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret)
|
||||
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
|
||||
setBoolInMap(larkNode, "allow_chat_id_fallback", cfg.Lark.AllowChatIDFallback)
|
||||
|
||||
telegramNode := ensureMap(robotsNode, "telegram")
|
||||
setBoolInMap(telegramNode, "enabled", cfg.Telegram.Enabled)
|
||||
setStringInMap(telegramNode, "bot_token", cfg.Telegram.BotToken)
|
||||
setStringInMap(telegramNode, "bot_username", cfg.Telegram.BotUsername)
|
||||
setBoolInMap(telegramNode, "allow_group_messages", cfg.Telegram.AllowGroupMessages)
|
||||
|
||||
slackNode := ensureMap(robotsNode, "slack")
|
||||
setBoolInMap(slackNode, "enabled", cfg.Slack.Enabled)
|
||||
setStringInMap(slackNode, "bot_token", cfg.Slack.BotToken)
|
||||
setStringInMap(slackNode, "app_token", cfg.Slack.AppToken)
|
||||
|
||||
discordNode := ensureMap(robotsNode, "discord")
|
||||
setBoolInMap(discordNode, "enabled", cfg.Discord.Enabled)
|
||||
setStringInMap(discordNode, "bot_token", cfg.Discord.BotToken)
|
||||
setBoolInMap(discordNode, "allow_guild_messages", cfg.Discord.AllowGuildMessages)
|
||||
|
||||
qqNode := ensureMap(robotsNode, "qq")
|
||||
setBoolInMap(qqNode, "enabled", cfg.QQ.Enabled)
|
||||
setStringInMap(qqNode, "app_id", cfg.QQ.AppID)
|
||||
setStringInMap(qqNode, "client_secret", cfg.QQ.ClientSecret)
|
||||
setBoolInMap(qqNode, "sandbox", cfg.QQ.Sandbox)
|
||||
}
|
||||
|
||||
func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
||||
|
||||
@@ -116,6 +116,9 @@ func (h *AgentHandler) EinoSingleAgentLoopStream(c *gin.Context) {
|
||||
"userMessageId": prep.UserMessageID,
|
||||
})
|
||||
}
|
||||
if h.runRoleWorkflowStreamIfBound(&req, prep, sendEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
var cancelWithCause context.CancelCauseFunc
|
||||
curFinalMessage := prep.FinalMessage
|
||||
@@ -385,6 +388,9 @@ func (h *AgentHandler) EinoSingleAgentLoop(c *gin.Context) {
|
||||
if h.hitlManager != nil {
|
||||
defer h.hitlManager.DeactivateConversation(prep.ConversationID)
|
||||
}
|
||||
if h.runRoleWorkflowJSONIfBound(c, &req, prep) {
|
||||
return
|
||||
}
|
||||
|
||||
var progressBuf strings.Builder
|
||||
progressCallbackRaw := func(eventType, message string, data interface{}) {
|
||||
|
||||
@@ -389,6 +389,18 @@ func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLReques
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *HITLManager) HasConversationConfig(conversationID string) (bool, error) {
|
||||
if strings.TrimSpace(conversationID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
var one int
|
||||
err := m.db.QueryRow(`SELECT 1 FROM hitl_conversation_configs WHERE conversation_id = ? LIMIT 1`, conversationID).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (m *HITLManager) waitDecision(ctx context.Context, p *pendingInterrupt, timeout time.Duration) (hitlDecision, error) {
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
@@ -427,14 +439,32 @@ func (h *AgentHandler) activateHITLForConversation(conversationID string, req *H
|
||||
return
|
||||
}
|
||||
if req == nil {
|
||||
cfg, err := h.hitlManager.LoadConversationConfig(conversationID)
|
||||
cfg, err := h.loadHITLConversationConfig(conversationID)
|
||||
if err == nil {
|
||||
req = cfg
|
||||
}
|
||||
}
|
||||
if req != nil && strings.TrimSpace(req.Reviewer) == "" {
|
||||
req.Reviewer = h.hitlEffectiveDefaultReviewer()
|
||||
}
|
||||
h.hitlManager.ActivateConversation(conversationID, h.hitlRequestWithMergedConfigWhitelist(req))
|
||||
}
|
||||
|
||||
func (h *AgentHandler) loadHITLConversationConfig(conversationID string) (*HITLRequest, error) {
|
||||
cfg, err := h.hitlManager.LoadConversationConfig(conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
has, err := h.hitlManager.HasConversationConfig(conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
cfg.Reviewer = h.hitlEffectiveDefaultReviewer()
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (h *AgentHandler) waitHITLApproval(runCtx context.Context, cancelRun context.CancelCauseFunc, conversationID, assistantMessageID, toolName, toolCallID string, payload map[string]interface{}, sendEventFunc func(eventType, message string, data interface{})) (*hitlDecision, error) {
|
||||
cfg, need := h.hitlManager.shouldInterrupt(conversationID, toolName)
|
||||
if !need {
|
||||
@@ -710,7 +740,7 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "conversationId is required"})
|
||||
return
|
||||
}
|
||||
cfg, err := h.hitlManager.LoadConversationConfig(conversationID)
|
||||
cfg, err := h.loadHITLConversationConfig(conversationID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -729,6 +759,7 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversationId": conversationID,
|
||||
"hitl": cfg,
|
||||
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
|
||||
"hitlGlobalToolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
})
|
||||
}
|
||||
@@ -741,6 +772,9 @@ func (h *AgentHandler) UpsertHITLConversationConfig(c *gin.Context) {
|
||||
}
|
||||
req.Mode = normalizeHitlMode(req.Mode)
|
||||
req.Reviewer = normalizeHitlReviewer(req.Reviewer)
|
||||
if strings.TrimSpace(req.Reviewer) == "" {
|
||||
req.Reviewer = h.hitlEffectiveDefaultReviewer()
|
||||
}
|
||||
if err := h.hitlManager.SaveConversationConfig(req.ConversationID, &req.HITLRequest); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -769,7 +803,48 @@ type setHitlGlobalWhitelistReq struct {
|
||||
// GetHITLGlobalToolWhitelist 返回 config.yaml 中的全局免审批工具白名单。
|
||||
func (h *AgentHandler) GetHITLGlobalToolWhitelist(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"toolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
"toolWhitelist": h.hitlConfigGlobalToolWhitelist(),
|
||||
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
|
||||
})
|
||||
}
|
||||
|
||||
type setHitlDefaultReviewerReq struct {
|
||||
Reviewer string `json:"reviewer"`
|
||||
}
|
||||
|
||||
// GetHITLDefaultReviewer 返回 config.yaml 中的全局默认审批方。
|
||||
func (h *AgentHandler) GetHITLDefaultReviewer(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"defaultReviewer": h.hitlEffectiveDefaultReviewer(),
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateHITLDefaultReviewer 将全局默认审批方写入 config.yaml(未选会话时切换审批方)。
|
||||
func (h *AgentHandler) UpdateHITLDefaultReviewer(c *gin.Context) {
|
||||
if h.hitlDefaultReviewerSaver == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "HITL 配置持久化不可用"})
|
||||
return
|
||||
}
|
||||
var req setHitlDefaultReviewerReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
reviewer := normalizeHitlReviewer(req.Reviewer)
|
||||
if err := h.hitlDefaultReviewerSaver.UpdateHitlDefaultReviewer(reviewer); err != nil {
|
||||
h.logger.Warn("写入 HITL 默认审批方到 config.yaml 失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if h.config != nil {
|
||||
h.config.Hitl.DefaultReviewer = reviewer
|
||||
}
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "hitl", "default_reviewer_update", "HITL 全局默认审批方更新", "hitl_config", "default_reviewer", nil)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"defaultReviewer": reviewer,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,9 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
||||
"userMessageId": prep.UserMessageID,
|
||||
})
|
||||
}
|
||||
if h.runRoleWorkflowStreamIfBound(&req, prep, sendEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
var cancelWithCause context.CancelCauseFunc
|
||||
curFinalMessage := prep.FinalMessage
|
||||
@@ -407,6 +410,9 @@ func (h *AgentHandler) MultiAgentLoop(c *gin.Context) {
|
||||
if h.hitlManager != nil {
|
||||
defer h.hitlManager.DeactivateConversation(prep.ConversationID)
|
||||
}
|
||||
if h.runRoleWorkflowJSONIfBound(c, &req, prep) {
|
||||
return
|
||||
}
|
||||
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
|
||||
defer cancelWithCause(nil)
|
||||
|
||||
+23
-23
@@ -506,7 +506,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
},
|
||||
"CreateVulnerabilityRequest": map[string]interface{}{
|
||||
"type": "object",
|
||||
"required": []string{"conversation_id", "title", "severity"},
|
||||
"required": []string{"conversation_id", "title", "description", "severity", "type", "target", "reproduction_steps", "evidence", "impact", "recommendation"},
|
||||
"properties": map[string]interface{}{
|
||||
"conversation_id": map[string]interface{}{
|
||||
"type": "string",
|
||||
@@ -538,10 +538,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "string",
|
||||
"description": "受影响的目标",
|
||||
},
|
||||
"proof": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "漏洞证明",
|
||||
},
|
||||
"preconditions": map[string]interface{}{"type": "string", "description": "前置条件"},
|
||||
"reproduction_steps": map[string]interface{}{"type": "string", "description": "复现步骤"},
|
||||
"evidence": map[string]interface{}{"type": "string", "description": "证据/POC,包含请求响应、命令输出、截图说明、日志等"},
|
||||
"impact": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "影响",
|
||||
@@ -550,6 +549,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "string",
|
||||
"description": "修复建议",
|
||||
},
|
||||
"retest_notes": map[string]interface{}{"type": "string", "description": "复测方式"},
|
||||
},
|
||||
},
|
||||
"UpdateVulnerabilityRequest": map[string]interface{}{
|
||||
@@ -581,10 +581,9 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "string",
|
||||
"description": "受影响的目标",
|
||||
},
|
||||
"proof": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "漏洞证明",
|
||||
},
|
||||
"preconditions": map[string]interface{}{"type": "string", "description": "前置条件"},
|
||||
"reproduction_steps": map[string]interface{}{"type": "string", "description": "复现步骤"},
|
||||
"evidence": map[string]interface{}{"type": "string", "description": "证据/POC,包含请求响应、命令输出、截图说明、日志等"},
|
||||
"impact": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "影响",
|
||||
@@ -593,6 +592,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "string",
|
||||
"description": "修复建议",
|
||||
},
|
||||
"retest_notes": map[string]interface{}{"type": "string", "description": "复测方式"},
|
||||
},
|
||||
},
|
||||
"ListVulnerabilitiesResponse": map[string]interface{}{
|
||||
@@ -805,18 +805,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"type": "object",
|
||||
"description": "视觉分析(analyze_image MCP 工具);enabled 且 model 非空时注册工具",
|
||||
"properties": map[string]interface{}{
|
||||
"enabled": map[string]interface{}{"type": "boolean", "description": "是否启用 analyze_image"},
|
||||
"model": map[string]interface{}{"type": "string", "description": "视觉模型名(必填)", "example": "qwen-vl-max"},
|
||||
"api_key": map[string]interface{}{"type": "string", "description": "API Key;留空复用 openai.api_key"},
|
||||
"base_url": map[string]interface{}{"type": "string", "description": "Base URL;留空复用 openai.base_url"},
|
||||
"provider": map[string]interface{}{"type": "string", "description": "提供商;留空复用 openai.provider"},
|
||||
"timeout_seconds": map[string]interface{}{"type": "integer", "description": "VL 调用超时(秒)"},
|
||||
"max_image_bytes": map[string]interface{}{"type": "integer", "description": "原始文件大小上限(字节)"},
|
||||
"max_dimension": map[string]interface{}{"type": "integer", "description": "长边缩放像素"},
|
||||
"jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"},
|
||||
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
|
||||
"skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"},
|
||||
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
|
||||
"enabled": map[string]interface{}{"type": "boolean", "description": "是否启用 analyze_image"},
|
||||
"model": map[string]interface{}{"type": "string", "description": "视觉模型名(必填)", "example": "qwen-vl-max"},
|
||||
"api_key": map[string]interface{}{"type": "string", "description": "API Key;留空复用 openai.api_key"},
|
||||
"base_url": map[string]interface{}{"type": "string", "description": "Base URL;留空复用 openai.base_url"},
|
||||
"provider": map[string]interface{}{"type": "string", "description": "提供商;留空复用 openai.provider"},
|
||||
"timeout_seconds": map[string]interface{}{"type": "integer", "description": "VL 调用超时(秒)"},
|
||||
"max_image_bytes": map[string]interface{}{"type": "integer", "description": "原始文件大小上限(字节)"},
|
||||
"max_dimension": map[string]interface{}{"type": "integer", "description": "长边缩放像素"},
|
||||
"jpeg_quality": map[string]interface{}{"type": "integer", "description": "JPEG 质量 60-100"},
|
||||
"max_payload_bytes": map[string]interface{}{"type": "integer", "description": "送 API 体积上限(字节)"},
|
||||
"skip_preprocess_below_bytes": map[string]interface{}{"type": "integer", "description": "低于该字节且尺寸合规时可原图直传;0=始终压缩"},
|
||||
"detail": map[string]interface{}{"type": "string", "enum": []string{"low", "high", "auto"}, "description": "OpenAI 兼容 image detail"},
|
||||
},
|
||||
},
|
||||
"AnalyzeImageToolCall": map[string]interface{}{
|
||||
@@ -1432,7 +1432,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
{
|
||||
"name": "id", "in": "path", "required": true,
|
||||
"description": "对话ID",
|
||||
"schema": map[string]interface{}{"type": "string"},
|
||||
"schema": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
"requestBody": map[string]interface{}{
|
||||
@@ -2570,7 +2570,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"content": map[string]interface{}{
|
||||
"application/json": map[string]interface{}{
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"type": "object",
|
||||
"required": []string{"source_fact_key", "target_fact_key", "edge_type"},
|
||||
"properties": map[string]interface{}{
|
||||
"source_fact_key": map[string]interface{}{"type": "string"},
|
||||
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// agentSessionContextBlock 注入会话工作目录、项目黑板与用户原文锚点(用于 system prompt 追加块)。
|
||||
// agentSessionContextBlock 注入会话工作目录与项目黑板(用于 system prompt 追加块)。
|
||||
// 用户输入由 message history 承载;压缩后由 summarization 摘要指令保留关键约束。
|
||||
func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||
var parts []string
|
||||
if ws := h.buildWorkspaceBlock(conversationID); ws != "" {
|
||||
@@ -16,9 +17,6 @@ func (h *AgentHandler) agentSessionContextBlock(conversationID string) string {
|
||||
if bb := h.projectBlackboardBlock(conversationID); bb != "" {
|
||||
parts = append(parts, bb)
|
||||
}
|
||||
if uv := h.userVerbatimAnchorBlock(conversationID); uv != "" {
|
||||
parts = append(parts, uv)
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
@@ -70,29 +68,6 @@ func (h *AgentHandler) projectBlackboardBlock(conversationID string) string {
|
||||
return strings.TrimSpace(block)
|
||||
}
|
||||
|
||||
// userVerbatimAnchorBlock 从 messages 表构建用户各轮原文锚点(压缩后仍由 summarization Finalize 刷新)。
|
||||
func (h *AgentHandler) userVerbatimAnchorBlock(conversationID string) string {
|
||||
if h == nil || h.db == nil || h.config == nil {
|
||||
return ""
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" {
|
||||
return ""
|
||||
}
|
||||
maxRunes := h.config.MultiAgent.UserVerbatimAnchorMaxRunesEffective()
|
||||
if maxRunes < 0 {
|
||||
return ""
|
||||
}
|
||||
msgs, err := h.db.GetMessages(conversationID)
|
||||
if err != nil {
|
||||
if h.logger != nil {
|
||||
h.logger.Warn("构建用户原文锚点失败", zap.String("conversationId", conversationID), zap.Error(err))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return project.BuildUserVerbatimAnchorBlockFromMessages(msgs, maxRunes)
|
||||
}
|
||||
|
||||
// conversationProjectID 返回对话绑定的项目 ID;未绑定或查询失败时返回空字符串。
|
||||
func (h *AgentHandler) conversationProjectID(conversationID string) string {
|
||||
if h == nil || h.db == nil {
|
||||
|
||||
@@ -45,9 +45,12 @@ type CreateVulnerabilityRequest struct {
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
Proof string `json:"proof"`
|
||||
Preconditions string `json:"preconditions"`
|
||||
ReproSteps string `json:"reproduction_steps"`
|
||||
Evidence string `json:"evidence"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
RetestNotes string `json:"retest_notes"`
|
||||
}
|
||||
|
||||
// CreateVulnerability 创建漏洞
|
||||
@@ -69,9 +72,12 @@ func (h *VulnerabilityHandler) CreateVulnerability(c *gin.Context) {
|
||||
Status: req.Status,
|
||||
Type: req.Type,
|
||||
Target: req.Target,
|
||||
Proof: req.Proof,
|
||||
Preconditions: req.Preconditions,
|
||||
ReproSteps: req.ReproSteps,
|
||||
Evidence: req.Evidence,
|
||||
Impact: req.Impact,
|
||||
Recommendation: req.Recommendation,
|
||||
RetestNotes: req.RetestNotes,
|
||||
}
|
||||
|
||||
created, err := h.db.CreateVulnerability(vuln)
|
||||
@@ -118,7 +124,7 @@ func parseVulnerabilityListFilter(c *gin.Context) database.VulnerabilityListFilt
|
||||
q = strings.TrimSpace(c.Query("search"))
|
||||
}
|
||||
return database.VulnerabilityListFilter{
|
||||
ProjectID: c.Query("project_id"),
|
||||
ProjectID: c.Query("project_id"),
|
||||
ID: c.Query("id"),
|
||||
Search: q,
|
||||
ConversationID: c.Query("conversation_id"),
|
||||
@@ -197,17 +203,20 @@ func (h *VulnerabilityHandler) ListVulnerabilities(c *gin.Context) {
|
||||
// UpdateVulnerabilityRequest 更新漏洞请求
|
||||
type UpdateVulnerabilityRequest struct {
|
||||
ProjectID *string `json:"project_id"`
|
||||
ConversationTag string `json:"conversation_tag"`
|
||||
TaskTag string `json:"task_tag"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"`
|
||||
Status string `json:"status"`
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
Proof string `json:"proof"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
ConversationTag *string `json:"conversation_tag"`
|
||||
TaskTag *string `json:"task_tag"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Severity *string `json:"severity"`
|
||||
Status *string `json:"status"`
|
||||
Type *string `json:"type"`
|
||||
Target *string `json:"target"`
|
||||
Preconditions *string `json:"preconditions"`
|
||||
ReproSteps *string `json:"reproduction_steps"`
|
||||
Evidence *string `json:"evidence"`
|
||||
Impact *string `json:"impact"`
|
||||
Recommendation *string `json:"recommendation"`
|
||||
RetestNotes *string `json:"retest_notes"`
|
||||
}
|
||||
|
||||
// UpdateVulnerability 更新漏洞
|
||||
@@ -231,38 +240,47 @@ func (h *VulnerabilityHandler) UpdateVulnerability(c *gin.Context) {
|
||||
if req.ProjectID != nil {
|
||||
existing.ProjectID = strings.TrimSpace(*req.ProjectID)
|
||||
}
|
||||
if req.ConversationTag != "" {
|
||||
existing.ConversationTag = req.ConversationTag
|
||||
if req.ConversationTag != nil {
|
||||
existing.ConversationTag = *req.ConversationTag
|
||||
}
|
||||
if req.TaskTag != "" {
|
||||
existing.TaskTag = req.TaskTag
|
||||
if req.TaskTag != nil {
|
||||
existing.TaskTag = *req.TaskTag
|
||||
}
|
||||
if req.Title != "" {
|
||||
existing.Title = req.Title
|
||||
if req.Title != nil {
|
||||
existing.Title = *req.Title
|
||||
}
|
||||
if req.Description != "" {
|
||||
existing.Description = req.Description
|
||||
if req.Description != nil {
|
||||
existing.Description = *req.Description
|
||||
}
|
||||
if req.Severity != "" {
|
||||
existing.Severity = req.Severity
|
||||
if req.Severity != nil {
|
||||
existing.Severity = *req.Severity
|
||||
}
|
||||
if req.Status != "" {
|
||||
existing.Status = req.Status
|
||||
if req.Status != nil {
|
||||
existing.Status = *req.Status
|
||||
}
|
||||
if req.Type != "" {
|
||||
existing.Type = req.Type
|
||||
if req.Type != nil {
|
||||
existing.Type = *req.Type
|
||||
}
|
||||
if req.Target != "" {
|
||||
existing.Target = req.Target
|
||||
if req.Target != nil {
|
||||
existing.Target = *req.Target
|
||||
}
|
||||
if req.Proof != "" {
|
||||
existing.Proof = req.Proof
|
||||
if req.Preconditions != nil {
|
||||
existing.Preconditions = *req.Preconditions
|
||||
}
|
||||
if req.Impact != "" {
|
||||
existing.Impact = req.Impact
|
||||
if req.ReproSteps != nil {
|
||||
existing.ReproSteps = *req.ReproSteps
|
||||
}
|
||||
if req.Recommendation != "" {
|
||||
existing.Recommendation = req.Recommendation
|
||||
if req.Evidence != nil {
|
||||
existing.Evidence = *req.Evidence
|
||||
}
|
||||
if req.Impact != nil {
|
||||
existing.Impact = *req.Impact
|
||||
}
|
||||
if req.Recommendation != nil {
|
||||
existing.Recommendation = *req.Recommendation
|
||||
}
|
||||
if req.RetestNotes != nil {
|
||||
existing.RetestNotes = *req.RetestNotes
|
||||
}
|
||||
|
||||
if err := h.db.UpdateVulnerability(id, existing); err != nil {
|
||||
@@ -495,9 +513,19 @@ func appendVulnerabilityMarkdown(b *strings.Builder, v *database.Vulnerability,
|
||||
b.WriteString(v.Description)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.Proof != "" {
|
||||
b.WriteString("\n#### 证明(POC)\n\n```\n")
|
||||
b.WriteString(v.Proof)
|
||||
if v.Preconditions != "" {
|
||||
b.WriteString("\n#### 前置条件\n\n")
|
||||
b.WriteString(v.Preconditions)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.ReproSteps != "" {
|
||||
b.WriteString("\n#### 复现步骤\n\n")
|
||||
b.WriteString(v.ReproSteps)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.Evidence != "" {
|
||||
b.WriteString("\n#### 证据 / POC\n\n```\n")
|
||||
b.WriteString(v.Evidence)
|
||||
b.WriteString("\n```\n")
|
||||
}
|
||||
if v.Impact != "" {
|
||||
@@ -510,6 +538,11 @@ func appendVulnerabilityMarkdown(b *strings.Builder, v *database.Vulnerability,
|
||||
b.WriteString(v.Recommendation)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if v.RetestNotes != "" {
|
||||
b.WriteString("\n#### 复测方式\n\n")
|
||||
b.WriteString(v.RetestNotes)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/audit"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
workflowrunner "cyberstrike-ai/internal/workflow"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type WorkflowHandler struct {
|
||||
db *database.DB
|
||||
logger *zap.Logger
|
||||
audit *audit.Service
|
||||
agent *agent.Agent
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewWorkflowHandler(db *database.DB, logger *zap.Logger) *WorkflowHandler {
|
||||
return &WorkflowHandler{db: db, logger: logger}
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) SetAudit(s *audit.Service) {
|
||||
h.audit = s
|
||||
}
|
||||
|
||||
type workflowSaveRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Graph json.RawMessage `json:"graph,omitempty"`
|
||||
GraphJSON json.RawMessage `json:"graph_json,omitempty"`
|
||||
}
|
||||
|
||||
type workflowDryRunRequest struct {
|
||||
Graph json.RawMessage `json:"graph,omitempty"`
|
||||
GraphJSON json.RawMessage `json:"graph_json,omitempty"`
|
||||
Inputs map[string]interface{} `json:"inputs,omitempty"`
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) List(c *gin.Context) {
|
||||
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
|
||||
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"workflows": items})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) Get(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
wf, err := h.db.GetWorkflowDefinition(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if wf == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "工作流不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"workflow": wf})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) Create(c *gin.Context) {
|
||||
h.save(c, "")
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) Validate(c *gin.Context) {
|
||||
var req workflowSaveRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
graph := req.Graph
|
||||
if len(graph) == 0 {
|
||||
graph = req.GraphJSON
|
||||
}
|
||||
if len(graph) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "graph 不能为空"})
|
||||
return
|
||||
}
|
||||
if !json.Valid(graph) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "graph 必须是合法 JSON"})
|
||||
return
|
||||
}
|
||||
if err := workflowrunner.ValidateGraphJSON(c.Request.Context(), string(graph)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) DryRun(c *gin.Context) {
|
||||
var req workflowDryRunRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
graph := req.Graph
|
||||
if len(graph) == 0 {
|
||||
graph = req.GraphJSON
|
||||
}
|
||||
if len(graph) == 0 || !json.Valid(graph) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "graph 必须是合法 JSON"})
|
||||
return
|
||||
}
|
||||
inputs := make(map[string]any, len(req.Inputs))
|
||||
for k, v := range req.Inputs {
|
||||
inputs[k] = v
|
||||
}
|
||||
result, err := workflowrunner.DryRunGraphJSON(c.Request.Context(), string(graph), inputs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) Update(c *gin.Context) {
|
||||
h.save(c, c.Param("id"))
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) save(c *gin.Context, pathID string) {
|
||||
var req workflowSaveRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(req.ID)
|
||||
if strings.TrimSpace(pathID) != "" {
|
||||
id = strings.TrimSpace(pathID)
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if id == "" || name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工作流 id 和 name 不能为空"})
|
||||
return
|
||||
}
|
||||
graph := req.Graph
|
||||
if len(graph) == 0 {
|
||||
graph = req.GraphJSON
|
||||
}
|
||||
if len(graph) == 0 {
|
||||
graph = []byte(`{"nodes":[],"edges":[],"config":{}}`)
|
||||
}
|
||||
if !json.Valid(graph) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "graph 必须是合法 JSON"})
|
||||
return
|
||||
}
|
||||
if err := workflowrunner.ValidateGraphJSON(c.Request.Context(), string(graph)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工作流图无法编译: " + err.Error()})
|
||||
return
|
||||
}
|
||||
var probe interface{}
|
||||
if err := json.Unmarshal(graph, &probe); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "graph JSON 解析失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
wf := &database.WorkflowDefinition{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
Version: req.Version,
|
||||
GraphJSON: string(graph),
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := h.db.UpsertWorkflowDefinition(wf); err != nil {
|
||||
if h.logger != nil {
|
||||
h.logger.Warn("保存工作流失败", zap.String("id", id), zap.Error(err))
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
saved, _ := h.db.GetWorkflowDefinition(id)
|
||||
workflowrunner.InvalidateCompiledCache(id)
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "workflow", "save", "保存图编排流程", "workflow", id, map[string]interface{}{"name": name})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "工作流已保存", "workflow": saved})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) Delete(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工作流 id 不能为空"})
|
||||
return
|
||||
}
|
||||
if err := h.db.DeleteWorkflowDefinition(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
workflowrunner.InvalidateCompiledCache(id)
|
||||
if h.audit != nil {
|
||||
h.audit.RecordOK(c, "workflow", "delete", "删除图编排流程", "workflow", id, nil)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "工作流已删除"})
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
workflowrunner "cyberstrike-ai/internal/workflow"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AgentHandler) roleForWorkflow(req *ChatRequest) (config.RoleConfig, bool) {
|
||||
if h == nil || h.config == nil || h.config.Roles == nil || req == nil {
|
||||
return config.RoleConfig{}, false
|
||||
}
|
||||
roleName := strings.TrimSpace(req.Role)
|
||||
if roleName == "" {
|
||||
return config.RoleConfig{}, false
|
||||
}
|
||||
role, ok := h.config.Roles[roleName]
|
||||
if !ok || !role.Enabled {
|
||||
return config.RoleConfig{}, false
|
||||
}
|
||||
if role.Name == "" {
|
||||
role.Name = roleName
|
||||
}
|
||||
if !workflowrunner.ShouldAutoRunRoleWorkflow(role) {
|
||||
return config.RoleConfig{}, false
|
||||
}
|
||||
return role, true
|
||||
}
|
||||
|
||||
func (h *AgentHandler) runRoleWorkflowStreamIfBound(
|
||||
req *ChatRequest,
|
||||
prep *multiAgentPrepared,
|
||||
sendEvent func(eventType, message string, data interface{}),
|
||||
) bool {
|
||||
role, ok := h.roleForWorkflow(req)
|
||||
if !ok || prep == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
conversationID := prep.ConversationID
|
||||
assistantMessageID := prep.AssistantMessageID
|
||||
userMessage := ""
|
||||
if req != nil {
|
||||
userMessage = req.Message
|
||||
}
|
||||
|
||||
taskStatus := "completed"
|
||||
taskOwned := false
|
||||
defer func() {
|
||||
if taskOwned {
|
||||
h.tasks.FinishTask(conversationID, taskStatus)
|
||||
}
|
||||
}()
|
||||
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||
defer cancelWithCause(nil)
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
|
||||
if _, err := h.tasks.StartTask(conversationID, userMessage, cancelWithCause); err != nil {
|
||||
var errorMsg string
|
||||
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||
errorMsg = "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。"
|
||||
sendEvent("error", errorMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"errorType": "task_already_running",
|
||||
})
|
||||
} else {
|
||||
errorMsg = "❌ 无法启动任务: " + err.Error()
|
||||
sendEvent("error", errorMsg, nil)
|
||||
}
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errorMsg, time.Now(), assistantMessageID)
|
||||
}
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return true
|
||||
}
|
||||
taskOwned = true
|
||||
|
||||
progress := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, sendEvent)
|
||||
result, err := workflowrunner.RunRoleBoundWorkflow(taskCtx, workflowrunner.RunArgs{
|
||||
DB: h.db,
|
||||
Logger: h.logger,
|
||||
Role: role,
|
||||
AppCfg: h.config,
|
||||
Agent: h.agent,
|
||||
ConversationID: conversationID,
|
||||
ProjectID: h.conversationProjectID(conversationID),
|
||||
UserMessage: prep.FinalMessage,
|
||||
History: prep.History,
|
||||
RoleTools: prep.RoleTools,
|
||||
AgentsMarkdownDir: h.agentsMarkdownDir,
|
||||
SystemPromptExtra: h.agentSessionContextBlock(conversationID),
|
||||
AssistantMessageID: assistantMessageID,
|
||||
Progress: progress,
|
||||
})
|
||||
if err != nil {
|
||||
cause := context.Cause(baseCtx)
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
taskStatus = "cancelled"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||
if assistantMessageID != "" {
|
||||
if err := h.appendAssistantMessageNotice(assistantMessageID, cancelMsg); err != nil {
|
||||
h.logger.Warn("更新取消后的助手消息失败", zap.Error(err))
|
||||
}
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||
}
|
||||
sendEvent("cancelled", cancelMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(context.Cause(taskCtx), context.DeadlineExceeded) {
|
||||
taskStatus = "timeout"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
timeoutMsg := "任务执行超时,已自动终止。"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", timeoutMsg, time.Now(), assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "timeout", timeoutMsg, nil)
|
||||
}
|
||||
sendEvent("error", timeoutMsg, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"messageId": assistantMessageID,
|
||||
"errorType": "timeout",
|
||||
})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return true
|
||||
}
|
||||
errMsg := "执行角色绑定流程失败: " + err.Error()
|
||||
taskStatus = "failed"
|
||||
h.tasks.UpdateTaskStatus(conversationID, taskStatus)
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "error", errMsg, nil)
|
||||
}
|
||||
sendEvent("error", errMsg, map[string]interface{}{"conversationId": conversationID})
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": conversationID})
|
||||
return true
|
||||
}
|
||||
if prep.AssistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"conversationId": prep.ConversationID,
|
||||
"messageId": prep.AssistantMessageID,
|
||||
"agentMode": "workflow",
|
||||
"workflowRunId": result.RunID,
|
||||
}
|
||||
if result.AwaitingHITL {
|
||||
payload["workflowStatus"] = "awaiting_hitl"
|
||||
payload["awaitingHitl"] = true
|
||||
}
|
||||
sendEvent("response", result.Response, payload)
|
||||
sendEvent("done", "", map[string]interface{}{"conversationId": prep.ConversationID})
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *AgentHandler) runRoleWorkflowJSONIfBound(c *gin.Context, req *ChatRequest, prep *multiAgentPrepared) bool {
|
||||
role, ok := h.roleForWorkflow(req)
|
||||
if !ok || prep == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
conversationID := prep.ConversationID
|
||||
assistantMessageID := prep.AssistantMessageID
|
||||
userMessage := ""
|
||||
if req != nil {
|
||||
userMessage = req.Message
|
||||
}
|
||||
|
||||
taskStatus := "completed"
|
||||
taskOwned := false
|
||||
defer func() {
|
||||
if taskOwned {
|
||||
h.tasks.FinishTask(conversationID, taskStatus)
|
||||
}
|
||||
}()
|
||||
|
||||
baseCtx, cancelWithCause := context.WithCancelCause(c.Request.Context())
|
||||
defer cancelWithCause(nil)
|
||||
taskCtx, timeoutCancel := context.WithTimeout(baseCtx, 600*time.Minute)
|
||||
defer timeoutCancel()
|
||||
|
||||
if _, err := h.tasks.StartTask(conversationID, userMessage, cancelWithCause); err != nil {
|
||||
if errors.Is(err, ErrTaskAlreadyRunning) {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "⚠️ 当前会话已有任务正在执行中,请等待当前任务完成或点击「停止任务」后再尝试。",
|
||||
"conversationId": conversationID,
|
||||
"errorType": "task_already_running",
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "❌ 无法启动任务: " + err.Error()})
|
||||
}
|
||||
return true
|
||||
}
|
||||
taskOwned = true
|
||||
|
||||
progress := h.createProgressCallback(taskCtx, cancelWithCause, conversationID, assistantMessageID, nil)
|
||||
result, err := workflowrunner.RunRoleBoundWorkflow(taskCtx, workflowrunner.RunArgs{
|
||||
DB: h.db,
|
||||
Logger: h.logger,
|
||||
Role: role,
|
||||
AppCfg: h.config,
|
||||
Agent: h.agent,
|
||||
ConversationID: conversationID,
|
||||
ProjectID: h.conversationProjectID(conversationID),
|
||||
UserMessage: prep.FinalMessage,
|
||||
History: prep.History,
|
||||
RoleTools: prep.RoleTools,
|
||||
AgentsMarkdownDir: h.agentsMarkdownDir,
|
||||
SystemPromptExtra: h.agentSessionContextBlock(conversationID),
|
||||
AssistantMessageID: assistantMessageID,
|
||||
Progress: progress,
|
||||
})
|
||||
if err != nil {
|
||||
cause := context.Cause(baseCtx)
|
||||
if errors.Is(cause, ErrTaskCancelled) {
|
||||
taskStatus = "cancelled"
|
||||
cancelMsg := "任务已被用户取消,后续操作已停止。"
|
||||
if assistantMessageID != "" {
|
||||
_ = h.appendAssistantMessageNotice(assistantMessageID, cancelMsg)
|
||||
_ = h.db.AddProcessDetail(assistantMessageID, conversationID, "cancelled", cancelMsg, nil)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "cancelled",
|
||||
"message": cancelMsg,
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
return true
|
||||
}
|
||||
errMsg := "执行角色绑定流程失败: " + err.Error()
|
||||
taskStatus = "failed"
|
||||
if assistantMessageID != "" {
|
||||
_, _ = h.db.Exec("UPDATE messages SET content = ?, updated_at = ? WHERE id = ?", errMsg, time.Now(), assistantMessageID)
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": errMsg, "conversationId": conversationID})
|
||||
return true
|
||||
}
|
||||
if prep.AssistantMessageID != "" {
|
||||
_ = h.db.UpdateAssistantMessageFinalize(prep.AssistantMessageID, result.Response, nil, "")
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"response": result.Response,
|
||||
"conversationId": prep.ConversationID,
|
||||
"assistantMessageId": prep.AssistantMessageID,
|
||||
"agentMode": "workflow",
|
||||
"workflowRunId": result.RunID,
|
||||
"workflowStatus": result.Status,
|
||||
"awaitingHitl": result.AwaitingHITL,
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
workflowrunner "cyberstrike-ai/internal/workflow"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *WorkflowHandler) SetRuntime(agent *agent.Agent, cfg *config.Config) {
|
||||
h.agent = agent
|
||||
h.cfg = cfg
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) GetRun(c *gin.Context) {
|
||||
runID := strings.TrimSpace(c.Param("runId"))
|
||||
run, err := h.db.GetWorkflowRun(runID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if run == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "工作流运行不存在"})
|
||||
return
|
||||
}
|
||||
nodeRuns, err := h.db.ListWorkflowNodeRuns(runID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"run": run, "nodeRuns": nodeRuns})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) ReplayRun(c *gin.Context) {
|
||||
runID := strings.TrimSpace(c.Param("runId"))
|
||||
nodeRuns, err := h.db.ListWorkflowNodeRuns(runID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
steps := make([]gin.H, 0, len(nodeRuns))
|
||||
for i, nodeRun := range nodeRuns {
|
||||
var input any
|
||||
var output any
|
||||
_ = json.Unmarshal([]byte(nodeRun.InputJSON), &input)
|
||||
_ = json.Unmarshal([]byte(nodeRun.OutputJSON), &output)
|
||||
steps = append(steps, gin.H{
|
||||
"step": i + 1,
|
||||
"nodeRunId": nodeRun.ID,
|
||||
"nodeId": nodeRun.NodeID,
|
||||
"status": nodeRun.Status,
|
||||
"input": input,
|
||||
"output": output,
|
||||
"error": nodeRun.Error,
|
||||
"startedAt": nodeRun.StartedAt,
|
||||
"finishedAt": nodeRun.FinishedAt,
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"workflowRunId": runID, "steps": steps})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) ListPendingRuns(c *gin.Context) {
|
||||
conversationID := strings.TrimSpace(c.Query("conversationId"))
|
||||
runs, err := h.db.ListWorkflowRunsAwaitingHITLFiltered(conversationID, 50)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"runs": runs})
|
||||
}
|
||||
|
||||
type workflowResumeRequest struct {
|
||||
Approved bool `json:"approved"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) ResumeRun(c *gin.Context) {
|
||||
if h.agent == nil || h.cfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "工作流运行时未初始化"})
|
||||
return
|
||||
}
|
||||
runID := strings.TrimSpace(c.Param("runId"))
|
||||
var req workflowResumeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
run, err := h.db.GetWorkflowRun(runID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if run == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "工作流运行不存在"})
|
||||
return
|
||||
}
|
||||
role := config.RoleConfig{Name: strings.TrimSpace(run.RoleID)}
|
||||
if role.Name != "" && h.cfg.Roles != nil {
|
||||
if r, ok := h.cfg.Roles[role.Name]; ok {
|
||||
role = r
|
||||
if role.Name == "" {
|
||||
role.Name = run.RoleID
|
||||
}
|
||||
}
|
||||
}
|
||||
if run.Status != "awaiting_hitl" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "工作流运行不在等待审批状态: " + run.Status})
|
||||
return
|
||||
}
|
||||
if err := h.db.RecordWorkflowRunHITLDecision(runID, req.Approved, req.Comment); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
decision := workflowrunner.HITLDecision{
|
||||
Approved: req.Approved,
|
||||
Comment: strings.TrimSpace(req.Comment),
|
||||
}
|
||||
delegated := workflowrunner.NotifyHITLDecision(runID, decision)
|
||||
if !delegated {
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if workflowrunner.NotifyHITLDecision(runID, decision) {
|
||||
delegated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if delegated {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"workflowRunId": runID,
|
||||
"status": "delegated",
|
||||
"streamResuming": true,
|
||||
"approved": req.Approved,
|
||||
})
|
||||
return
|
||||
}
|
||||
result, err := workflowrunner.ResumeWorkflowRun(c.Request.Context(), workflowrunner.RunArgs{
|
||||
DB: h.db,
|
||||
Logger: h.logger,
|
||||
Role: role,
|
||||
AppCfg: h.cfg,
|
||||
Agent: h.agent,
|
||||
ConversationID: run.ConversationID,
|
||||
ProjectID: run.ProjectID,
|
||||
}, runID, req.Approved, req.Comment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"response": result.Response,
|
||||
"workflowRunId": result.RunID,
|
||||
"status": result.Status,
|
||||
"awaitingHitl": result.AwaitingHITL,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/cloudwego/eino/callbacks"
|
||||
"github.com/cloudwego/eino/components"
|
||||
"github.com/cloudwego/eino/components/retriever"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// knowledgePipelineRetriever: MultiQuery → vector candidates → rerank → post-process.
|
||||
type knowledgePipelineRetriever struct {
|
||||
inner retriever.Retriever
|
||||
base *Retriever
|
||||
}
|
||||
|
||||
func newKnowledgePipelineRetriever(inner retriever.Retriever, base *Retriever) *knowledgePipelineRetriever {
|
||||
if inner == nil || base == nil {
|
||||
return nil
|
||||
}
|
||||
return &knowledgePipelineRetriever{inner: inner, base: base}
|
||||
}
|
||||
|
||||
func (p *knowledgePipelineRetriever) GetType() string {
|
||||
return "KnowledgeRAGPipeline"
|
||||
}
|
||||
|
||||
func (p *knowledgePipelineRetriever) Retrieve(ctx context.Context, query string, opts ...retriever.Option) (out []*schema.Document, err error) {
|
||||
if p == nil || p.inner == nil || p.base == nil {
|
||||
return nil, fmt.Errorf("knowledge pipeline retriever: nil")
|
||||
}
|
||||
q := strings.TrimSpace(query)
|
||||
if q == "" {
|
||||
return nil, fmt.Errorf("查询不能为空")
|
||||
}
|
||||
|
||||
ro := retriever.GetCommonOptions(nil, opts...)
|
||||
finalTopK := p.base.config.TopK
|
||||
if finalTopK <= 0 {
|
||||
finalTopK = 5
|
||||
}
|
||||
if ro.TopK != nil && *ro.TopK > 0 {
|
||||
finalTopK = *ro.TopK
|
||||
}
|
||||
|
||||
ctx = callbacks.EnsureRunInfo(ctx, p.GetType(), components.ComponentOfRetriever)
|
||||
ctx = callbacks.OnStart(ctx, &retriever.CallbackInput{Query: q, TopK: finalTopK, Extra: ro.DSLInfo})
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = callbacks.OnError(ctx, err)
|
||||
return
|
||||
}
|
||||
_ = callbacks.OnEnd(ctx, &retriever.CallbackOutput{Docs: out})
|
||||
}()
|
||||
|
||||
out, err = p.inner.Retrieve(ctx, q, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
if rr := p.base.documentReranker(); rr != nil && len(out) > 1 {
|
||||
reranked, rerr := rr.Rerank(ctx, q, out)
|
||||
if rerr != nil {
|
||||
if p.base.logger != nil {
|
||||
p.base.logger.Warn("知识检索重排失败,已使用融合序", zap.Error(rerr))
|
||||
}
|
||||
} else if len(reranked) > 0 {
|
||||
out = reranked
|
||||
}
|
||||
}
|
||||
|
||||
tokenModel := ""
|
||||
if p.base.embedder != nil {
|
||||
tokenModel = p.base.embedder.EmbeddingModelName()
|
||||
}
|
||||
var postPO *config.PostRetrieveConfig
|
||||
if p.base.config != nil {
|
||||
postPO = &p.base.config.PostRetrieve
|
||||
}
|
||||
out, err = ApplyPostRetrieve(out, postPO, tokenModel, finalTopK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var _ retriever.Retriever = (*knowledgePipelineRetriever)(nil)
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// BuildKnowledgeRetrieveChain 编译「查询字符串 → 文档列表」的 Eino Chain,底层为 SQLite 向量检索([VectorEinoRetriever])。
|
||||
// 去重、上下文预算截断与最终 Top-K 均在 [VectorEinoRetriever.Retrieve] 内完成,与 HTTP/MCP 检索路径一致。
|
||||
// BuildKnowledgeRetrieveChain 编译「查询字符串 → 文档列表」的 Eino Chain(MultiQuery → 向量 → 重排 → 后处理)。
|
||||
func BuildKnowledgeRetrieveChain(ctx context.Context, r *Retriever) (compose.Runnable[string, []*schema.Document], error) {
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("retriever is nil")
|
||||
|
||||
@@ -11,19 +11,10 @@ import (
|
||||
"github.com/cloudwego/eino/components"
|
||||
"github.com/cloudwego/eino/components/retriever"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// VectorEinoRetriever implements [retriever.Retriever] on top of SQLite-stored embeddings + cosine similarity.
|
||||
//
|
||||
// Options:
|
||||
// - [retriever.WithTopK]
|
||||
// - [retriever.WithDSLInfo] with [DSLRiskType] (string), [DSLSimilarityThreshold] (float, cosine 0–1), [DSLSubIndexFilter] (string)
|
||||
//
|
||||
// Document scores are cosine similarity; [retriever.WithScoreThreshold] is not mapped to a different metric.
|
||||
//
|
||||
// After vector search: optional [DocumentReranker] (see [Retriever.SetDocumentReranker]), then
|
||||
// [ApplyPostRetrieve] (normalized-text dedupe, context budget, final Top-K) using [config.PostRetrieveConfig].
|
||||
// It returns prefetch-sized vector candidates only; rerank and post-process run in [knowledgePipelineRetriever].
|
||||
type VectorEinoRetriever struct {
|
||||
inner *Retriever
|
||||
}
|
||||
@@ -119,26 +110,6 @@ func (h *VectorEinoRetriever) Retrieve(ctx context.Context, query string, opts .
|
||||
return nil, err
|
||||
}
|
||||
out = retrievalResultsToDocuments(results)
|
||||
|
||||
if rr := h.inner.documentReranker(); rr != nil && len(out) > 1 {
|
||||
reranked, rerr := rr.Rerank(ctx, q, out)
|
||||
if rerr != nil {
|
||||
if h.inner.logger != nil {
|
||||
h.inner.logger.Warn("知识检索重排失败,已使用向量序", zap.Error(rerr))
|
||||
}
|
||||
} else if len(reranked) > 0 {
|
||||
out = reranked
|
||||
}
|
||||
}
|
||||
|
||||
tokenModel := ""
|
||||
if h.inner.embedder != nil {
|
||||
tokenModel = h.inner.embedder.EmbeddingModelName()
|
||||
}
|
||||
out, err = ApplyPostRetrieve(out, postPO, tokenModel, finalTopK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// HTTPReranker calls a hosted rerank API (DashScope or Cohere-compatible).
|
||||
type HTTPReranker struct {
|
||||
provider string
|
||||
model string
|
||||
baseURL string
|
||||
apiKey string
|
||||
client *http.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewHTTPReranker builds a rerank client from knowledge retrieval config; openAI supplies fallback credentials.
|
||||
func NewHTTPReranker(rc *config.RerankConfig, openAI *config.OpenAIConfig, logger *zap.Logger) (*HTTPReranker, error) {
|
||||
if rc == nil {
|
||||
return nil, fmt.Errorf("rerank config is nil")
|
||||
}
|
||||
baseURL := strings.TrimSpace(rc.BaseURL)
|
||||
apiKey := strings.TrimSpace(rc.APIKey)
|
||||
if openAI != nil {
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSpace(openAI.BaseURL)
|
||||
}
|
||||
if apiKey == "" {
|
||||
apiKey = strings.TrimSpace(openAI.APIKey)
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("rerank api_key is required")
|
||||
}
|
||||
provider := rc.ProviderEffective(baseURL)
|
||||
model := rc.ModelEffective(provider)
|
||||
return &HTTPReranker{
|
||||
provider: provider,
|
||||
model: model,
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
client: &http.Client{Timeout: 60 * time.Second},
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *HTTPReranker) Rerank(ctx context.Context, query string, docs []*schema.Document) ([]*schema.Document, error) {
|
||||
if r == nil {
|
||||
return docs, nil
|
||||
}
|
||||
q := strings.TrimSpace(query)
|
||||
if q == "" || len(docs) == 0 {
|
||||
return docs, nil
|
||||
}
|
||||
if len(docs) == 1 {
|
||||
return docs, nil
|
||||
}
|
||||
texts := make([]string, 0, len(docs))
|
||||
for _, d := range docs {
|
||||
if d == nil {
|
||||
texts = append(texts, "")
|
||||
continue
|
||||
}
|
||||
texts = append(texts, d.Content)
|
||||
}
|
||||
var order []int
|
||||
var err error
|
||||
switch r.provider {
|
||||
case "dashscope":
|
||||
order, err = r.rerankDashScope(ctx, q, texts, len(docs))
|
||||
default:
|
||||
order, err = r.rerankCohere(ctx, q, texts, len(docs))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*schema.Document, 0, len(order))
|
||||
for _, idx := range order {
|
||||
if idx < 0 || idx >= len(docs) || docs[idx] == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, docs[idx])
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return docs, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *HTTPReranker) rerankCohere(ctx context.Context, query string, documents []string, topN int) ([]int, error) {
|
||||
url := r.cohereRerankURL()
|
||||
body := map[string]any{
|
||||
"model": r.model,
|
||||
"query": query,
|
||||
"documents": documents,
|
||||
"top_n": topN,
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+r.apiKey)
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rerank request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("rerank http %d: %s", resp.StatusCode, truncateForRerankLog(string(respBody)))
|
||||
}
|
||||
var parsed struct {
|
||||
Results []struct {
|
||||
Index int `json:"index"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("rerank decode: %w", err)
|
||||
}
|
||||
order := make([]int, 0, len(parsed.Results))
|
||||
for _, row := range parsed.Results {
|
||||
order = append(order, row.Index)
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (r *HTTPReranker) rerankDashScope(ctx context.Context, query string, documents []string, topN int) ([]int, error) {
|
||||
url := r.dashscopeRerankURL()
|
||||
body := map[string]any{
|
||||
"model": r.model,
|
||||
"input": map[string]any{
|
||||
"query": query,
|
||||
"documents": documents,
|
||||
},
|
||||
"parameters": map[string]any{
|
||||
"return_documents": false,
|
||||
"top_n": topN,
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+r.apiKey)
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dashscope rerank: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("dashscope rerank http %d: %s", resp.StatusCode, truncateForRerankLog(string(respBody)))
|
||||
}
|
||||
var parsed struct {
|
||||
Output struct {
|
||||
Results []struct {
|
||||
Index int `json:"index"`
|
||||
} `json:"results"`
|
||||
} `json:"output"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("dashscope rerank decode: %w", err)
|
||||
}
|
||||
order := make([]int, 0, len(parsed.Output.Results))
|
||||
for _, row := range parsed.Output.Results {
|
||||
order = append(order, row.Index)
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (r *HTTPReranker) cohereRerankURL() string {
|
||||
base := r.baseURL
|
||||
if base == "" {
|
||||
base = "https://api.cohere.com"
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
return base + "/rerank"
|
||||
}
|
||||
return base + "/v1/rerank"
|
||||
}
|
||||
|
||||
func (r *HTTPReranker) dashscopeRerankURL() string {
|
||||
base := strings.TrimSpace(r.baseURL)
|
||||
if base == "" {
|
||||
return "https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank"
|
||||
}
|
||||
if strings.Contains(base, "/api/v1/services/rerank") {
|
||||
return base
|
||||
}
|
||||
if strings.Contains(base, "dashscope.aliyuncs.com") || strings.Contains(base, "compatible-mode") {
|
||||
return "https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank"
|
||||
}
|
||||
return strings.TrimSuffix(base, "/")
|
||||
}
|
||||
|
||||
func truncateForRerankLog(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > 512 {
|
||||
return s[:512] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
var _ DocumentReranker = (*HTTPReranker)(nil)
|
||||
@@ -0,0 +1,97 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestHTTPReranker_CohereOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/rerank" {
|
||||
t.Fatalf("path %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"results": []map[string]any{
|
||||
{"index": 2, "relevance_score": 0.9},
|
||||
{"index": 0, "relevance_score": 0.5},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rr, err := NewHTTPReranker(&config.RerankConfig{
|
||||
Provider: "cohere",
|
||||
Model: "rerank-multilingual-v3.0",
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "test-key",
|
||||
}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs := []*schema.Document{
|
||||
{ID: "a", Content: "alpha"},
|
||||
{ID: "b", Content: "beta"},
|
||||
{ID: "c", Content: "gamma"},
|
||||
}
|
||||
out, err := rr.Rerank(context.Background(), "query", docs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 2 || out[0].ID != "c" || out[1].ID != "a" {
|
||||
t.Fatalf("order wrong: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPReranker_DashScopeOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"output": map[string]any{
|
||||
"results": []map[string]any{
|
||||
{"index": 1, "relevance_score": 0.88},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rr, err := NewHTTPReranker(&config.RerankConfig{
|
||||
Provider: "dashscope",
|
||||
Model: "gte-rerank",
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "test-key",
|
||||
}, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs := []*schema.Document{{ID: "a", Content: "a"}, {ID: "b", Content: "b"}}
|
||||
out, err := rr.Rerank(context.Background(), "q", docs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 1 || out[0].ID != "b" {
|
||||
t.Fatalf("got %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerankConfigDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
rc := config.RerankConfig{}
|
||||
if rc.ProviderEffective("https://dashscope.aliyuncs.com/x") != "dashscope" {
|
||||
t.Fatal("dashscope detect")
|
||||
}
|
||||
if rc.ModelEffective("dashscope") != "gte-rerank" {
|
||||
t.Fatal("dashscope model")
|
||||
}
|
||||
if rc.ModelEffective("cohere") != "rerank-multilingual-v3.0" {
|
||||
t.Fatal("cohere model")
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// postRetrieveMaxPrefetchCap 限制单次向量候选上限,避免误配置导致全表扫压力过大。
|
||||
const postRetrieveMaxPrefetchCap = 200
|
||||
|
||||
// DocumentReranker 可选重排(如交叉编码器 / 第三方 Rerank API),由 [Retriever.SetDocumentReranker] 注入;失败时在适配层降级为向量序。
|
||||
// DocumentReranker 精排(HTTP dashscope / Cohere 兼容 API),由 [WireRetrieverPipeline] 注入。
|
||||
type DocumentReranker interface {
|
||||
Rerank(ctx context.Context, query string, docs []*schema.Document) ([]*schema.Document, error)
|
||||
}
|
||||
@@ -167,13 +167,16 @@ func truncateDocumentsByBudget(docs []*schema.Document, maxRunes, maxTokens int,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EffectivePrefetchTopK 计算向量检索应拉取的候选条数(供粗排 / 去重 / 重排)。
|
||||
// EffectivePrefetchTopK 计算每条 MultiQuery 变体在向量阶段的候选条数(供融合 / 重排 / 后处理)。
|
||||
func EffectivePrefetchTopK(topK int, po *config.PostRetrieveConfig) int {
|
||||
if topK < 1 {
|
||||
topK = 5
|
||||
}
|
||||
fetch := topK
|
||||
if po != nil && po.PrefetchTopK > fetch {
|
||||
fetch := topK * 4
|
||||
if fetch < 20 {
|
||||
fetch = 20
|
||||
}
|
||||
if po != nil && po.PrefetchTopK > 0 {
|
||||
fetch = po.PrefetchTopK
|
||||
}
|
||||
if fetch > postRetrieveMaxPrefetchCap {
|
||||
@@ -182,7 +185,7 @@ func EffectivePrefetchTopK(topK int, po *config.PostRetrieveConfig) int {
|
||||
return fetch
|
||||
}
|
||||
|
||||
// ApplyPostRetrieve 检索后处理:规范化正文去重 → 预算截断 → 最终 TopK。重排在 [VectorEinoRetriever] 中单独调用以便失败时降级。
|
||||
// ApplyPostRetrieve 检索后处理:规范化正文去重 → 预算截断 → 最终 TopK(精排已在流水线中完成)。
|
||||
func ApplyPostRetrieve(docs []*schema.Document, po *config.PostRetrieveConfig, tokenModel string, finalTopK int) ([]*schema.Document, error) {
|
||||
if finalTopK < 1 {
|
||||
finalTopK = 5
|
||||
|
||||
@@ -28,8 +28,8 @@ func TestDedupeByNormalizedContent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEffectivePrefetchTopK(t *testing.T) {
|
||||
if g := EffectivePrefetchTopK(5, nil); g != 5 {
|
||||
t.Fatalf("got %d", g)
|
||||
if g := EffectivePrefetchTopK(5, nil); g != 20 {
|
||||
t.Fatalf("default prefetch got %d want 20", g)
|
||||
}
|
||||
if g := EffectivePrefetchTopK(5, &config.PostRetrieveConfig{PrefetchTopK: 50}); g != 50 {
|
||||
t.Fatalf("got %d", g)
|
||||
|
||||
@@ -27,15 +27,19 @@ type Retriever struct {
|
||||
|
||||
rerankMu sync.RWMutex
|
||||
reranker DocumentReranker
|
||||
|
||||
pipeline retriever.Retriever
|
||||
wireOpenAI *config.OpenAIConfig
|
||||
}
|
||||
|
||||
// RetrievalConfig 检索配置
|
||||
type RetrievalConfig struct {
|
||||
TopK int
|
||||
SimilarityThreshold float64
|
||||
// SubIndexFilter 非空时仅检索 sub_indexes 包含该标签(逗号分隔之一)的行;空 sub_indexes 的旧行仍保留以兼容。
|
||||
SubIndexFilter string
|
||||
PostRetrieve config.PostRetrieveConfig
|
||||
SubIndexFilter string
|
||||
MultiQuery config.MultiQueryConfig
|
||||
Rerank config.RerankConfig
|
||||
PostRetrieve config.PostRetrieveConfig
|
||||
}
|
||||
|
||||
// NewRetriever 创建新的检索器
|
||||
@@ -48,7 +52,7 @@ func NewRetriever(db *sql.DB, embedder *Embedder, config *RetrievalConfig, logge
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateConfig 更新检索配置
|
||||
// UpdateConfig 更新检索配置并重建 Eino MultiQuery + 重排流水线。
|
||||
func (r *Retriever) UpdateConfig(cfg *RetrievalConfig) {
|
||||
if cfg != nil {
|
||||
r.config = cfg
|
||||
@@ -57,12 +61,18 @@ func (r *Retriever) UpdateConfig(cfg *RetrievalConfig) {
|
||||
zap.Int("top_k", cfg.TopK),
|
||||
zap.Float64("similarity_threshold", cfg.SimilarityThreshold),
|
||||
zap.String("sub_index_filter", cfg.SubIndexFilter),
|
||||
zap.Int("multi_query_max", cfg.MultiQuery.MaxQueriesEffective()),
|
||||
zap.Int("post_retrieve_prefetch_top_k", cfg.PostRetrieve.PrefetchTopK),
|
||||
zap.Int("post_retrieve_max_context_chars", cfg.PostRetrieve.MaxContextChars),
|
||||
zap.Int("post_retrieve_max_context_tokens", cfg.PostRetrieve.MaxContextTokens),
|
||||
)
|
||||
}
|
||||
}
|
||||
if r.wireOpenAI != nil {
|
||||
if err := WireRetrieverPipeline(context.Background(), r, r.wireOpenAI); err != nil && r.logger != nil {
|
||||
r.logger.Warn("检索流水线重建失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetDocumentReranker 注入可选重排器(并发安全);nil 表示禁用。
|
||||
@@ -103,7 +113,7 @@ func cosineSimilarity(a, b []float32) float64 {
|
||||
return dotProduct / (math.Sqrt(normA) * math.Sqrt(normB))
|
||||
}
|
||||
|
||||
// Search 搜索知识库。统一经 [VectorEinoRetriever](Eino retriever.Retriever 边界)。
|
||||
// Search 搜索知识库(Eino MultiQuery → 向量检索 → 重排 → 后处理)。
|
||||
func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*RetrievalResult, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("请求不能为空")
|
||||
@@ -113,7 +123,7 @@ func (r *Retriever) Search(ctx context.Context, req *SearchRequest) ([]*Retrieva
|
||||
return nil, fmt.Errorf("查询不能为空")
|
||||
}
|
||||
opts := r.einoRetrieverOptions(req)
|
||||
docs, err := NewVectorEinoRetriever(r).Retrieve(ctx, q, opts...)
|
||||
docs, err := r.activeEinoRetriever().Retrieve(ctx, q, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -143,7 +153,19 @@ func (r *Retriever) einoRetrieverOptions(req *SearchRequest) []retriever.Option
|
||||
|
||||
// EinoRetrieve 直接返回 [schema.Document],供 Eino Graph / Chain 使用。
|
||||
func (r *Retriever) EinoRetrieve(ctx context.Context, query string, opts ...retriever.Option) ([]*schema.Document, error) {
|
||||
return NewVectorEinoRetriever(r).Retrieve(ctx, query, opts...)
|
||||
return r.activeEinoRetriever().Retrieve(ctx, query, opts...)
|
||||
}
|
||||
|
||||
func (r *Retriever) activeEinoRetriever() retriever.Retriever {
|
||||
if r != nil && r.pipeline != nil {
|
||||
return r.pipeline
|
||||
}
|
||||
return NewVectorEinoRetriever(r)
|
||||
}
|
||||
|
||||
// AsEinoRetriever 将知识库检索流水线暴露为 Eino [retriever.Retriever]。
|
||||
func (r *Retriever) AsEinoRetriever() retriever.Retriever {
|
||||
return r.activeEinoRetriever()
|
||||
}
|
||||
|
||||
func (r *Retriever) knowledgeEmbeddingSelectSQL(riskType, subIndexFilter string) (string, []interface{}) {
|
||||
@@ -299,7 +321,14 @@ func (r *Retriever) vectorSearch(ctx context.Context, req *SearchRequest) ([]*Re
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// AsEinoRetriever 将纯向量检索暴露为 Eino [retriever.Retriever]。
|
||||
func (r *Retriever) AsEinoRetriever() retriever.Retriever {
|
||||
return NewVectorEinoRetriever(r)
|
||||
// RetrievalConfigFromYAML maps API/YAML retrieval settings into the knowledge package.
|
||||
func RetrievalConfigFromYAML(r config.RetrievalConfig) *RetrievalConfig {
|
||||
return &RetrievalConfig{
|
||||
TopK: r.TopK,
|
||||
SimilarityThreshold: r.SimilarityThreshold,
|
||||
SubIndexFilter: r.SubIndexFilter,
|
||||
MultiQuery: r.MultiQuery,
|
||||
Rerank: r.Rerank,
|
||||
PostRetrieve: r.PostRetrieve,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package knowledge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
|
||||
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/flow/retriever/multiquery"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WireRetrieverPipeline builds Eino MultiQuery + HTTP rerank + post-process pipeline on r.
|
||||
// Call once after NewRetriever; UpdateConfig re-invokes when wireOpenAI is set.
|
||||
func WireRetrieverPipeline(ctx context.Context, r *Retriever, openAI *config.OpenAIConfig) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("retriever is nil")
|
||||
}
|
||||
if openAI == nil {
|
||||
return fmt.Errorf("openai config is nil")
|
||||
}
|
||||
if r.config == nil {
|
||||
return fmt.Errorf("retrieval config is nil")
|
||||
}
|
||||
r.wireOpenAI = openAI
|
||||
|
||||
httpClient := openai.NewEinoHTTPClient(openAI, &http.Client{Timeout: 120 * time.Second})
|
||||
chatCfg := &einoopenai.ChatModelConfig{
|
||||
APIKey: strings.TrimSpace(openAI.APIKey),
|
||||
BaseURL: strings.TrimSuffix(strings.TrimSpace(openAI.BaseURL), "/"),
|
||||
Model: strings.TrimSpace(openAI.Model),
|
||||
HTTPClient: httpClient,
|
||||
}
|
||||
if chatCfg.Model == "" {
|
||||
chatCfg.Model = "gpt-4o"
|
||||
}
|
||||
rewriteLLM, err := einoopenai.NewChatModel(ctx, chatCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("multi_query rewrite model: %w", err)
|
||||
}
|
||||
|
||||
reranker, err := NewHTTPReranker(&r.config.Rerank, openAI, r.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reranker: %w", err)
|
||||
}
|
||||
r.SetDocumentReranker(reranker)
|
||||
|
||||
vec := NewVectorEinoRetriever(r)
|
||||
mq, err := multiquery.NewRetriever(ctx, &multiquery.Config{
|
||||
RewriteLLM: rewriteLLM,
|
||||
MaxQueriesNum: r.config.MultiQuery.MaxQueriesEffective(),
|
||||
OrigRetriever: vec,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("multi_query: %w", err)
|
||||
}
|
||||
|
||||
r.pipeline = newKnowledgePipelineRetriever(mq, r)
|
||||
if r.logger != nil {
|
||||
provider := r.config.Rerank.ProviderEffective(strings.TrimSpace(openAI.BaseURL))
|
||||
r.logger.Info("知识库检索流水线已启用",
|
||||
zap.String("pipeline", "MultiQuery→Vector→Rerank→PostRetrieve"),
|
||||
zap.Int("multi_query_max", r.config.MultiQuery.MaxQueriesEffective()),
|
||||
zap.String("rerank_provider", provider),
|
||||
zap.String("rerank_model", r.config.Rerank.ModelEffective(provider)),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -80,34 +80,9 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
||||
return nil, fmt.Errorf("plan_execute replanner: %w", err)
|
||||
}
|
||||
|
||||
// 组装 executor handler 栈,顺序与 Deep/Supervisor 主代理一致(outermost first)。
|
||||
var execHandlers []adk.ChatModelAgentMiddleware
|
||||
// 1. patchtoolcalls, reduction, toolsearch, plantask(来自 prependEinoMiddlewares)
|
||||
if len(a.ExecPreMiddlewares) > 0 {
|
||||
execHandlers = append(execHandlers, a.ExecPreMiddlewares...)
|
||||
}
|
||||
// 2. filesystem 中间件(可选)
|
||||
if a.FilesystemMiddleware != nil {
|
||||
execHandlers = append(execHandlers, a.FilesystemMiddleware)
|
||||
}
|
||||
// 3. skill 中间件(可选)
|
||||
if a.SkillMiddleware != nil {
|
||||
execHandlers = append(execHandlers, a.SkillMiddleware)
|
||||
}
|
||||
// 4. pre-summarization normalize + continuation dedup, then summarization (与 Deep/Supervisor 一致)
|
||||
if a.AppCfg != nil {
|
||||
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
|
||||
if sumErr != nil {
|
||||
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
|
||||
}
|
||||
execHandlers = appendEinoChatModelTailMiddlewares(execHandlers, einoChatModelTailConfig{
|
||||
logger: a.Logger,
|
||||
phase: "plan_execute_executor",
|
||||
summarization: sumMw,
|
||||
modelName: a.ModelName,
|
||||
conversationID: a.ConversationID,
|
||||
trace: a.ModelFacingTrace,
|
||||
})
|
||||
execHandlers, err := buildPlanExecuteExecutorHandlers(ctx, a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
executor, err := newPlanExecuteExecutor(ctx, &planexecute.ExecutorConfig{
|
||||
Model: a.ExecModel,
|
||||
@@ -130,6 +105,39 @@ func NewPlanExecuteRoot(ctx context.Context, a *PlanExecuteRootArgs) (adk.Resuma
|
||||
})
|
||||
}
|
||||
|
||||
// buildPlanExecuteExecutorHandlers 组装 Executor 中间件栈(outermost first),与 Deep/Supervisor 主代理对齐:
|
||||
// ExecPreMiddlewares(patch / reduction / toolsearch / plantask)→ filesystem → skill → summarization tail。
|
||||
func buildPlanExecuteExecutorHandlers(ctx context.Context, a *PlanExecuteRootArgs) ([]adk.ChatModelAgentMiddleware, error) {
|
||||
if a == nil {
|
||||
return nil, fmt.Errorf("plan_execute: args 为空")
|
||||
}
|
||||
var execHandlers []adk.ChatModelAgentMiddleware
|
||||
if len(a.ExecPreMiddlewares) > 0 {
|
||||
execHandlers = append(execHandlers, a.ExecPreMiddlewares...)
|
||||
}
|
||||
if a.FilesystemMiddleware != nil {
|
||||
execHandlers = append(execHandlers, a.FilesystemMiddleware)
|
||||
}
|
||||
if a.SkillMiddleware != nil {
|
||||
execHandlers = append(execHandlers, a.SkillMiddleware)
|
||||
}
|
||||
if a.AppCfg != nil {
|
||||
sumMw, sumErr := newEinoSummarizationMiddleware(ctx, a.ExecModel, a.AppCfg, a.MwCfg, a.ConversationID, a.DB, a.ProjectID, a.Logger)
|
||||
if sumErr != nil {
|
||||
return nil, fmt.Errorf("plan_execute executor summarization: %w", sumErr)
|
||||
}
|
||||
execHandlers = appendEinoChatModelTailMiddlewares(execHandlers, einoChatModelTailConfig{
|
||||
logger: a.Logger,
|
||||
phase: "plan_execute_executor",
|
||||
summarization: sumMw,
|
||||
modelName: a.ModelName,
|
||||
conversationID: a.ConversationID,
|
||||
trace: a.ModelFacingTrace,
|
||||
})
|
||||
}
|
||||
return execHandlers, nil
|
||||
}
|
||||
|
||||
// planExecutePlannerGenInput 将 orchestrator instruction 作为 SystemMessage 注入 planner 输入。
|
||||
// 返回 nil 时 Eino 使用内置默认 planner prompt。
|
||||
func planExecutePlannerGenInput(
|
||||
|
||||
@@ -22,15 +22,60 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
||||
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
||||
// einoSummarizeUserInstruction:压缩历史时保留渗透测试与用户约束关键信息。
|
||||
// 结构对齐 Eino 最佳实践(禁止工具、<analysis>+<summary>、<all_user_messages>),章节为安全测试领域化。
|
||||
const einoSummarizeUserInstruction = `关键:仅以纯文本响应。禁止调用任何工具(read_file、exec、grep、glob、write、edit 等)。
|
||||
上述对话中已包含全部待压缩上下文;不要要求用户粘贴历史,不要输出「请提供待压缩的对话历史」等占位/meta 回复。
|
||||
工具调用将被拒绝并浪费唯一一次摘要机会。
|
||||
|
||||
必须保留:已确认漏洞与攻击路径、工具输出中的核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策。
|
||||
保留精确技术细节(URL、路径、参数、Payload、版本号、报错原文可摘要但要点不丢)。
|
||||
将冗长扫描输出概括为结论;重复发现合并表述。
|
||||
已枚举资产须保留**可继承的摘要**:主域、关键子域/主机短表(或数量+代表样例)、高价值目标与已识别服务/端口要点,避免后续子代理因「看不见清单」而重复全量枚举。
|
||||
你的任务:在保持所有关键安全测试信息完整的前提下压缩对话历史,使后续代理能无缝继续同一授权测试任务。
|
||||
|
||||
输出须使后续代理能无缝继续同一授权测试任务。`
|
||||
压缩原则:
|
||||
- 必须保留:已确认漏洞与攻击路径、工具输出核心发现、凭证与认证细节、架构与薄弱点、当前进度、失败尝试与死路、策略决策
|
||||
- 保留精确技术细节(URL、路径、参数、Payload、版本号;报错原文可摘要但要点不丢)
|
||||
- 冗长扫描输出概括为结论;重复发现合并表述
|
||||
- 已枚举资产须保留可继承摘要:主域、关键子域/主机短表(或数量+代表样例)、高价值目标、已识别服务/端口要点
|
||||
|
||||
输出格式(严格遵循,仅一轮回复):
|
||||
1. 先输出 <analysis> 块:按时间顺序梳理对话,检查是否涵盖下方各章节要点;analysis 仅供自检,保持简洁(建议 ≤400 字)
|
||||
2. 再输出 <summary> 块:按以下章节写入可继承的压缩报告(无信息处写「无」,禁止留空模板占位符)
|
||||
|
||||
<summary>
|
||||
## 1. 授权范围与约束
|
||||
- 目标/范围/禁止项(域名、路径、IP、环境)
|
||||
- 凭证/认证信息(账号、Token、Cookie;敏感值原文保留)
|
||||
- 用户指定的方法、工具、优先级与待办
|
||||
- 否定约束(不测什么、不用什么手法)
|
||||
|
||||
## 2. 资产与服务枚举摘要
|
||||
- 主域/核心资产、关键子域或主机短表(或数量+代表样例)
|
||||
- 高价值目标、已识别服务/端口要点
|
||||
- 资产状态(存活/可攻/已排除/待验证)
|
||||
|
||||
## 3. 架构与已知薄弱点
|
||||
- 技术栈/部署拓扑/信任边界
|
||||
- 已识别薄弱点列表
|
||||
|
||||
## 4. 已确认漏洞与攻击路径
|
||||
- 漏洞名/CVE、URL/路径、参数/Payload、PoC 要点、影响等级
|
||||
- 攻击链/利用路径(步骤化)
|
||||
|
||||
## 5. 工具核心发现与扫描结论
|
||||
- 各工具结论(概括核心输出,非冗长日志)
|
||||
- 重复发现合并表述
|
||||
|
||||
## 6. 所有用户消息
|
||||
<all_user_messages>
|
||||
- [逐条列出非 tool 结果的用户消息要点;敏感约束与原文措辞尽量保留]
|
||||
</all_user_messages>
|
||||
|
||||
## 7. 当前进度、策略决策与下一步
|
||||
- 当前位置(已完成/进行中/卡点)
|
||||
- 失败尝试与死路(方法、现象/报错摘要、结论)
|
||||
- 策略决策与下一步具体操作(须与最近用户请求及未完成任务一致)
|
||||
</summary>
|
||||
|
||||
提醒:不要调用任何工具;必须基于上文已有对话直接输出 <analysis> 与 <summary>,勿输出 analysis 以外的正文。`
|
||||
|
||||
// newEinoSummarizationMiddleware 使用 Eino ADK Summarization 中间件(见 https://www.cloudwego.io/zh/docs/eino/core_modules/eino_adk/eino_adk_chatmodelagentmiddleware/middleware_summarization/)。
|
||||
// 触发阈值:估算 token 超过 openai.max_total_tokens * summarization_trigger_ratio(默认 0.8)时摘要。
|
||||
@@ -144,13 +189,13 @@ func newEinoSummarizationMiddleware(
|
||||
},
|
||||
},
|
||||
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
||||
summary = stripAnalysisFromSummarizationMessage(summary)
|
||||
out, ferr := summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
||||
if ferr != nil {
|
||||
return nil, ferr
|
||||
}
|
||||
if appCfg != nil {
|
||||
out = refreshFactIndexInMessages(out, db, projectID, appCfg.Project, logger)
|
||||
out = refreshUserVerbatimAnchorInMessages(out, db, conversationID, appCfg.MultiAgent.UserVerbatimAnchorMaxRunesEffective(), logger)
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
@@ -414,36 +459,6 @@ func writeSummarizationTranscript(path string, msgs []adk.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshUserVerbatimAnchorInMessages 压缩后从 messages 表刷新 system 中的用户原文锚点。
|
||||
func refreshUserVerbatimAnchorInMessages(msgs []adk.Message, db *database.DB, conversationID string, maxRunes int, logger *zap.Logger) []adk.Message {
|
||||
if maxRunes < 0 || db == nil {
|
||||
return msgs
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" {
|
||||
return msgs
|
||||
}
|
||||
rows, err := db.GetMessages(conversationID)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("summarization: 刷新用户原文锚点失败",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
block := project.BuildUserVerbatimAnchorBlockFromMessages(rows, maxRunes)
|
||||
if block == "" {
|
||||
return msgs
|
||||
}
|
||||
out := project.RefreshUserVerbatimAnchorInMessages(msgs, block)
|
||||
if logger != nil {
|
||||
logger.Info("summarization: 已刷新用户原文锚点", zap.String("conversationId", conversationID))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func einoSummarizationTokenCounter(openAIModel string) summarization.TokenCounterFunc {
|
||||
tc := agent.NewTikTokenCounter()
|
||||
return func(ctx context.Context, input *summarization.TokenCounterInput) (int, error) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
var (
|
||||
summarizationAnalysisBlockRegex = regexp.MustCompile(`(?is)<analysis>\s*.*?\s*</analysis>`)
|
||||
summarizationSummaryBlockRegex = regexp.MustCompile(`(?is)<summary>\s*(.*?)\s*</summary>`)
|
||||
)
|
||||
|
||||
// stripAnalysisFromSummarizationMessage removes the <analysis> block from a post-processed
|
||||
// Eino summary user message. Analysis helps one-shot generation quality but should not
|
||||
// occupy continuation context after compaction.
|
||||
func stripAnalysisFromSummarizationMessage(msg adk.Message) adk.Message {
|
||||
if msg == nil {
|
||||
return msg
|
||||
}
|
||||
cloned := *msg
|
||||
if cloned.Content != "" {
|
||||
cloned.Content = stripAnalysisFromSummarizationText(cloned.Content)
|
||||
}
|
||||
if len(cloned.UserInputMultiContent) > 0 {
|
||||
parts := make([]schema.MessageInputPart, len(cloned.UserInputMultiContent))
|
||||
copy(parts, cloned.UserInputMultiContent)
|
||||
// Only the first text part carries model output plus Eino preamble/transcript path.
|
||||
for i := range parts {
|
||||
if parts[i].Type != schema.ChatMessagePartTypeText || parts[i].Text == "" {
|
||||
continue
|
||||
}
|
||||
if i == 0 {
|
||||
parts[i].Text = stripAnalysisFromSummarizationText(parts[i].Text)
|
||||
}
|
||||
break
|
||||
}
|
||||
cloned.UserInputMultiContent = parts
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func stripAnalysisFromSummarizationText(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return text
|
||||
}
|
||||
stripped := strings.TrimSpace(summarizationAnalysisBlockRegex.ReplaceAllString(text, ""))
|
||||
if stripped == "" {
|
||||
return text
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
// extractSummarizationSummaryBody returns the inner text of the last <summary> block when present.
|
||||
// Used by tests and optional strict compaction paths.
|
||||
func extractSummarizationSummaryBody(text string) (string, bool) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return "", false
|
||||
}
|
||||
all := summarizationSummaryBlockRegex.FindAllStringSubmatch(text, -1)
|
||||
if len(all) == 0 || len(all[len(all)-1]) < 2 {
|
||||
return "", false
|
||||
}
|
||||
body := strings.TrimSpace(all[len(all)-1][1])
|
||||
if body == "" {
|
||||
return "", false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestStripAnalysisFromSummarizationText(t *testing.T) {
|
||||
in := "<analysis>internal notes</analysis>\n\n<summary>\n## 1. 授权\n- example.com\n</summary>"
|
||||
got := stripAnalysisFromSummarizationText(in)
|
||||
if strings.Contains(got, "<analysis>") {
|
||||
t.Fatalf("analysis block should be removed: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "## 1. 授权") {
|
||||
t.Fatalf("summary body should remain: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAnalysisFromSummarizationMessage_UserInputMultiContent(t *testing.T) {
|
||||
msg := &schema.Message{
|
||||
Role: schema.User,
|
||||
UserInputMultiContent: []schema.MessageInputPart{
|
||||
{
|
||||
Type: schema.ChatMessagePartTypeText,
|
||||
Text: "此会话延续自此前一段因上下文耗尽而终止的对话。\n\n<analysis>draft</analysis>\n<summary>body</summary>\n\n完整记录位于:/tmp/transcript.txt",
|
||||
},
|
||||
{
|
||||
Type: schema.ChatMessagePartTypeText,
|
||||
Text: "请从我们中断的地方继续对话,无需向用户提出任何进一步的问题。",
|
||||
},
|
||||
},
|
||||
}
|
||||
out := stripAnalysisFromSummarizationMessage(msg)
|
||||
if len(out.UserInputMultiContent) != 2 {
|
||||
t.Fatalf("expected 2 parts, got %d", len(out.UserInputMultiContent))
|
||||
}
|
||||
if strings.Contains(out.UserInputMultiContent[0].Text, "<analysis>") {
|
||||
t.Fatalf("part 0 should drop analysis: %q", out.UserInputMultiContent[0].Text)
|
||||
}
|
||||
if !strings.Contains(out.UserInputMultiContent[0].Text, "<summary>body</summary>") {
|
||||
t.Fatalf("part 0 should keep summary: %q", out.UserInputMultiContent[0].Text)
|
||||
}
|
||||
if out.UserInputMultiContent[1].Text != "请从我们中断的地方继续对话,无需向用户提出任何进一步的问题。" {
|
||||
t.Fatalf("continue instruction part should be unchanged: %q", out.UserInputMultiContent[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSummarizationSummaryBody(t *testing.T) {
|
||||
body, ok := extractSummarizationSummaryBody("<analysis>x</analysis><summary> kept </summary>")
|
||||
if !ok || body != "kept" {
|
||||
t.Fatalf("extract summary body: ok=%v body=%q", ok, body)
|
||||
}
|
||||
_, ok = extractSummarizationSummaryBody("plain text only")
|
||||
if ok {
|
||||
t.Fatal("expected false for plain text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripAnalysisFromSummarizationText_NoAnalysisUnchanged(t *testing.T) {
|
||||
in := "<summary>only summary</summary>"
|
||||
got := stripAnalysisFromSummarizationText(in)
|
||||
if got != in {
|
||||
t.Fatalf("expected unchanged text, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package multiagent
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MCPExecutionBinder maps ADK toolCallID → MCP monitor execution ID for a single agent run.
|
||||
type MCPExecutionBinder struct {
|
||||
mu sync.RWMutex
|
||||
byToolCall map[string]string
|
||||
}
|
||||
|
||||
@@ -20,12 +24,17 @@ func (b *MCPExecutionBinder) Bind(toolCallID, executionID string) {
|
||||
if tid == "" || eid == "" {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.byToolCall[tid] = eid
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *MCPExecutionBinder) ExecutionID(toolCallID string) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return b.byToolCall[strings.TrimSpace(toolCallID)]
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.byToolCall[tid]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMCPExecutionBinder(t *testing.T) {
|
||||
b := NewMCPExecutionBinder()
|
||||
@@ -12,3 +16,28 @@ func TestMCPExecutionBinder(t *testing.T) {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPExecutionBinder_ConcurrentBind 回归并行 tool 回调不得 concurrent map panic。
|
||||
func TestMCPExecutionBinder_ConcurrentBind(t *testing.T) {
|
||||
b := NewMCPExecutionBinder()
|
||||
const workers = 64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers * 2)
|
||||
for i := 0; i < workers; i++ {
|
||||
i := i
|
||||
toolCallID := fmt.Sprintf("call-%d", i)
|
||||
execID := fmt.Sprintf("exec-%d", i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
b.Bind(toolCallID, execID)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = b.ExecutionID(toolCallID)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := b.ExecutionID("call-0"); got != "exec-0" {
|
||||
t.Fatalf("expected exec-0, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,15 @@ import (
|
||||
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
|
||||
)
|
||||
|
||||
// newPlanExecuteExecutor 与 planexecute.NewExecutor 行为一致,但可为执行器注入 Handlers(例如 summarization 中间件)。
|
||||
// newPlanExecuteExecutor builds the Plan-Execute Executor as an Eino ChatModelAgent.
|
||||
//
|
||||
// Eino's planexecute.Config accepts any adk.Agent as Executor; this implementation
|
||||
// keeps the official Executor contract (Plan/UserInput/ExecutedSteps session keys
|
||||
// and ExecutedStepSessionKey output) while using ChatModelAgentConfig.Handlers so
|
||||
// the executor can run the same ADK middleware stack as Deep/Supervisor. As of
|
||||
// Eino v0.9.12/v0.10.0-alpha.10, planexecute.NewExecutor still does not expose a
|
||||
// Handlers field, so this custom Executor is the best-practice extension point
|
||||
// that preserves middleware without forking the whole planexecute loop.
|
||||
func newPlanExecuteExecutor(ctx context.Context, cfg *planexecute.ExecutorConfig, handlers []adk.ChatModelAgentMiddleware) (adk.Agent, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("plan_execute: ExecutorConfig 为空")
|
||||
@@ -25,18 +33,27 @@ func newPlanExecuteExecutor(ctx context.Context, cfg *planexecute.ExecutorConfig
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q missing (possible session corruption)", planexecute.PlanSessionKey)
|
||||
}
|
||||
plan_ := plan.(planexecute.Plan)
|
||||
plan_, ok := plan.(planexecute.Plan)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q has invalid type %T", planexecute.PlanSessionKey, plan)
|
||||
}
|
||||
|
||||
userInput, ok := adk.GetSessionValue(ctx, planexecute.UserInputSessionKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q missing (possible session corruption)", planexecute.UserInputSessionKey)
|
||||
}
|
||||
userInput_ := userInput.([]adk.Message)
|
||||
userInput_, ok := userInput.([]adk.Message)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q has invalid type %T", planexecute.UserInputSessionKey, userInput)
|
||||
}
|
||||
|
||||
var executedSteps_ []planexecute.ExecutedStep
|
||||
executedStep, ok := adk.GetSessionValue(ctx, planexecute.ExecutedStepsSessionKey)
|
||||
if ok {
|
||||
executedSteps_ = executedStep.([]planexecute.ExecutedStep)
|
||||
executedSteps_, ok = executedStep.([]planexecute.ExecutedStep)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q has invalid type %T", planexecute.ExecutedStepsSessionKey, executedStep)
|
||||
}
|
||||
}
|
||||
|
||||
in := &planexecute.ExecutionContext{
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/components/tool"
|
||||
)
|
||||
|
||||
type stubChatModelAgentMiddleware struct {
|
||||
adk.BaseChatModelAgentMiddleware
|
||||
tag string
|
||||
}
|
||||
|
||||
func stubMW(tag string) adk.ChatModelAgentMiddleware {
|
||||
return &stubChatModelAgentMiddleware{tag: tag}
|
||||
}
|
||||
|
||||
func TestBuildPlanExecuteExecutorHandlers_IncludesExecPreMiddlewares(t *testing.T) {
|
||||
t.Parallel()
|
||||
pre := []adk.ChatModelAgentMiddleware{
|
||||
stubMW("patch"),
|
||||
stubMW("reduction"),
|
||||
}
|
||||
|
||||
got, err := buildPlanExecuteExecutorHandlers(context.Background(), &PlanExecuteRootArgs{
|
||||
ExecPreMiddlewares: pre,
|
||||
FilesystemMiddleware: stubMW("filesystem"),
|
||||
SkillMiddleware: stubMW("skill"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildPlanExecuteExecutorHandlers: %v", err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("expected 4 pre-tail handlers (2 pre + fs + skill), got %d", len(got))
|
||||
}
|
||||
for i, want := range []string{"patch", "reduction", "filesystem", "skill"} {
|
||||
st, ok := got[i].(*stubChatModelAgentMiddleware)
|
||||
if !ok || st.tag != want {
|
||||
t.Fatalf("handler[%d]: got %#v want tag %q", i, got[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stubTools(n int) []tool.BaseTool {
|
||||
out := make([]tool.BaseTool, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = stubTool{name: fmt.Sprintf("t%d", i)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBuildPlanExecuteExecutorHandlers_NilArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := buildPlanExecuteExecutorHandlers(context.Background(), nil); err == nil {
|
||||
t.Fatal("expected error for nil args")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependEinoMiddlewares_Main_IncludesPatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
mw := configMultiAgentEinoMiddlewareForTest()
|
||||
mw.ReductionEnable = false
|
||||
mw.ToolSearchEnable = false
|
||||
mw.PlantaskEnable = false
|
||||
_, extra, _, err := prependEinoMiddlewares(ctx, mw, einoMWMain, stubTools(25), nil, "", "conv-test", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("prependEinoMiddlewares: %v", err)
|
||||
}
|
||||
if len(extra) == 0 {
|
||||
t.Fatal("expected patch middleware on einoMWMain when patch_tool_calls enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func configMultiAgentEinoMiddlewareForTest() *config.MultiAgentEinoMiddlewareConfig {
|
||||
patch := true
|
||||
return &config.MultiAgentEinoMiddlewareConfig{
|
||||
PatchToolCalls: &patch,
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,14 @@ func RunDeepAgent(
|
||||
if orchMode == "supervisor" && len(effectiveSubs) == 0 {
|
||||
return nil, fmt.Errorf("multi_agent.orchestration=supervisor 时需至少配置一个子代理(sub_agents 或 agents 目录 Markdown)")
|
||||
}
|
||||
if orchMode == "supervisor" && len(effectiveSubs) == 1 && progress != nil {
|
||||
progress("progress", "Supervisor 是专家路由模式;当前仅 1 个子代理,专家路由空间有限,仍会继续执行。", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"orchestration": orchMode,
|
||||
"kind": "supervisor_boundary_hint",
|
||||
})
|
||||
}
|
||||
|
||||
einoLoc, einoSkillMW, einoFSTools, skillsRoot, einoErr := prepareEinoSkills(ctx, appCfg.SkillsDir, ma, logger)
|
||||
if einoErr != nil {
|
||||
@@ -351,6 +359,7 @@ func RunDeepAgent(
|
||||
sb.WriteString("\n- ")
|
||||
sb.WriteString(sa.Name(ctx))
|
||||
}
|
||||
sb.WriteString("\n\nSupervisor 是专家路由模式:仅当任务确实需要不同专家分工时才 transfer;简单查询、单步工具调用或无需专业分流的任务由你直接完成。避免在同一子代理之间反复 transfer;除非有新的、具体的补充目标。专家返回后,你必须自行汇总、裁剪、校验证据,再用 exit 交付最终答案。")
|
||||
sb.WriteString("\n\n当你已完成用户目标或需要将最终结论交付用户时,使用 exit 工具结束。")
|
||||
supInstr = sb.String()
|
||||
}
|
||||
@@ -474,6 +483,7 @@ func RunDeepAgent(
|
||||
ProjectID: projectID,
|
||||
Logger: logger,
|
||||
ModelName: appCfg.OpenAI.Model,
|
||||
// 与 Deep/Supervisor 主代理同源:patch / reduction / toolsearch / plantask(见 buildPlanExecuteExecutorHandlers)。
|
||||
ExecPreMiddlewares: mainOrchestratorPre,
|
||||
SkillMiddleware: einoSkillMW,
|
||||
FilesystemMiddleware: peFsMw,
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
const (
|
||||
// UserVerbatimSectionHeading 用户原文锚点可读标题(块内保留,供 Agent 阅读)。
|
||||
UserVerbatimSectionHeading = "## 用户历史输入(原文保留,勿省略或改写)"
|
||||
|
||||
// UserVerbatimSectionStartMarker / EndMarker:HTML 注释边界,供程序化替换;对模型无指令语义。
|
||||
UserVerbatimSectionStartMarker = "<!-- user-verbatim-start -->"
|
||||
UserVerbatimSectionEndMarker = "<!-- user-verbatim-end -->"
|
||||
)
|
||||
|
||||
// ExtractUserContentsFromMessages 按时间顺序提取 user 角色消息的原文(跳过空白)。
|
||||
func ExtractUserContentsFromMessages(msgs []database.Message) []string {
|
||||
out := make([]string, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
if !strings.EqualFold(strings.TrimSpace(msgs[i].Role), "user") {
|
||||
continue
|
||||
}
|
||||
content := strings.TrimSpace(msgs[i].Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildUserVerbatimAnchorBlockFromMessages 从 messages 表行构建用户原文锚点块。
|
||||
// maxRunes: 0 = 不截断;>0 = 总 rune 上限(仍保留每一轮,仅对超长单条做尾部截断提示)。
|
||||
func BuildUserVerbatimAnchorBlockFromMessages(msgs []database.Message, maxRunes int) string {
|
||||
return BuildUserVerbatimAnchorBlock(ExtractUserContentsFromMessages(msgs), maxRunes)
|
||||
}
|
||||
|
||||
// BuildUserVerbatimAnchorBlock 将各轮用户原文格式化为 system prompt 锚点块。
|
||||
func BuildUserVerbatimAnchorBlock(userContents []string, maxRunes int) string {
|
||||
if len(userContents) == 0 {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(userContents))
|
||||
for _, content := range userContents {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("[第%d轮] %s", len(lines)+1, content))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
body := strings.Join(lines, "\n")
|
||||
if maxRunes > 0 {
|
||||
body = capUserVerbatimBody(body, maxRunes)
|
||||
}
|
||||
return wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n" + body)
|
||||
}
|
||||
|
||||
func capUserVerbatimBody(body string, maxRunes int) string {
|
||||
rs := []rune(body)
|
||||
if len(rs) <= maxRunes {
|
||||
return body
|
||||
}
|
||||
suffix := "\n\n...(用户原文锚点已达配置上限,更早轮次可能被截断;完整原文见 messages 表)..."
|
||||
suffixRunes := []rune(suffix)
|
||||
keep := maxRunes - len(suffixRunes)
|
||||
if keep <= 0 {
|
||||
return string(rs[:maxRunes])
|
||||
}
|
||||
return string(rs[:keep]) + suffix
|
||||
}
|
||||
|
||||
func wrapUserVerbatimBlock(content string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
return UserVerbatimSectionStartMarker + "\n" + content + "\n" + UserVerbatimSectionEndMarker + "\n"
|
||||
}
|
||||
|
||||
// ReplaceUserVerbatimAnchorSection 用 freshBlock 替换 content 中已有的用户原文锚点段。
|
||||
func ReplaceUserVerbatimAnchorSection(content, freshBlock string) (string, bool) {
|
||||
content = strings.TrimSpace(content)
|
||||
freshBlock = strings.TrimSpace(freshBlock)
|
||||
if freshBlock == "" {
|
||||
return content, false
|
||||
}
|
||||
start, ok := userVerbatimSectionStart(content)
|
||||
if !ok {
|
||||
return content, false
|
||||
}
|
||||
end, ok := userVerbatimSectionEnd(content, start)
|
||||
if !ok {
|
||||
return content, false
|
||||
}
|
||||
return strings.TrimSpace(content[:start] + freshBlock + content[end:]), true
|
||||
}
|
||||
|
||||
func userVerbatimSectionStart(content string) (int, bool) {
|
||||
idx := strings.Index(content, UserVerbatimSectionStartMarker)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return idx, true
|
||||
}
|
||||
|
||||
func userVerbatimSectionEnd(content string, start int) (int, bool) {
|
||||
if start < 0 || start >= len(content) {
|
||||
return 0, false
|
||||
}
|
||||
tail := content[start:]
|
||||
idx := strings.LastIndex(tail, UserVerbatimSectionEndMarker)
|
||||
if idx < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return start + idx + len(UserVerbatimSectionEndMarker), true
|
||||
}
|
||||
|
||||
// RefreshUserVerbatimAnchorInMessages 在 summarization 等压缩后,用 freshBlock 刷新 system 中的用户原文锚点。
|
||||
// 若尚无锚点段,则追加到首条 system 消息;若无 system 消息则在开头插入一条。
|
||||
func RefreshUserVerbatimAnchorInMessages(msgs []adk.Message, freshBlock string) []adk.Message {
|
||||
freshBlock = strings.TrimSpace(freshBlock)
|
||||
if freshBlock == "" || len(msgs) == 0 {
|
||||
return msgs
|
||||
}
|
||||
|
||||
out := make([]adk.Message, len(msgs))
|
||||
changed := false
|
||||
for i, msg := range msgs {
|
||||
if msg == nil || msg.Role != schema.System {
|
||||
out[i] = msg
|
||||
continue
|
||||
}
|
||||
newContent, ok := ReplaceUserVerbatimAnchorSection(msg.Content, freshBlock)
|
||||
if !ok {
|
||||
out[i] = msg
|
||||
continue
|
||||
}
|
||||
cloned := *msg
|
||||
cloned.Content = newContent
|
||||
out[i] = &cloned
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
return out
|
||||
}
|
||||
|
||||
for i, msg := range msgs {
|
||||
if msg == nil || msg.Role != schema.System {
|
||||
continue
|
||||
}
|
||||
cloned := *msg
|
||||
cloned.Content = AppendSystemPromptBlock(cloned.Content, freshBlock)
|
||||
out[i] = &cloned
|
||||
return out
|
||||
}
|
||||
|
||||
prefix := make([]adk.Message, 0, len(msgs)+1)
|
||||
prefix = append(prefix, schema.SystemMessage(freshBlock))
|
||||
return append(prefix, msgs...)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
package project
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/cloudwego/eino/adk"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func TestBuildUserVerbatimAnchorBlock_MultiTurn(t *testing.T) {
|
||||
msgs := []database.Message{
|
||||
{Role: "user", Content: "目标 https://a.com 仅测 /api"},
|
||||
{Role: "assistant", Content: "好的"},
|
||||
{Role: "user", Content: "用 admin:test 登录"},
|
||||
}
|
||||
block := BuildUserVerbatimAnchorBlockFromMessages(msgs, 0)
|
||||
if block == "" {
|
||||
t.Fatal("expected non-empty block")
|
||||
}
|
||||
if !strings.Contains(block, UserVerbatimSectionStartMarker) {
|
||||
t.Error("missing start marker")
|
||||
}
|
||||
if !strings.Contains(block, "[第1轮]") || !strings.Contains(block, "https://a.com") {
|
||||
t.Error("missing first user turn")
|
||||
}
|
||||
if !strings.Contains(block, "[第2轮]") || !strings.Contains(block, "admin:test") {
|
||||
t.Error("missing second user turn")
|
||||
}
|
||||
if strings.Contains(block, "好的") {
|
||||
t.Error("assistant content should not appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceUserVerbatimAnchorSection(t *testing.T) {
|
||||
old := "prefix\n\n" + wrapUserVerbatimBlock("## old\n\n[第1轮] a") + "\nsuffix"
|
||||
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] b\n[第2轮] c")
|
||||
out, ok := ReplaceUserVerbatimAnchorSection(old, newBlock)
|
||||
if !ok {
|
||||
t.Fatal("expected replace ok")
|
||||
}
|
||||
if !strings.Contains(out, "[第2轮] c") {
|
||||
t.Errorf("expected new block, got %q", out)
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(out), "prefix") {
|
||||
t.Error("prefix should remain")
|
||||
}
|
||||
if !strings.Contains(out, "suffix") {
|
||||
t.Error("suffix should remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshUserVerbatimAnchorInMessages_ReplaceExisting(t *testing.T) {
|
||||
oldBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] old")
|
||||
msgs := []adk.Message{
|
||||
schema.SystemMessage("instr\n\n" + oldBlock),
|
||||
schema.UserMessage("hi"),
|
||||
}
|
||||
newBlock := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] new")
|
||||
out := RefreshUserVerbatimAnchorInMessages(msgs, newBlock)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("message count: got %d", len(out))
|
||||
}
|
||||
if !strings.Contains(out[0].Content, "[第1轮] new") {
|
||||
t.Errorf("system content: %q", out[0].Content)
|
||||
}
|
||||
if strings.Contains(out[0].Content, "[第1轮] old") {
|
||||
t.Error("old anchor should be replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshUserVerbatimAnchorInMessages_InsertWhenMissing(t *testing.T) {
|
||||
msgs := []adk.Message{
|
||||
schema.SystemMessage("base instruction"),
|
||||
schema.UserMessage("hi"),
|
||||
}
|
||||
block := wrapUserVerbatimBlock(UserVerbatimSectionHeading + "\n\n[第1轮] anchor")
|
||||
out := RefreshUserVerbatimAnchorInMessages(msgs, block)
|
||||
if !strings.Contains(out[0].Content, "[第1轮] anchor") {
|
||||
t.Errorf("expected appended anchor, got %q", out[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserVerbatimAnchorBlock_MaxRunes(t *testing.T) {
|
||||
long := strings.Repeat("字", 200)
|
||||
block := BuildUserVerbatimAnchorBlock([]string{long}, 50)
|
||||
body := block
|
||||
if idx := strings.Index(body, UserVerbatimSectionStartMarker); idx >= 0 {
|
||||
body = strings.TrimPrefix(body[idx+len(UserVerbatimSectionStartMarker):], "\n")
|
||||
}
|
||||
if len([]rune(body)) > 120 {
|
||||
t.Errorf("expected capped body, got %d runes", len([]rune(body)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
discordPlatform = "discord"
|
||||
discordMaxMessageRunes = 2000
|
||||
)
|
||||
|
||||
// StartDiscord 启动 Discord Gateway(WebSocket,无需公网回调)。
|
||||
func StartDiscord(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Discord
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" {
|
||||
return
|
||||
}
|
||||
go runDiscordLoop(ctx, cfg, h, logger)
|
||||
}
|
||||
|
||||
func runDiscordLoop(ctx context.Context, cfg config.RobotDiscordConfig, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := reconnectInitial
|
||||
for {
|
||||
err := runDiscordSession(ctx, cfg, h, logger)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("Discord Gateway 已按配置关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Discord Gateway 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
if !waitReconnect(ctx, &backoff) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runDiscordSession(ctx context.Context, cfg config.RobotDiscordConfig, h MessageHandler, logger *zap.Logger) error {
|
||||
token := strings.TrimSpace(cfg.BotToken)
|
||||
if !strings.HasPrefix(token, "Bot ") {
|
||||
token = "Bot " + token
|
||||
}
|
||||
session, err := discordgo.New(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Identify.Intents = discordgo.IntentsGuildMessages |
|
||||
discordgo.IntentsDirectMessages |
|
||||
discordgo.IntentMessageContent
|
||||
|
||||
session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
if m == nil || m.Author == nil || m.Author.Bot {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(m.Content)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if m.GuildID != "" {
|
||||
if !cfg.AllowGuildMessages {
|
||||
return
|
||||
}
|
||||
if s.State.User == nil || !discordMentionsBot(m, s.State.User.ID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
userID := discordSessionKey(m.GuildID, m.Author.ID)
|
||||
logger.Info("Discord 收到消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(discordPlatform, userID, text)
|
||||
discordPostReply(s, m.ChannelID, reply, logger)
|
||||
})
|
||||
|
||||
if err := session.Open(); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Discord Gateway 已连接,等待收消息")
|
||||
defer session.Close()
|
||||
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func discordMentionsBot(m *discordgo.MessageCreate, botUserID string) bool {
|
||||
if m == nil || botUserID == "" {
|
||||
return false
|
||||
}
|
||||
for _, mention := range m.Mentions {
|
||||
if mention != nil && mention.ID == botUserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(m.Content, "<@"+botUserID+">") || strings.Contains(m.Content, "<@!"+botUserID+">")
|
||||
}
|
||||
|
||||
func discordSessionKey(guildID, userID string) string {
|
||||
guildID = strings.TrimSpace(guildID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if guildID == "" {
|
||||
return "u:" + userID
|
||||
}
|
||||
return "g:" + guildID + "|u:" + userID
|
||||
}
|
||||
|
||||
func discordPostReply(s *discordgo.Session, channelID, reply string, logger *zap.Logger) {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, discordMaxMessageRunes) {
|
||||
if _, err := s.ChannelMessageSend(channelID, chunk); err != nil {
|
||||
logger.Warn("Discord 发送回复失败", zap.String("channel", channelID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/tencent-connect/botgo"
|
||||
"github.com/tencent-connect/botgo/dto"
|
||||
"github.com/tencent-connect/botgo/event"
|
||||
"github.com/tencent-connect/botgo/openapi"
|
||||
"github.com/tencent-connect/botgo/token"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
qqPlatform = "qq"
|
||||
qqMaxMessageRunes = 3500
|
||||
)
|
||||
|
||||
var (
|
||||
qqHandlerMu sync.Mutex
|
||||
qqHandler MessageHandler
|
||||
qqLogger *zap.Logger
|
||||
qqAPI openapi.OpenAPI
|
||||
)
|
||||
|
||||
// StartQQ 启动 QQ 机器人 WebSocket(C2C 与群 @,出站连接,无需公网回调)。
|
||||
func StartQQ(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.QQ
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
return
|
||||
}
|
||||
go runQQLoop(ctx, cfg, h, logger)
|
||||
}
|
||||
|
||||
func runQQLoop(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := reconnectInitial
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("QQ 机器人 WebSocket 已按配置关闭")
|
||||
return
|
||||
}
|
||||
err := runQQSession(ctx, cfg, h, logger)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("QQ 机器人 WebSocket 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
if !waitReconnect(ctx, &backoff) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runQQSession(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) error {
|
||||
appID := strings.TrimSpace(cfg.AppID)
|
||||
secret := strings.TrimSpace(cfg.ClientSecret)
|
||||
credentials := &token.QQBotCredentials{AppID: appID, AppSecret: secret}
|
||||
tokenSource := token.NewQQBotTokenSource(credentials)
|
||||
|
||||
if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var api openapi.OpenAPI
|
||||
if cfg.Sandbox {
|
||||
api = botgo.NewSandboxOpenAPI(appID, tokenSource)
|
||||
} else {
|
||||
api = botgo.NewOpenAPI(appID, tokenSource)
|
||||
}
|
||||
|
||||
qqHandlerMu.Lock()
|
||||
qqHandler = h
|
||||
qqLogger = logger
|
||||
qqAPI = api
|
||||
qqHandlerMu.Unlock()
|
||||
defer func() {
|
||||
qqHandlerMu.Lock()
|
||||
qqHandler = nil
|
||||
qqLogger = nil
|
||||
qqAPI = nil
|
||||
qqHandlerMu.Unlock()
|
||||
}()
|
||||
|
||||
intents := event.RegisterHandlers(
|
||||
event.C2CMessageEventHandler(handleQQC2CMessage),
|
||||
event.GroupATMessageEventHandler(handleQQGroupATMessage),
|
||||
)
|
||||
|
||||
wsInfo, err := api.WS(ctx, nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("QQ 机器人 WebSocket 正在连接…", zap.String("app_id", appID), zap.Bool("sandbox", cfg.Sandbox))
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- botgo.NewSessionManager().Start(wsInfo, tokenSource, &intents)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleQQC2CMessage(payload *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||
if data == nil || data.Author == nil {
|
||||
return nil
|
||||
}
|
||||
text := strings.TrimSpace(data.Content)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
userOpenID := strings.TrimSpace(data.Author.ID)
|
||||
if userOpenID == "" {
|
||||
return nil
|
||||
}
|
||||
userID := "u:" + userOpenID
|
||||
qqHandlerMu.Lock()
|
||||
h := qqHandler
|
||||
logger := qqLogger
|
||||
api := qqAPI
|
||||
qqHandlerMu.Unlock()
|
||||
if h == nil || api == nil {
|
||||
return nil
|
||||
}
|
||||
logger.Info("QQ 收到 C2C 消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(qqPlatform, userID, text)
|
||||
return qqPostC2CReply(context.Background(), api, userOpenID, payload, data.ID, reply, logger)
|
||||
}
|
||||
|
||||
func handleQQGroupATMessage(payload *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||
if data == nil || data.Author == nil {
|
||||
return nil
|
||||
}
|
||||
text := strings.TrimSpace(data.Content)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
userOpenID := strings.TrimSpace(data.Author.ID)
|
||||
groupID := strings.TrimSpace(data.GroupID)
|
||||
if userOpenID == "" {
|
||||
return nil
|
||||
}
|
||||
userID := "g:" + groupID + "|u:" + userOpenID
|
||||
qqHandlerMu.Lock()
|
||||
h := qqHandler
|
||||
logger := qqLogger
|
||||
api := qqAPI
|
||||
qqHandlerMu.Unlock()
|
||||
if h == nil || api == nil {
|
||||
return nil
|
||||
}
|
||||
logger.Info("QQ 收到群 @ 消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(qqPlatform, userID, text)
|
||||
return qqPostGroupReply(context.Background(), api, groupID, payload, data.ID, reply, logger)
|
||||
}
|
||||
|
||||
func qqPostC2CReply(ctx context.Context, api openapi.OpenAPI, userOpenID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) {
|
||||
msg := &dto.MessageToCreate{
|
||||
Content: chunk,
|
||||
MsgID: msgID,
|
||||
}
|
||||
if payload != nil && payload.EventID != "" {
|
||||
msg.EventID = payload.EventID
|
||||
}
|
||||
if _, err := api.PostC2CMessage(ctx, userOpenID, msg); err != nil {
|
||||
logger.Warn("QQ 发送 C2C 回复失败", zap.String("to", userOpenID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func qqPostGroupReply(ctx context.Context, api openapi.OpenAPI, groupID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) {
|
||||
msg := &dto.MessageToCreate{
|
||||
Content: chunk,
|
||||
MsgID: msgID,
|
||||
}
|
||||
if payload != nil && payload.EventID != "" {
|
||||
msg.EventID = payload.EventID
|
||||
}
|
||||
if _, err := api.PostGroupMessage(ctx, groupID, msg); err != nil {
|
||||
logger.Warn("QQ 发送群消息回复失败", zap.String("group", groupID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
reconnectInitial = 5 * time.Second
|
||||
reconnectMax = 60 * time.Second
|
||||
)
|
||||
|
||||
func waitReconnect(ctx context.Context, backoff *time.Duration) bool {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(*backoff):
|
||||
if *backoff < reconnectMax {
|
||||
*backoff *= 2
|
||||
if *backoff > reconnectMax {
|
||||
*backoff = reconnectMax
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func bumpBackoff(backoff *time.Duration) {
|
||||
if *backoff < reconnectMax {
|
||||
*backoff *= 2
|
||||
if *backoff > reconnectMax {
|
||||
*backoff = reconnectMax
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
slackPlatform = "slack"
|
||||
slackMaxMessageRunes = 3900
|
||||
)
|
||||
|
||||
// StartSlack 启动 Slack Socket Mode(出站 WebSocket,无需公网回调)。
|
||||
func StartSlack(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Slack
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" || strings.TrimSpace(cfg.AppToken) == "" {
|
||||
return
|
||||
}
|
||||
go runSlackLoop(ctx, cfg, h, logger)
|
||||
}
|
||||
|
||||
func runSlackLoop(ctx context.Context, cfg config.RobotSlackConfig, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := reconnectInitial
|
||||
for {
|
||||
err := runSlackSocket(ctx, cfg, h, logger)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("Slack Socket Mode 已按配置关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Slack Socket Mode 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
if !waitReconnect(ctx, &backoff) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runSlackSocket(ctx context.Context, cfg config.RobotSlackConfig, h MessageHandler, logger *zap.Logger) error {
|
||||
api := slack.New(
|
||||
strings.TrimSpace(cfg.BotToken),
|
||||
slack.OptionAppLevelToken(strings.TrimSpace(cfg.AppToken)),
|
||||
)
|
||||
client := socketmode.New(api)
|
||||
logger.Info("Slack Socket Mode 正在连接…")
|
||||
|
||||
go func() {
|
||||
for evt := range client.Events {
|
||||
switch evt.Type {
|
||||
case socketmode.EventTypeEventsAPI:
|
||||
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
client.Ack(*evt.Request)
|
||||
if eventsAPIEvent.Type != slackevents.CallbackEvent {
|
||||
continue
|
||||
}
|
||||
switch ev := eventsAPIEvent.InnerEvent.Data.(type) {
|
||||
case *slackevents.MessageEvent:
|
||||
handleSlackMessage(ctx, api, eventsAPIEvent.TeamID, ev, h, logger)
|
||||
case *slackevents.AppMentionEvent:
|
||||
handleSlackAppMention(ctx, api, eventsAPIEvent.TeamID, ev, h, logger)
|
||||
}
|
||||
case socketmode.EventTypeConnecting:
|
||||
logger.Info("Slack Socket Mode 正在连接…")
|
||||
case socketmode.EventTypeConnected:
|
||||
logger.Info("Slack Socket Mode 已连接,等待收消息")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return client.RunContext(ctx)
|
||||
}
|
||||
|
||||
func handleSlackMessage(ctx context.Context, api *slack.Client, teamID string, ev *slackevents.MessageEvent, h MessageHandler, logger *zap.Logger) {
|
||||
if ev == nil || ev.BotID != "" || ev.SubType != "" {
|
||||
return
|
||||
}
|
||||
if ev.ChannelType != "im" {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(ev.Text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
userID := slackSessionKey(teamID, ev.User)
|
||||
logger.Info("Slack 收到消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(slackPlatform, userID, text)
|
||||
slackPostReply(ctx, api, ev.Channel, reply, logger)
|
||||
}
|
||||
|
||||
func handleSlackAppMention(ctx context.Context, api *slack.Client, teamID string, ev *slackevents.AppMentionEvent, h MessageHandler, logger *zap.Logger) {
|
||||
if ev == nil || ev.BotID != "" {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(ev.Text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
userID := slackSessionKey(teamID, ev.User)
|
||||
logger.Info("Slack 收到 @ 消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(slackPlatform, userID, text)
|
||||
slackPostReply(ctx, api, ev.Channel, reply, logger)
|
||||
}
|
||||
|
||||
func slackSessionKey(teamID, userID string) string {
|
||||
teamID = strings.TrimSpace(teamID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if teamID == "" {
|
||||
teamID = "default"
|
||||
}
|
||||
return "t:" + teamID + "|u:" + userID
|
||||
}
|
||||
|
||||
func slackPostReply(ctx context.Context, api *slack.Client, channel, reply string, logger *zap.Logger) {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, slackMaxMessageRunes) {
|
||||
_, _, err := api.PostMessageContext(ctx, channel, slack.MsgOptionText(chunk, false))
|
||||
if err != nil {
|
||||
logger.Warn("Slack 发送回复失败", zap.String("channel", channel), zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package robot
|
||||
|
||||
import "strings"
|
||||
|
||||
// splitTextChunks splits text into chunks no longer than maxRunes (rune count).
|
||||
func splitTextChunks(text string, maxRunes int) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" || maxRunes <= 0 {
|
||||
return nil
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxRunes {
|
||||
return []string{text}
|
||||
}
|
||||
var out []string
|
||||
for len(runes) > 0 {
|
||||
end := maxRunes
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
out = append(out, string(runes[:end]))
|
||||
runes = runes[end:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimReply(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
telegramPlatform = "telegram"
|
||||
telegramAPIBase = "https://api.telegram.org"
|
||||
telegramLongPollSec = 30
|
||||
telegramMaxMessageRunes = 4096
|
||||
)
|
||||
|
||||
type telegramUpdate struct {
|
||||
UpdateID int `json:"update_id"`
|
||||
Message *telegramMessage `json:"message"`
|
||||
}
|
||||
|
||||
type telegramMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
Chat telegramChat `json:"chat"`
|
||||
From *telegramUser `json:"from"`
|
||||
Text string `json:"text"`
|
||||
Entities []telegramEntity `json:"entities"`
|
||||
}
|
||||
|
||||
type telegramChat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type telegramUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
}
|
||||
|
||||
type telegramEntity struct {
|
||||
Type string `json:"type"`
|
||||
Offset int `json:"offset"`
|
||||
Length int `json:"length"`
|
||||
}
|
||||
|
||||
type telegramGetUpdatesResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result []telegramUpdate `json:"result"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type telegramBotMe struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// StartTelegram 启动 Telegram Bot 长轮询(getUpdates,无需公网回调)。
|
||||
func StartTelegram(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Telegram
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" {
|
||||
return
|
||||
}
|
||||
go runTelegramLoop(ctx, cfg, h, logger)
|
||||
}
|
||||
|
||||
func runTelegramLoop(ctx context.Context, cfg config.RobotTelegramConfig, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := reconnectInitial
|
||||
for {
|
||||
err := runTelegramPoll(ctx, cfg, h, logger)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("Telegram 长轮询已按配置关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Telegram 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
if !waitReconnect(ctx, &backoff) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runTelegramPoll(ctx context.Context, cfg config.RobotTelegramConfig, h MessageHandler, logger *zap.Logger) error {
|
||||
token := strings.TrimSpace(cfg.BotToken)
|
||||
botUsername := strings.TrimSpace(cfg.BotUsername)
|
||||
if botUsername == "" {
|
||||
if name, err := telegramGetMe(ctx, token); err != nil {
|
||||
logger.Warn("Telegram getMe 失败", zap.Error(err))
|
||||
} else {
|
||||
botUsername = name
|
||||
}
|
||||
}
|
||||
offset := cfg.UpdateOffset
|
||||
logger.Info("Telegram 长轮询已启动", zap.String("bot", botUsername))
|
||||
client := &http.Client{Timeout: telegramLongPollSec*time.Second + 10*time.Second}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
updates, err := telegramGetUpdates(ctx, client, token, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range updates {
|
||||
next := int64(u.UpdateID) + 1
|
||||
if next > offset {
|
||||
offset = next
|
||||
}
|
||||
if u.Message == nil || u.Message.From == nil || u.Message.From.IsBot {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(u.Message.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
chatType := strings.ToLower(strings.TrimSpace(u.Message.Chat.Type))
|
||||
if chatType != "private" {
|
||||
if !cfg.AllowGroupMessages {
|
||||
continue
|
||||
}
|
||||
if botUsername != "" && !telegramMentionsBot(text, u.Message.Entities, botUsername) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
userID := telegramSessionKey(chatType, u.Message.Chat.ID, u.Message.From.ID)
|
||||
logger.Info("Telegram 收到消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(telegramPlatform, userID, text)
|
||||
if err := telegramSendReply(ctx, client, token, u.Message.Chat.ID, reply); err != nil {
|
||||
logger.Warn("Telegram 发送回复失败", zap.String("to", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func telegramSessionKey(chatType string, chatID, fromUserID int64) string {
|
||||
if chatType == "private" {
|
||||
return fmt.Sprintf("u:%d", fromUserID)
|
||||
}
|
||||
return fmt.Sprintf("g:%d|u:%d", chatID, fromUserID)
|
||||
}
|
||||
|
||||
func telegramMentionsBot(text string, entities []telegramEntity, botUsername string) bool {
|
||||
needle := "@" + strings.TrimPrefix(strings.ToLower(botUsername), "@")
|
||||
lower := strings.ToLower(text)
|
||||
if strings.Contains(lower, needle) {
|
||||
return true
|
||||
}
|
||||
for _, e := range entities {
|
||||
if e.Type != "mention" {
|
||||
continue
|
||||
}
|
||||
if e.Offset < 0 || e.Length <= 0 || e.Offset+e.Length > len(text) {
|
||||
continue
|
||||
}
|
||||
mention := strings.ToLower(text[e.Offset : e.Offset+e.Length])
|
||||
if mention == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func telegramAPIURL(token, method string) string {
|
||||
return fmt.Sprintf("%s/bot%s/%s", telegramAPIBase, token, method)
|
||||
}
|
||||
|
||||
func telegramGetMe(ctx context.Context, token string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, telegramAPIURL(token, "getMe"), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var parsed telegramBotMe
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !parsed.OK {
|
||||
return "", fmt.Errorf("getMe failed: %s", string(body))
|
||||
}
|
||||
return parsed.Result.Username, nil
|
||||
}
|
||||
|
||||
func telegramGetUpdates(ctx context.Context, client *http.Client, token string, offset int64) ([]telegramUpdate, error) {
|
||||
url := fmt.Sprintf("%s?timeout=%d&allowed_updates=%s", telegramAPIURL(token, "getUpdates"), telegramLongPollSec, `["message"]`)
|
||||
if offset > 0 {
|
||||
url += fmt.Sprintf("&offset=%d", offset)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var parsed telegramGetUpdatesResp
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.OK {
|
||||
if parsed.Description != "" {
|
||||
return nil, fmt.Errorf("getUpdates: %s", parsed.Description)
|
||||
}
|
||||
return nil, fmt.Errorf("getUpdates failed: %s", string(body))
|
||||
}
|
||||
return parsed.Result, nil
|
||||
}
|
||||
|
||||
func telegramSendReply(ctx context.Context, client *http.Client, token string, chatID int64, reply string) error {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, telegramMaxMessageRunes) {
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": chunk,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, telegramAPIURL(token, "sendMessage"), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("sendMessage status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// compileAgentSubgraph wraps an Agent canvas node as an Eino subgraph (AddGraphNode best practice).
|
||||
func compileAgentSubgraph(_ context.Context, node graphNode) (compose.AnyGraph, error) {
|
||||
n := node
|
||||
prepareID := n.ID + "__agent_prepare"
|
||||
executeID := n.ID + "__agent_execute"
|
||||
finalizeID := n.ID + "__agent_finalize"
|
||||
g := compose.NewGraph[WorkflowNodeOutput, WorkflowNodeOutput]()
|
||||
_ = g.AddLambdaNode(prepareID, compose.InvokableLambda(func(_ context.Context, input WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
if input == nil {
|
||||
input = WorkflowNodeOutput{}
|
||||
}
|
||||
input["agent_subgraph_stage"] = "prepare"
|
||||
input["agent_node_id"] = n.ID
|
||||
return input, nil
|
||||
}))
|
||||
_ = g.AddLambdaNode(executeID, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
return runWorkflowNodeLambda(runCtx, n)
|
||||
}))
|
||||
_ = g.AddLambdaNode(finalizeID, compose.InvokableLambda(func(_ context.Context, output WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
if output == nil {
|
||||
output = WorkflowNodeOutput{}
|
||||
}
|
||||
output["agent_subgraph_stage"] = "finalize"
|
||||
output["agent_node_id"] = n.ID
|
||||
return output, nil
|
||||
}))
|
||||
if err := g.AddEdge(compose.START, prepareID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddEdge(prepareID, executeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddEdge(executeID, finalizeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddEdge(finalizeID, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FieldBinding selects a value from workflow state (replaces {{...}} templates).
|
||||
type FieldBinding struct {
|
||||
From string `json:"from"` // inputs | previous | <nodeId>
|
||||
Field string `json:"field"` // e.g. output, message
|
||||
}
|
||||
|
||||
func parseFieldBinding(cfg map[string]any, keys ...string) (FieldBinding, bool) {
|
||||
for _, key := range keys {
|
||||
if cfg == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := cfg[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case map[string]any:
|
||||
return FieldBinding{
|
||||
From: strings.TrimSpace(fmt.Sprint(v["from"])),
|
||||
Field: strings.TrimSpace(fmt.Sprint(v["field"])),
|
||||
}, true
|
||||
case string:
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
var b FieldBinding
|
||||
if err := json.Unmarshal([]byte(s), &b); err == nil && (b.From != "" || b.Field != "") {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return FieldBinding{}, false
|
||||
}
|
||||
|
||||
func defaultBinding(from, field string) FieldBinding {
|
||||
return FieldBinding{From: from, Field: field}
|
||||
}
|
||||
|
||||
func resolveBinding(b FieldBinding, state *WorkflowLocalState) any {
|
||||
from := strings.TrimSpace(b.From)
|
||||
field := strings.TrimSpace(b.Field)
|
||||
if field == "" {
|
||||
field = "output"
|
||||
}
|
||||
if from == "" || from == "previous" || from == "prev" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.LastOutput, field)
|
||||
}
|
||||
if field == "output" && state.LastOutput != nil {
|
||||
return state.LastOutput["output"]
|
||||
}
|
||||
return valueFromPath("previous."+field, state)
|
||||
}
|
||||
if from == "inputs" || from == "input" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.Inputs, field)
|
||||
}
|
||||
if field == "" {
|
||||
return state.Inputs
|
||||
}
|
||||
return valueFromPath("inputs."+field, state)
|
||||
}
|
||||
if from == "outputs" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.Outputs, field)
|
||||
}
|
||||
return valueFromPath("outputs."+field, state)
|
||||
}
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(valueFromPath(from, state), field)
|
||||
}
|
||||
return valueFromPath(from+"."+field, state)
|
||||
}
|
||||
|
||||
func resolveBindingString(b FieldBinding, state *WorkflowLocalState) string {
|
||||
return strings.TrimSpace(fmt.Sprint(resolveBinding(b, state)))
|
||||
}
|
||||
|
||||
func resolveNodeInputBinding(cfg map[string]any, state *WorkflowLocalState) string {
|
||||
if b, ok := parseFieldBinding(cfg, "input_binding"); ok {
|
||||
return resolveBindingString(b, state)
|
||||
}
|
||||
// legacy template field removed — default previous.output
|
||||
return resolveBindingString(defaultBinding("previous", "output"), state)
|
||||
}
|
||||
|
||||
func resolveOutputSourceBinding(cfg map[string]any, state *WorkflowLocalState) any {
|
||||
if b, ok := parseFieldBinding(cfg, "source_binding"); ok {
|
||||
return resolveBinding(b, state)
|
||||
}
|
||||
return resolveBinding(defaultBinding("previous", "output"), state)
|
||||
}
|
||||
|
||||
func resolveHITLPromptBinding(cfg map[string]any, state *WorkflowLocalState) string {
|
||||
if b, ok := parseFieldBinding(cfg, "prompt_binding"); ok {
|
||||
return resolveBindingString(b, state)
|
||||
}
|
||||
if s := cfgString(cfg, "prompt"); s != "" {
|
||||
return s
|
||||
}
|
||||
return resolveBindingString(defaultBinding("previous", "output"), state)
|
||||
}
|
||||
|
||||
func toolArgumentBindings(cfg map[string]any) map[string]FieldBinding {
|
||||
raw, ok := cfg["argument_bindings"].(map[string]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]FieldBinding, len(raw))
|
||||
for argName, v := range raw {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[argName] = FieldBinding{
|
||||
From: strings.TrimSpace(fmt.Sprint(m["from"])),
|
||||
Field: strings.TrimSpace(fmt.Sprint(m["field"])),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveToolArguments(cfg map[string]any, state *WorkflowLocalState) (map[string]interface{}, error) {
|
||||
bindings := toolArgumentBindings(cfg)
|
||||
if len(bindings) > 0 {
|
||||
args := make(map[string]interface{}, len(bindings))
|
||||
for k, b := range bindings {
|
||||
args[k] = resolveBinding(b, state)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
raw := cfgString(cfg, "arguments")
|
||||
if raw == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// fileCheckPointStore persists Eino workflow checkpoints on disk (per run id).
|
||||
type fileCheckPointStore struct {
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newFileCheckPointStore(dir string) (*fileCheckPointStore, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
dir = filepath.Join("data", "workflow-checkpoints")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create workflow checkpoint dir: %w", err)
|
||||
}
|
||||
return &fileCheckPointStore{dir: dir}, nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) path(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("checkpoint id is empty")
|
||||
}
|
||||
if strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) {
|
||||
return "", fmt.Errorf("invalid checkpoint id")
|
||||
}
|
||||
return filepath.Join(s.dir, id+".ckpt"), nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) Get(_ context.Context, checkPointID string) ([]byte, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p, err := s.path(checkPointID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) Set(_ context.Context, checkPointID string, checkPoint []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, err := s.path(checkPointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, checkPoint, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DryRunResult struct {
|
||||
Outputs map[string]any `json:"outputs"`
|
||||
NodeOutputs map[string]map[string]any `json:"nodeOutputs"`
|
||||
Executed []string `json:"executed"`
|
||||
Skipped []string `json:"skipped"`
|
||||
Trace []map[string]any `json:"trace"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
ReplayScript []map[string]any `json:"replayScript"`
|
||||
}
|
||||
|
||||
func DryRunGraphJSON(ctx context.Context, graphJSON string, inputs map[string]any) (*DryRunResult, error) {
|
||||
g, err := parseGraph(graphJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := indexGraph(g)
|
||||
if err := validateGraphDefinition(g, idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := make(map[string]interface{}, len(inputs))
|
||||
for k, v := range inputs {
|
||||
in[k] = v
|
||||
}
|
||||
if _, ok := in["message"]; !ok {
|
||||
in["message"] = ""
|
||||
}
|
||||
state := newWorkflowLocalState(in, "dry-run")
|
||||
rt := &workflowRuntime{runID: "dry-run", idx: idx, state: state}
|
||||
trace := []map[string]any{}
|
||||
executedIDs := map[string]bool{}
|
||||
queue := findStartNodeIDs(idx)
|
||||
for len(queue) > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
nodeID := queue[0]
|
||||
queue = queue[1:]
|
||||
if executedIDs[nodeID] {
|
||||
continue
|
||||
}
|
||||
node := idx.nodes[nodeID]
|
||||
if !dryRunPredecessorsReady(idx, nodeID, executedIDs) {
|
||||
queue = append(queue, nodeID)
|
||||
continue
|
||||
}
|
||||
if err := prepareNodeInputState(rt, node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
out, proceed, status, errText := dryRunNode(node, state)
|
||||
out["duration_ms"] = time.Since(started).Milliseconds()
|
||||
out["status"] = status
|
||||
state.NodeOutputs[node.ID] = out
|
||||
state.LastOutput = out
|
||||
executedIDs[nodeID] = true
|
||||
if status == "skipped" {
|
||||
state.Skipped = append(state.Skipped, firstNonEmpty(node.Label, node.ID))
|
||||
} else {
|
||||
state.Executed = append(state.Executed, firstNonEmpty(node.Label, node.ID))
|
||||
}
|
||||
trace = append(trace, map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"label": firstNonEmpty(node.Label, node.ID),
|
||||
"type": node.Type,
|
||||
"status": status,
|
||||
"error": errText,
|
||||
"output": out,
|
||||
"previous": state.LastOutput,
|
||||
})
|
||||
if !proceed {
|
||||
continue
|
||||
}
|
||||
for edgeIdx, edge := range idx.outgoing[nodeID] {
|
||||
if edgeAllowed(edge, node, edgeIdx, state) {
|
||||
queue = append(queue, edge.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, node := range idx.nodes {
|
||||
if !executedIDs[id] {
|
||||
state.Skipped = append(state.Skipped, firstNonEmpty(node.Label, id))
|
||||
}
|
||||
}
|
||||
return &DryRunResult{
|
||||
Outputs: state.Outputs,
|
||||
NodeOutputs: state.NodeOutputs,
|
||||
Executed: state.Executed,
|
||||
Skipped: state.Skipped,
|
||||
Trace: trace,
|
||||
Metrics: state.Metrics,
|
||||
ReplayScript: buildReplayScript(trace),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dryRunPredecessorsReady(idx *graphIndex, nodeID string, executed map[string]bool) bool {
|
||||
for _, edge := range idx.incoming[nodeID] {
|
||||
if !executed[edge.Source] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func dryRunNode(node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
switch strings.ToLower(strings.TrimSpace(node.Type)) {
|
||||
case "start":
|
||||
return startOutputMap(node, state.Inputs["message"], state.Inputs["conversationId"], state.Inputs["projectId"]), true, "completed", ""
|
||||
case "condition":
|
||||
expr := cfgString(node.Config, "expression")
|
||||
matched := evalCondition(expr, state)
|
||||
return conditionOutputMap(node, expr, matched), true, "completed", ""
|
||||
case "output":
|
||||
key := cfgString(node.Config, "output_key")
|
||||
value := resolveOutputSourceBinding(node.Config, state)
|
||||
if static := cfgString(node.Config, "static_value"); static != "" {
|
||||
value = static
|
||||
}
|
||||
state.Outputs[key] = value
|
||||
return outputNodeOutputMap(node, key, value), true, "completed", ""
|
||||
case "end":
|
||||
value := resolveOutputSourceBinding(node.Config, state)
|
||||
if b, ok := parseFieldBinding(node.Config, "result_binding"); ok {
|
||||
value = resolveBinding(b, state)
|
||||
}
|
||||
return endOutputMap(node, value), false, "completed", ""
|
||||
case "tool":
|
||||
args, err := resolveToolArguments(node.Config, state)
|
||||
if err != nil {
|
||||
errText := fmt.Sprintf("工具参数不是合法 JSON:%v", err)
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
|
||||
}
|
||||
return toolOutputMap(node, "[dry-run] tool call skipped", cfgString(node.Config, "tool_name"), args, "dry-run", false), true, "simulated", ""
|
||||
case "agent":
|
||||
mode := firstNonEmpty(cfgString(node.Config, "agent_mode"), "eino_single")
|
||||
response := "[dry-run] agent execution skipped"
|
||||
if key := cfgString(node.Config, "output_key"); key != "" {
|
||||
state.Outputs[key] = response
|
||||
}
|
||||
return agentOutputMap(node, response, mode, nil), true, "simulated", ""
|
||||
case "hitl":
|
||||
prompt := resolveHITLPromptBinding(node.Config, state)
|
||||
return hitlOutputMap(node, "simulated", prompt, prompt, firstNonEmpty(cfgString(node.Config, "reviewer"), "human"), true), true, "simulated", ""
|
||||
default:
|
||||
return outputMap(envelope("unknown", node.ID, node.Type, "skipped", ""), map[string]any{"reason": "未知节点类型"}), true, "skipped", "未知节点类型"
|
||||
}
|
||||
}
|
||||
|
||||
func buildReplayScript(trace []map[string]any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(trace))
|
||||
for i, step := range trace {
|
||||
raw, _ := json.Marshal(step["output"])
|
||||
out = append(out, map[string]any{
|
||||
"step": i + 1,
|
||||
"nodeId": step["nodeId"],
|
||||
"type": step["type"],
|
||||
"status": step["status"],
|
||||
"output": string(raw),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
func hasConditionalOutgoingEdges(idx *graphIndex, nodeID string) bool {
|
||||
for _, edge := range idx.outgoing[nodeID] {
|
||||
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
|
||||
if cond != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wireConditionBranch(
|
||||
wf *compose.Workflow[WorkflowInput, WorkflowOutput],
|
||||
nodeRefs map[string]*compose.WorkflowNode,
|
||||
idx *graphIndex,
|
||||
condID string,
|
||||
condNode graphNode,
|
||||
) error {
|
||||
edges := idx.outgoing[condID]
|
||||
if len(edges) == 0 {
|
||||
return nil
|
||||
}
|
||||
branchID := branchNodeID(condID)
|
||||
wf.AddPassthroughNode(branchID).AddInput(condID)
|
||||
|
||||
endNodes := map[string]bool{compose.END: true}
|
||||
for _, edge := range edges {
|
||||
endNodes[edge.Target] = true
|
||||
}
|
||||
|
||||
sortedEdges := append([]graphEdge(nil), edges...)
|
||||
sortEdgesByCanvas(sortedEdges, idx.nodes)
|
||||
|
||||
branch := compose.NewGraphBranch(func(runCtx context.Context, _ map[string]any) (string, error) {
|
||||
rt := workflowRuntimeFrom(runCtx)
|
||||
if rt == nil {
|
||||
return compose.END, fmt.Errorf("workflow runtime missing in context")
|
||||
}
|
||||
emitConditionBranchProgress(rt.args, rt.runID, condNode, sortedEdges, idx.nodes, rt.state)
|
||||
for edgeIdx, edge := range sortedEdges {
|
||||
if conditionBranchAllowed(edge, edgeIdx, rt.state) {
|
||||
return edge.Target, nil
|
||||
}
|
||||
}
|
||||
return compose.END, nil
|
||||
}, endNodes)
|
||||
wf.AddBranch(branchID, branch)
|
||||
|
||||
for _, edge := range edges {
|
||||
if target, ok := nodeRefs[edge.Target]; ok {
|
||||
target.AddInput(branchID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wireEdgeConditionBranch(
|
||||
wf *compose.Workflow[WorkflowInput, WorkflowOutput],
|
||||
nodeRefs map[string]*compose.WorkflowNode,
|
||||
idx *graphIndex,
|
||||
sourceID string,
|
||||
sourceNode graphNode,
|
||||
) error {
|
||||
edges := idx.outgoing[sourceID]
|
||||
if len(edges) == 0 {
|
||||
return nil
|
||||
}
|
||||
branchID := edgeBranchNodeID(sourceID)
|
||||
wf.AddPassthroughNode(branchID).AddInput(sourceID)
|
||||
|
||||
endNodes := map[string]bool{compose.END: true}
|
||||
for _, edge := range edges {
|
||||
endNodes[edge.Target] = true
|
||||
}
|
||||
|
||||
sortedEdges := append([]graphEdge(nil), edges...)
|
||||
sortEdgesByCanvas(sortedEdges, idx.nodes)
|
||||
|
||||
branch := compose.NewGraphBranch(func(runCtx context.Context, _ map[string]any) (string, error) {
|
||||
rt := workflowRuntimeFrom(runCtx)
|
||||
if rt == nil {
|
||||
return compose.END, fmt.Errorf("workflow runtime missing in context")
|
||||
}
|
||||
for edgeIdx, edge := range sortedEdges {
|
||||
if edgeAllowed(edge, sourceNode, edgeIdx, rt.state) {
|
||||
return edge.Target, nil
|
||||
}
|
||||
}
|
||||
return compose.END, nil
|
||||
}, endNodes)
|
||||
wf.AddBranch(branchID, branch)
|
||||
|
||||
for _, edge := range edges {
|
||||
if target, ok := nodeRefs[edge.Target]; ok {
|
||||
target.AddInput(branchID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/einoobserve"
|
||||
)
|
||||
|
||||
func attachWorkflowCallbacks(ctx context.Context, cfg *config.Config, args RunArgs, workflowName string) context.Context {
|
||||
if cfg == nil {
|
||||
return ctx
|
||||
}
|
||||
cbCfg := &cfg.MultiAgent.EinoCallbacks
|
||||
return einoobserve.AttachAgentRunCallbacks(ctx, cbCfg, einoobserve.Params{
|
||||
Logger: args.Logger,
|
||||
Progress: args.Progress,
|
||||
ConversationID: args.ConversationID,
|
||||
OrchMode: "workflow",
|
||||
OrchestratorName: workflowName,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
func executeEinoGraph(ctx context.Context, args RunArgs, runID string, workflowID string, version int, g *graphDef, state *WorkflowLocalState) error {
|
||||
_, err := invokeEinoGraph(ctx, args, runID, workflowID, version, g, state, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func invokeEinoGraph(ctx context.Context, args RunArgs, runID string, workflowID string, version int, g *graphDef, state *WorkflowLocalState, resume bool) (bool, error) {
|
||||
wfInput := workflowInputFromMap(state.Inputs)
|
||||
if resume {
|
||||
wfInput = WorkflowInput{}
|
||||
}
|
||||
rt := &workflowRuntime{
|
||||
args: args,
|
||||
runID: runID,
|
||||
idx: indexGraph(g),
|
||||
state: state,
|
||||
}
|
||||
|
||||
art, err := defaultEngine.getOrCompile(ctx, workflowID, version, g)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("编译 Eino Workflow 失败: %w", err)
|
||||
}
|
||||
rt.idx = art.idx
|
||||
|
||||
runCtx := withWorkflowRuntime(ctx, rt)
|
||||
runCtx = attachWorkflowCallbacks(runCtx, args.AppCfg, args, workflowID)
|
||||
|
||||
invokeOpts := []compose.Option{compose.WithCheckPointID(runID)}
|
||||
for {
|
||||
_, err = art.runnable.Invoke(runCtx, wfInput, invokeOpts...)
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
if hitlErr := extractAwaitingHITL(err, art, runID, args, state); hitlErr != nil {
|
||||
return true, hitlErr
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
func extractAwaitingHITL(err error, art *compiledArtifact, runID string, args RunArgs, state *WorkflowLocalState) error {
|
||||
info, ok := compose.ExtractInterruptInfo(err)
|
||||
if !ok || len(art.hitlIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
nodeID := nextHITLNodeID(info, art.hitlIDs)
|
||||
node := art.idx.nodes[nodeID]
|
||||
if nodeID == "" {
|
||||
return nil
|
||||
}
|
||||
prompt := resolveHITLPromptBinding(node.Config, state)
|
||||
label := firstNonEmpty(node.Label, nodeID)
|
||||
if args.DB != nil {
|
||||
pending := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"label": label,
|
||||
"prompt": prompt,
|
||||
"reviewer": cfgString(node.Config, "reviewer"),
|
||||
"checkpointId": runID,
|
||||
"interrupt": workflowInterruptMetadata(info),
|
||||
"resumePayload": map[string]any{"approved": "bool", "comment": "string"},
|
||||
}
|
||||
pendingJSON, _ := json.Marshal(pending)
|
||||
_ = args.DB.SetWorkflowRunAwaitingHITL(runID, nodeID, string(pendingJSON))
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_waiting", fmt.Sprintf("等待人工确认:%s", label), map[string]any{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": nodeID,
|
||||
"label": label,
|
||||
"prompt": prompt,
|
||||
"reviewer": cfgString(node.Config, "reviewer"),
|
||||
"mode": "interactive",
|
||||
"resumeApi": fmt.Sprintf("/api/workflows/runs/%s/resume", runID),
|
||||
})
|
||||
}
|
||||
return &AwaitingHITLError{
|
||||
RunID: runID,
|
||||
NodeID: nodeID,
|
||||
NodeLabel: label,
|
||||
Prompt: prompt,
|
||||
Reviewer: cfgString(node.Config, "reviewer"),
|
||||
}
|
||||
}
|
||||
|
||||
func workflowInterruptMetadata(info *compose.InterruptInfo) map[string]any {
|
||||
if info == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
before := append([]string(nil), info.BeforeNodes...)
|
||||
return map[string]any{
|
||||
"beforeNodes": before,
|
||||
"resumeTarget": firstString(before),
|
||||
"address": map[string]any{
|
||||
"kind": "compose_interrupt",
|
||||
"beforeNodes": before,
|
||||
"path": strings.Join(before, "/"),
|
||||
},
|
||||
"raw": fmt.Sprintf("%+v", info),
|
||||
}
|
||||
}
|
||||
|
||||
func firstString(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func nextHITLNodeID(info *compose.InterruptInfo, hitlIDs []string) string {
|
||||
if info != nil && len(info.BeforeNodes) > 0 {
|
||||
for _, id := range info.BeforeNodes {
|
||||
for _, hitl := range hitlIDs {
|
||||
if id == hitl {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return info.BeforeNodes[0]
|
||||
}
|
||||
if len(hitlIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return hitlIDs[0]
|
||||
}
|
||||
|
||||
// ResumeWorkflowRun continues a run paused at HITL after human decision.
|
||||
func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved bool, comment string) (*RunResult, error) {
|
||||
run, err := args.DB.GetWorkflowRun(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if run == nil {
|
||||
return nil, fmt.Errorf("工作流运行不存在")
|
||||
}
|
||||
if run.Status != "awaiting_hitl" {
|
||||
return nil, fmt.Errorf("工作流运行不在等待审批状态: %s", run.Status)
|
||||
}
|
||||
wf, err := args.DB.GetWorkflowDefinition(run.WorkflowID)
|
||||
if err != nil || wf == nil {
|
||||
return nil, fmt.Errorf("工作流定义不存在")
|
||||
}
|
||||
graph, err := parseGraph(wf.GraphJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var input map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(run.InputJSON), &input)
|
||||
state := newWorkflowLocalState(input, runID)
|
||||
if state.Inputs == nil {
|
||||
state.Inputs = map[string]any{}
|
||||
}
|
||||
state.Inputs["_hitl_approved"] = approved
|
||||
state.Inputs["_hitl_comment"] = strings.TrimSpace(comment)
|
||||
state.Inputs["_hitl_node_id"] = run.PendingHITLNodeID
|
||||
|
||||
if !approved {
|
||||
errText := strings.TrimSpace(comment)
|
||||
if errText == "" {
|
||||
errText = "人工审批拒绝"
|
||||
}
|
||||
_ = args.DB.FinishWorkflowRun(runID, "rejected", "", errText)
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_rejected", fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", run.PendingHITLNodeID), map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": run.PendingHITLNodeID,
|
||||
"comment": errText,
|
||||
})
|
||||
}
|
||||
return &RunResult{
|
||||
RunID: runID,
|
||||
Response: fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", run.PendingHITLNodeID),
|
||||
Status: "rejected",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_resumed", "人工审批已通过,继续执行", map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": run.PendingHITLNodeID,
|
||||
"comment": strings.TrimSpace(comment),
|
||||
})
|
||||
}
|
||||
|
||||
_ = args.DB.SetWorkflowRunStatus(runID, "running")
|
||||
resumeArgs := args
|
||||
if strings.TrimSpace(resumeArgs.ConversationID) == "" {
|
||||
resumeArgs.ConversationID = run.ConversationID
|
||||
}
|
||||
|
||||
awaiting, err := invokeEinoGraph(ctx, resumeArgs, runID, wf.ID, run.WorkflowVersion, graph, state, true)
|
||||
if err != nil {
|
||||
if IsAwaitingHITL(err) {
|
||||
return &RunResult{
|
||||
RunID: runID,
|
||||
Status: "awaiting_hitl",
|
||||
Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID),
|
||||
AwaitingHITL: true,
|
||||
}, nil
|
||||
}
|
||||
_ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
_ = awaiting
|
||||
|
||||
output := map[string]interface{}{
|
||||
"workflowId": wf.ID,
|
||||
"workflowName": wf.Name,
|
||||
"workflowVersion": wf.Version,
|
||||
"workflowRunId": runID,
|
||||
"status": "completed",
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"executedNodes": state.Executed,
|
||||
"skippedNodes": state.Skipped,
|
||||
"engine": "eino_workflow",
|
||||
}
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
response := renderWorkflowResponse(args.Role.Name, wf.Name, wf.Version, runID, state)
|
||||
_ = args.DB.FinishWorkflowRun(runID, "completed", string(outputJSON), "")
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_done", fmt.Sprintf("流程「%s」运行完成", wf.Name), map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"workflowId": wf.ID,
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"response": response,
|
||||
"engine": "eino_workflow",
|
||||
})
|
||||
}
|
||||
return &RunResult{Response: response, RunID: runID, Status: "completed"}, nil
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func testWorkflowDB(t *testing.T) *database.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(dir, "workflow.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func linearStartOutputGraph() string {
|
||||
return `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 120}, "config": {"output_key": "result", "source_binding": {"from": "inputs", "field": "message"}}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "out-1"}
|
||||
],
|
||||
"config": {"schema_version": 1}
|
||||
}`
|
||||
}
|
||||
|
||||
func conditionBranchGraph() string {
|
||||
return `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "cond-1", "type": "condition", "label": "判断", "position": {"x": 0, "y": 80}, "config": {"expression": "{{inputs.message}} == yes"}},
|
||||
{"id": "out-yes", "type": "output", "label": "是", "position": {"x": -80, "y": 160}, "config": {"output_key": "branch", "static_value": "yes"}},
|
||||
{"id": "out-no", "type": "output", "label": "否", "position": {"x": 80, "y": 160}, "config": {"output_key": "branch", "static_value": "no"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "cond-1"},
|
||||
{"id": "e2", "source": "cond-1", "target": "out-yes", "label": "是"},
|
||||
{"id": "e3", "source": "cond-1", "target": "out-no", "label": "否"}
|
||||
],
|
||||
"config": {"schema_version": 1}
|
||||
}`
|
||||
}
|
||||
|
||||
func TestValidateGraphJSON_linear(t *testing.T) {
|
||||
if err := ValidateGraphJSON(context.Background(), linearStartOutputGraph()); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGraphJSON_rejectsInvalidGraphs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
graph string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "start with incoming edge",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "agent-1", "type": "agent", "label": "Agent", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "agent-1"},
|
||||
{"id": "e2", "source": "agent-1", "target": "start-1"},
|
||||
{"id": "e3", "source": "agent-1", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "开始节点",
|
||||
},
|
||||
{
|
||||
name: "output with outgoing edge",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {"output_key": "result"}},
|
||||
{"id": "end-1", "type": "end", "label": "结束", "position": {"x": 0, "y": 160}, "config": {}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "out-1"},
|
||||
{"id": "e2", "source": "out-1", "target": "end-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "不能有出边",
|
||||
},
|
||||
{
|
||||
name: "tool without name",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "tool-1", "type": "tool", "label": "工具", "position": {"x": 0, "y": 80}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "tool-1"},
|
||||
{"id": "e2", "source": "tool-1", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "必须选择 MCP 工具",
|
||||
},
|
||||
{
|
||||
name: "condition with too many branches",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "cond-1", "type": "condition", "label": "判断", "position": {"x": 0, "y": 80}, "config": {"expression": "{{inputs.message}}"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出1", "position": {"x": -80, "y": 160}, "config": {"output_key": "a"}},
|
||||
{"id": "out-2", "type": "output", "label": "输出2", "position": {"x": 0, "y": 160}, "config": {"output_key": "b"}},
|
||||
{"id": "out-3", "type": "output", "label": "输出3", "position": {"x": 80, "y": 160}, "config": {"output_key": "c"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "cond-1"},
|
||||
{"id": "e2", "source": "cond-1", "target": "out-1"},
|
||||
{"id": "e3", "source": "cond-1", "target": "out-2"},
|
||||
{"id": "e4", "source": "cond-1", "target": "out-3"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "1 到 2 条出边",
|
||||
},
|
||||
{
|
||||
name: "orphan node",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {"output_key": "result"}},
|
||||
{"id": "agent-1", "type": "agent", "label": "孤岛", "position": {"x": 200, "y": 80}, "config": {"instruction": "noop"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "不可达",
|
||||
},
|
||||
{
|
||||
name: "cycle",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "agent-1", "type": "agent", "label": "Agent1", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop", "output_key": "a1"}},
|
||||
{"id": "agent-2", "type": "agent", "label": "Agent2", "position": {"x": 0, "y": 160}, "config": {"instruction": "noop", "output_key": "a2"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 240}, "config": {"output_key": "result"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "agent-1"},
|
||||
{"id": "e2", "source": "agent-1", "target": "agent-2"},
|
||||
{"id": "e3", "source": "agent-2", "target": "agent-1"},
|
||||
{"id": "e4", "source": "agent-2", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "环路",
|
||||
},
|
||||
{
|
||||
name: "output without key",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "输出变量名",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateGraphJSON(context.Background(), tt.graph)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileEngine_linear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
g, err := parseGraph(linearStartOutputGraph())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := defaultEngine.compile(ctx, g); err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestWorkflowRun(t *testing.T, db *database.DB, runID string) {
|
||||
t.Helper()
|
||||
if err := db.CreateWorkflowRun(&database.WorkflowRun{
|
||||
ID: runID,
|
||||
WorkflowID: "test-wf",
|
||||
Status: "running",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWorkflowRun: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEinoGraph_linearStartOutput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
db := testWorkflowDB(t)
|
||||
createTestWorkflowRun(t, db, "run-linear")
|
||||
g, err := parseGraph(linearStartOutputGraph())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := newWorkflowLocalState(map[string]interface{}{"message": "ping"}, "run-linear")
|
||||
args := RunArgs{DB: db}
|
||||
if err := executeEinoGraph(ctx, args, "run-linear", "test-wf", 1, g, state); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got := state.Outputs["result"]; got != "ping" {
|
||||
t.Fatalf("outputs[result] = %v, want ping", got)
|
||||
}
|
||||
if len(state.Executed) != 2 {
|
||||
t.Fatalf("executed nodes = %d, want 2", len(state.Executed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEinoGraph_conditionBranch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
db := testWorkflowDB(t)
|
||||
createTestWorkflowRun(t, db, "run-yes")
|
||||
createTestWorkflowRun(t, db, "run-no")
|
||||
g, err := parseGraph(conditionBranchGraph())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stateYes := newWorkflowLocalState(map[string]interface{}{"message": "yes"}, "run-yes")
|
||||
if err := executeEinoGraph(ctx, RunArgs{DB: db}, "run-yes", "test-wf-branch", 1, g, stateYes); err != nil {
|
||||
t.Fatalf("execute yes: %v", err)
|
||||
}
|
||||
if got := stateYes.Outputs["branch"]; got != "yes" {
|
||||
t.Fatalf("yes branch output = %v", got)
|
||||
}
|
||||
|
||||
stateNo := newWorkflowLocalState(map[string]interface{}{"message": "no"}, "run-no")
|
||||
if err := executeEinoGraph(ctx, RunArgs{DB: db}, "run-no", "test-wf-branch", 1, g, stateNo); err != nil {
|
||||
t.Fatalf("execute no: %v", err)
|
||||
}
|
||||
if got := stateNo.Outputs["branch"]; got != "no" {
|
||||
t.Fatalf("no branch output = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRoleBoundWorkflow_integration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
db := testWorkflowDB(t)
|
||||
graph := linearStartOutputGraph()
|
||||
if err := db.UpsertWorkflowDefinition(&database.WorkflowDefinition{
|
||||
ID: "wf-linear",
|
||||
Name: "线性流程",
|
||||
Version: 1,
|
||||
GraphJSON: graph,
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role := config.RoleConfig{
|
||||
Name: "tester",
|
||||
Enabled: true,
|
||||
WorkflowID: "wf-linear",
|
||||
WorkflowPolicy: "auto",
|
||||
}
|
||||
result, err := RunRoleBoundWorkflow(ctx, RunArgs{
|
||||
DB: db,
|
||||
Logger: zap.NewNop(),
|
||||
Role: role,
|
||||
UserMessage: "from-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunRoleBoundWorkflow: %v", err)
|
||||
}
|
||||
if result == nil || result.RunID == "" {
|
||||
t.Fatal("expected run result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompiledCache_reuse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
InvalidateCompiledCache("cache-wf")
|
||||
g, err := parseGraph(linearStartOutputGraph())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a1, err := defaultEngine.getOrCompile(ctx, "cache-wf", 1, g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a2, err := defaultEngine.getOrCompile(ctx, "cache-wf", 1, g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a1 != a2 {
|
||||
t.Fatal("expected cached artifact pointer reuse")
|
||||
}
|
||||
InvalidateCompiledCache("cache-wf")
|
||||
a3, err := defaultEngine.getOrCompile(ctx, "cache-wf", 1, g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a1 == a3 {
|
||||
t.Fatal("expected new artifact after invalidation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type workflowRuntimeCtxKey struct{}
|
||||
|
||||
// workflowRuntime carries per-run execution context into Eino Workflow local state.
|
||||
type workflowRuntime struct {
|
||||
args RunArgs
|
||||
runID string
|
||||
idx *graphIndex
|
||||
state *WorkflowLocalState
|
||||
}
|
||||
|
||||
func withWorkflowRuntime(ctx context.Context, rt *workflowRuntime) context.Context {
|
||||
return context.WithValue(ctx, workflowRuntimeCtxKey{}, rt)
|
||||
}
|
||||
|
||||
func workflowRuntimeFrom(ctx context.Context) *workflowRuntime {
|
||||
rt, _ := ctx.Value(workflowRuntimeCtxKey{}).(*workflowRuntime)
|
||||
return rt
|
||||
}
|
||||
|
||||
func newWorkflowRuntime(args RunArgs, runID string, idx *graphIndex, inputs map[string]interface{}) *workflowRuntime {
|
||||
return &workflowRuntime{
|
||||
args: args,
|
||||
runID: runID,
|
||||
idx: idx,
|
||||
state: newWorkflowLocalState(inputs, runID),
|
||||
}
|
||||
}
|
||||
|
||||
// RunArgs is the execution context for a role-bound workflow run.
|
||||
type RunArgs struct {
|
||||
DB *database.DB
|
||||
Logger *zap.Logger
|
||||
Role config.RoleConfig
|
||||
AppCfg *config.Config
|
||||
Agent *agent.Agent
|
||||
ConversationID string
|
||||
ProjectID string
|
||||
UserMessage string
|
||||
History []agent.ChatMessage
|
||||
RoleTools []string
|
||||
AgentsMarkdownDir string
|
||||
SystemPromptExtra string
|
||||
AssistantMessageID string
|
||||
Progress agent.ProgressCallback
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
Response string
|
||||
RunID string
|
||||
Status string
|
||||
AwaitingHITL bool
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
type compiledArtifact struct {
|
||||
runnable compose.Runnable[WorkflowInput, WorkflowOutput]
|
||||
idx *graphIndex
|
||||
hitlIDs []string
|
||||
}
|
||||
|
||||
// Engine compiles and caches Eino Workflow artifacts.
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*compiledArtifact
|
||||
cpStore compose.CheckPointStore
|
||||
cpStoreMu sync.Once
|
||||
cpStoreErr error
|
||||
checkpointDir string
|
||||
}
|
||||
|
||||
var defaultEngine = &Engine{
|
||||
cache: make(map[string]*compiledArtifact),
|
||||
checkpointDir: "data/workflow-checkpoints",
|
||||
}
|
||||
|
||||
// SetCheckpointDir overrides the workflow checkpoint root (mainly for tests).
|
||||
func SetCheckpointDir(dir string) {
|
||||
defaultEngine.mu.Lock()
|
||||
defer defaultEngine.mu.Unlock()
|
||||
defaultEngine.checkpointDir = strings.TrimSpace(dir)
|
||||
defaultEngine.cpStore = nil
|
||||
defaultEngine.cpStoreErr = nil
|
||||
defaultEngine.cpStoreMu = sync.Once{}
|
||||
}
|
||||
|
||||
func (e *Engine) checkpointStore() (compose.CheckPointStore, error) {
|
||||
e.cpStoreMu.Do(func() {
|
||||
e.cpStore, e.cpStoreErr = newFileCheckPointStore(e.checkpointDir)
|
||||
})
|
||||
return e.cpStore, e.cpStoreErr
|
||||
}
|
||||
|
||||
// InvalidateCompiledCache drops cached compilations for a workflow id.
|
||||
func InvalidateCompiledCache(workflowID string) {
|
||||
workflowID = strings.TrimSpace(workflowID)
|
||||
if workflowID == "" {
|
||||
return
|
||||
}
|
||||
defaultEngine.mu.Lock()
|
||||
defer defaultEngine.mu.Unlock()
|
||||
for key := range defaultEngine.cache {
|
||||
if strings.HasPrefix(key, workflowID+":") {
|
||||
delete(defaultEngine.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGraphJSON parses and trial-compiles a canvas graph (save-time gate).
|
||||
func ValidateGraphJSON(ctx context.Context, graphJSON string) error {
|
||||
g, err := parseGraph(graphJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx := indexGraph(g)
|
||||
if err := validateGraphDefinition(g, idx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = defaultEngine.compile(ctx, g)
|
||||
return err
|
||||
}
|
||||
|
||||
func hasTerminalNode(idx *graphIndex) bool {
|
||||
for id, node := range idx.nodes {
|
||||
if len(idx.outgoing[id]) == 0 {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(node.Type, "end") || strings.EqualFold(node.Type, "output") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *Engine) getOrCompile(ctx context.Context, workflowID string, version int, g *graphDef) (*compiledArtifact, error) {
|
||||
key := cacheKey(workflowID, version)
|
||||
e.mu.RLock()
|
||||
if art, ok := e.cache[key]; ok {
|
||||
e.mu.RUnlock()
|
||||
return art, nil
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
art, err := e.compile(ctx, g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
if existing, ok := e.cache[key]; ok {
|
||||
e.mu.Unlock()
|
||||
return existing, nil
|
||||
}
|
||||
e.cache[key] = art
|
||||
e.mu.Unlock()
|
||||
return art, nil
|
||||
}
|
||||
|
||||
func (e *Engine) compile(ctx context.Context, g *graphDef) (*compiledArtifact, error) {
|
||||
cpStore, err := e.checkpointStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := indexGraph(g)
|
||||
if err := validateGraphDefinition(g, idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hitlIDs := collectHITLNodeIDs(idx)
|
||||
compileOpts := []compose.GraphCompileOption{
|
||||
compose.WithGraphName("CyberStrikeWorkflow"),
|
||||
compose.WithCheckPointStore(cpStore),
|
||||
}
|
||||
if len(hitlIDs) > 0 {
|
||||
compileOpts = append(compileOpts, compose.WithInterruptBeforeNodes(hitlIDs))
|
||||
}
|
||||
|
||||
wf := compose.NewWorkflow[WorkflowInput, WorkflowOutput](
|
||||
compose.WithGenLocalState(func(runCtx context.Context) *WorkflowLocalState {
|
||||
if rt := workflowRuntimeFrom(runCtx); rt != nil && rt.state != nil {
|
||||
return rt.state
|
||||
}
|
||||
return &WorkflowLocalState{
|
||||
Outputs: make(map[string]any),
|
||||
NodeOutputs: make(map[string]map[string]any),
|
||||
NodeProceed: make(map[string]bool),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
nodeRefs := make(map[string]*compose.WorkflowNode, len(idx.nodes))
|
||||
for id, node := range idx.nodes {
|
||||
n := node
|
||||
if strings.EqualFold(n.Type, "agent") {
|
||||
sub, err := compileAgentSubgraph(ctx, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编译 Agent 子图 %s 失败: %w", id, err)
|
||||
}
|
||||
nodeRefs[id] = wf.AddGraphNode(id, sub)
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(n.Type, "start") {
|
||||
nodeRefs[id] = wf.AddLambdaNode(id, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowInput) (WorkflowNodeOutput, error) {
|
||||
return runWorkflowNodeLambda(runCtx, n)
|
||||
}))
|
||||
continue
|
||||
}
|
||||
nodeRefs[id] = wf.AddLambdaNode(id, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
return runWorkflowNodeLambda(runCtx, n)
|
||||
}))
|
||||
}
|
||||
|
||||
for id, node := range idx.nodes {
|
||||
if strings.EqualFold(node.Type, "condition") {
|
||||
if err := wireConditionBranch(wf, nodeRefs, idx, id, node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if hasConditionalOutgoingEdges(idx, id) {
|
||||
if err := wireEdgeConditionBranch(wf, nodeRefs, idx, id, node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, edge := range idx.outgoing[id] {
|
||||
if target, ok := nodeRefs[edge.Target]; ok {
|
||||
target.AddInput(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, startID := range findStartNodeIDs(idx) {
|
||||
if ref, ok := nodeRefs[startID]; ok {
|
||||
ref.AddInput(compose.START)
|
||||
}
|
||||
}
|
||||
|
||||
endNode := wf.End()
|
||||
for id, node := range idx.nodes {
|
||||
if len(idx.outgoing[id]) == 0 || strings.EqualFold(node.Type, "end") {
|
||||
endNode.AddInput(id, compose.ToField(id))
|
||||
}
|
||||
}
|
||||
|
||||
runnable, err := wf.Compile(ctx, compileOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &compiledArtifact{runnable: runnable, idx: idx, hitlIDs: hitlIDs}, nil
|
||||
}
|
||||
|
||||
func collectHITLNodeIDs(idx *graphIndex) []string {
|
||||
var ids []string
|
||||
for id, node := range idx.nodes {
|
||||
if strings.EqualFold(node.Type, "hitl") {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func runWorkflowNodeLambda(runCtx context.Context, n graphNode) (WorkflowNodeOutput, error) {
|
||||
localRT := workflowRuntimeFrom(runCtx)
|
||||
if localRT == nil {
|
||||
return nil, fmt.Errorf("workflow runtime missing in context")
|
||||
}
|
||||
if err := prepareNodeInputState(localRT, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, proceed, err := executeNode(runCtx, localRT.args, localRT.runID, n, localRT.state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localRT.state.NodeOutputs[n.ID] = result
|
||||
localRT.state.LastOutput = result
|
||||
if !proceed && !strings.EqualFold(n.Type, "end") {
|
||||
label := firstNonEmpty(n.Label, n.ID)
|
||||
if errText := cfgString(result, "error"); errText != "" {
|
||||
return result, fmt.Errorf("节点「%s」失败: %s", label, errText)
|
||||
}
|
||||
return result, fmt.Errorf("节点「%s」未继续执行", label)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package workflow
|
||||
|
||||
import "errors"
|
||||
|
||||
// AwaitingHITLError indicates the workflow paused before a HITL node for human approval.
|
||||
type AwaitingHITLError struct {
|
||||
RunID string
|
||||
NodeID string
|
||||
NodeLabel string
|
||||
Prompt string
|
||||
Reviewer string
|
||||
}
|
||||
|
||||
func (e *AwaitingHITLError) Error() string {
|
||||
if e == nil {
|
||||
return "workflow awaiting human approval"
|
||||
}
|
||||
return "workflow awaiting human approval at node " + e.NodeID
|
||||
}
|
||||
|
||||
func IsAwaitingHITL(err error) bool {
|
||||
var target *AwaitingHITLError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var expressionOps = []string{">=", "<=", "==", "!=", " contains ", " matches ", ">", "<"}
|
||||
var jsonFuncRe = regexp.MustCompile(`^(jsonpath|jq)\((.*),\s*(['"][^'"]+['"])\)$`)
|
||||
var jsonFuncFindRe = regexp.MustCompile(`(jsonpath|jq)\([^)]*\)`)
|
||||
var singleTemplateVarRe = regexp.MustCompile(`^\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}$`)
|
||||
|
||||
func validateConditionExpression(expr string) error {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return fmt.Errorf("条件表达式不能为空")
|
||||
}
|
||||
for _, part := range splitBoolExpr(expr, "||") {
|
||||
for _, atom := range splitBoolExpr(part, "&&") {
|
||||
if err := validateConditionAtom(atom); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConditionAtom(expr string) error {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return fmt.Errorf("条件表达式存在空片段")
|
||||
}
|
||||
if strings.Count(expr, "{{") != strings.Count(expr, "}}") {
|
||||
return fmt.Errorf("条件表达式模板括号不匹配: %s", expr)
|
||||
}
|
||||
if err := validateJSONFunctions(expr); err != nil {
|
||||
return err
|
||||
}
|
||||
if left, right, ok := splitExpressionAtom(expr, " matches "); ok {
|
||||
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
|
||||
return fmt.Errorf("matches 表达式两侧不能为空: %s", expr)
|
||||
}
|
||||
pattern := cleanComparable(resolveStaticTemplate(right))
|
||||
if _, err := regexp.Compile(pattern); err != nil {
|
||||
return fmt.Errorf("matches 正则非法: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, op := range expressionOps {
|
||||
if op == " matches " {
|
||||
continue
|
||||
}
|
||||
if left, right, ok := splitExpressionAtom(expr, op); ok {
|
||||
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
|
||||
return fmt.Errorf("表达式 %q 两侧不能为空: %s", strings.TrimSpace(op), expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func evalCondition(expr string, state *WorkflowLocalState) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return true
|
||||
}
|
||||
orParts := splitBoolExpr(expr, "||")
|
||||
for _, orPart := range orParts {
|
||||
andOK := true
|
||||
for _, atom := range splitBoolExpr(orPart, "&&") {
|
||||
if !evalConditionAtom(atom, state) {
|
||||
andOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if andOK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evalConditionAtom(expr string, state *WorkflowLocalState) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
for _, op := range expressionOps {
|
||||
if left, right, ok := splitExpressionAtom(expr, op); ok {
|
||||
left = strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(left, state)))
|
||||
right = strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(right, state)))
|
||||
switch strings.TrimSpace(op) {
|
||||
case "==":
|
||||
return cleanComparable(left) == cleanComparable(right)
|
||||
case "!=":
|
||||
return cleanComparable(left) != cleanComparable(right)
|
||||
case ">":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a > b })
|
||||
case ">=":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a >= b })
|
||||
case "<":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a < b })
|
||||
case "<=":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a <= b })
|
||||
case "contains":
|
||||
return strings.Contains(cleanComparable(left), cleanComparable(right))
|
||||
case "matches":
|
||||
matched, _ := regexp.MatchString(cleanComparable(right), cleanComparable(left))
|
||||
return matched
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved := strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(expr, state)))
|
||||
v := strings.ToLower(cleanComparable(resolved))
|
||||
return v != "" && v != "false" && v != "0" && v != "null"
|
||||
}
|
||||
|
||||
func splitBoolExpr(expr, sep string) []string {
|
||||
parts := strings.Split(expr, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if s := strings.TrimSpace(part); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{strings.TrimSpace(expr)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitExpressionAtom(expr, op string) (string, string, bool) {
|
||||
if strings.TrimSpace(op) == "contains" || strings.TrimSpace(op) == "matches" {
|
||||
idx := strings.Index(expr, op)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return expr[:idx], expr[idx+len(op):], true
|
||||
}
|
||||
idx := strings.Index(expr, op)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return expr[:idx], expr[idx+len(op):], true
|
||||
}
|
||||
|
||||
func compareNumeric(left, right string, cmp func(float64, float64) bool) bool {
|
||||
a, errA := strconv.ParseFloat(cleanComparable(left), 64)
|
||||
b, errB := strconv.ParseFloat(cleanComparable(right), 64)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return cmp(a, b)
|
||||
}
|
||||
|
||||
func resolveStaticTemplate(s string) string {
|
||||
return templateVarRe.ReplaceAllString(s, "value")
|
||||
}
|
||||
|
||||
func resolveExpressionOperand(raw string, state *WorkflowLocalState) any {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if m := jsonFuncRe.FindStringSubmatch(raw); len(m) == 4 {
|
||||
inputExpr := strings.TrimSpace(m[2])
|
||||
path := strings.Trim(m[3], `"'`)
|
||||
input := resolveExpressionOperand(inputExpr, state)
|
||||
return evalJSONPathValue(input, path)
|
||||
}
|
||||
if m := singleTemplateVarRe.FindStringSubmatch(raw); len(m) == 2 {
|
||||
return valueFromPath(m[1], state)
|
||||
}
|
||||
return resolveTemplate(raw, state)
|
||||
}
|
||||
|
||||
func validateJSONFunctions(expr string) error {
|
||||
for _, candidate := range jsonFuncFindRe.FindAllString(expr, -1) {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
m := jsonFuncRe.FindStringSubmatch(candidate)
|
||||
if len(m) != 4 {
|
||||
return fmt.Errorf("JSONPath/JQ 函数格式应为 jsonpath(value, \"$.path\") 或 jq(value, \".path\")")
|
||||
}
|
||||
if err := validateJSONPathSyntax(strings.Trim(m[3], `"'`)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvalCondition_extendedOperators(t *testing.T) {
|
||||
state := newWorkflowLocalState(map[string]interface{}{"score": 9, "message": "status: ok"}, "run-expr")
|
||||
state.LastOutput = map[string]any{"output": "asset-123.example.com"}
|
||||
|
||||
tests := []string{
|
||||
"{{inputs.score}} >= 9",
|
||||
"{{inputs.message}} contains ok",
|
||||
"{{previous.output}} matches ^asset-[0-9]+\\.example\\.com$",
|
||||
"{{inputs.score}} > 5 && {{inputs.message}} contains status",
|
||||
}
|
||||
for _, expr := range tests {
|
||||
if err := validateConditionExpression(expr); err != nil {
|
||||
t.Fatalf("validate %q: %v", expr, err)
|
||||
}
|
||||
if !evalCondition(expr, state) {
|
||||
t.Fatalf("evalCondition(%q) = false, want true", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalCondition_jsonPathAndJQSafeSubset(t *testing.T) {
|
||||
state := newWorkflowLocalState(map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"risk": 9,
|
||||
"items": []any{
|
||||
map[string]any{"name": "first"},
|
||||
},
|
||||
},
|
||||
}, "run-jsonpath")
|
||||
state.LastOutput = map[string]any{"output": `{"status":"ok","score":7}`}
|
||||
|
||||
tests := []string{
|
||||
`jsonpath({{inputs.payload}}, "$.risk") >= 8`,
|
||||
`jq({{inputs.payload}}, ".items[0].name") == first`,
|
||||
`jsonpath({{previous.output}}, "$.status") == ok`,
|
||||
}
|
||||
for _, expr := range tests {
|
||||
if err := validateConditionExpression(expr); err != nil {
|
||||
t.Fatalf("validate %q: %v", expr, err)
|
||||
}
|
||||
if !evalCondition(expr, state) {
|
||||
t.Fatalf("evalCondition(%q) = false, want true", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUpstreamOutputs_allMerge(t *testing.T) {
|
||||
got := mergeUpstreamOutputs(JoinAllMerge, []map[string]any{
|
||||
{"output": "a", "left": 1},
|
||||
{"output": "b", "right": 2},
|
||||
})
|
||||
if got["kind"] != "join" || got["strategy"] != JoinAllMerge {
|
||||
t.Fatalf("join metadata = %#v", got)
|
||||
}
|
||||
values, ok := got["output"].([]any)
|
||||
if !ok || len(values) != 2 || values[0] != "a" || values[1] != "b" {
|
||||
t.Fatalf("merged output = %#v", got["output"])
|
||||
}
|
||||
if got["left"] != 1 || got["right"] != 2 {
|
||||
t.Fatalf("merged fields = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUpstreamOutputs_firstNonEmpty(t *testing.T) {
|
||||
got := mergeUpstreamOutputs(JoinFirstNonEmpty, []map[string]any{
|
||||
{"output": ""},
|
||||
{"output": "winner"},
|
||||
})
|
||||
if got["output"] != "winner" {
|
||||
t.Fatalf("output = %#v, want winner", got["output"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunGraphJSON_simulatesUnsafeNodes(t *testing.T) {
|
||||
graph := `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "agent-1", "type": "agent", "label": "Agent", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop", "output_key": "agent_result"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result", "source_binding": {"from": "outputs", "field": "agent_result"}}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "agent-1"},
|
||||
{"id": "e2", "source": "agent-1", "target": "out-1"}
|
||||
]
|
||||
}`
|
||||
result, err := DryRunGraphJSON(nilContext(), graph, map[string]any{"message": "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("DryRunGraphJSON: %v", err)
|
||||
}
|
||||
if got := result.Outputs["result"]; got != "[dry-run] agent execution skipped" {
|
||||
t.Fatalf("result output = %#v", got)
|
||||
}
|
||||
if len(result.Trace) != 3 {
|
||||
t.Fatalf("trace len = %d, want 3", len(result.Trace))
|
||||
}
|
||||
}
|
||||
|
||||
func nilContext() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type graphDef struct {
|
||||
Nodes []graphNode `json:"nodes"`
|
||||
Edges []graphEdge `json:"edges"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
type graphNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Position graphPosition `json:"position"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
type graphEdge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Label string `json:"label"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
type graphPosition struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
type graphIndex struct {
|
||||
nodes map[string]graphNode
|
||||
outgoing map[string][]graphEdge
|
||||
incoming map[string][]graphEdge
|
||||
}
|
||||
|
||||
func parseGraph(raw string) (*graphDef, error) {
|
||||
var g graphDef
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &g); err != nil {
|
||||
return nil, fmt.Errorf("解析工作流图失败: %w", err)
|
||||
}
|
||||
if len(g.Nodes) == 0 {
|
||||
return nil, fmt.Errorf("工作流没有节点")
|
||||
}
|
||||
if g.Config == nil {
|
||||
g.Config = make(map[string]any)
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func indexGraph(g *graphDef) *graphIndex {
|
||||
idx := &graphIndex{
|
||||
nodes: make(map[string]graphNode, len(g.Nodes)),
|
||||
outgoing: make(map[string][]graphEdge),
|
||||
incoming: make(map[string][]graphEdge),
|
||||
}
|
||||
for _, node := range g.Nodes {
|
||||
node.ID = strings.TrimSpace(node.ID)
|
||||
if node.ID == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(node.Type) == "" {
|
||||
node.Type = "tool"
|
||||
}
|
||||
if node.Config == nil {
|
||||
node.Config = make(map[string]any)
|
||||
}
|
||||
idx.nodes[node.ID] = node
|
||||
}
|
||||
for _, edge := range g.Edges {
|
||||
if _, ok := idx.nodes[edge.Source]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := idx.nodes[edge.Target]; !ok {
|
||||
continue
|
||||
}
|
||||
idx.outgoing[edge.Source] = append(idx.outgoing[edge.Source], edge)
|
||||
idx.incoming[edge.Target] = append(idx.incoming[edge.Target], edge)
|
||||
}
|
||||
for source := range idx.outgoing {
|
||||
sortEdgesByCanvas(idx.outgoing[source], idx.nodes)
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func sortEdgesByCanvas(edges []graphEdge, nodes map[string]graphNode) {
|
||||
sort.SliceStable(edges, func(i, j int) bool {
|
||||
a := nodes[edges[i].Target]
|
||||
b := nodes[edges[j].Target]
|
||||
if a.Position.Y != b.Position.Y {
|
||||
return a.Position.Y < b.Position.Y
|
||||
}
|
||||
if a.Position.X != b.Position.X {
|
||||
return a.Position.X < b.Position.X
|
||||
}
|
||||
return edges[i].Target < edges[j].Target
|
||||
})
|
||||
}
|
||||
|
||||
func sortNodeIDsByCanvas(ids []string, nodes map[string]graphNode) {
|
||||
sort.SliceStable(ids, func(i, j int) bool {
|
||||
a := nodes[ids[i]]
|
||||
b := nodes[ids[j]]
|
||||
if a.Position.Y != b.Position.Y {
|
||||
return a.Position.Y < b.Position.Y
|
||||
}
|
||||
if a.Position.X != b.Position.X {
|
||||
return a.Position.X < b.Position.X
|
||||
}
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
}
|
||||
|
||||
func findStartNodeIDs(idx *graphIndex) []string {
|
||||
var queue []string
|
||||
for id, node := range idx.nodes {
|
||||
if strings.EqualFold(node.Type, "start") {
|
||||
queue = append(queue, id)
|
||||
}
|
||||
}
|
||||
if len(queue) == 0 {
|
||||
inDegree := make(map[string]int, len(idx.nodes))
|
||||
for id := range idx.nodes {
|
||||
inDegree[id] = 0
|
||||
}
|
||||
for _, edges := range idx.outgoing {
|
||||
for _, edge := range edges {
|
||||
inDegree[edge.Target]++
|
||||
}
|
||||
}
|
||||
for id, deg := range inDegree {
|
||||
if deg == 0 {
|
||||
queue = append(queue, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
sortNodeIDsByCanvas(queue, idx.nodes)
|
||||
return queue
|
||||
}
|
||||
|
||||
func branchNodeID(nodeID string) string {
|
||||
return nodeID + "__eino_branch"
|
||||
}
|
||||
|
||||
func edgeBranchNodeID(nodeID string) string {
|
||||
return nodeID + "__eino_edge_branch"
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
)
|
||||
|
||||
// HITLDecision is a human decision on a workflow approval node.
|
||||
type HITLDecision struct {
|
||||
Approved bool
|
||||
Comment string
|
||||
}
|
||||
|
||||
var hitlWaiters sync.Map // runID -> chan HITLDecision
|
||||
|
||||
func registerHITLWaiter(runID string) chan HITLDecision {
|
||||
ch := make(chan HITLDecision, 1)
|
||||
hitlWaiters.Store(runID, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func unregisterHITLWaiter(runID string, ch chan HITLDecision) {
|
||||
hitlWaiters.CompareAndDelete(runID, ch)
|
||||
}
|
||||
|
||||
// NotifyHITLDecision wakes a streaming workflow run waiting at a HITL node.
|
||||
// Returns true when an active waiter was signaled.
|
||||
func NotifyHITLDecision(runID string, decision HITLDecision) bool {
|
||||
v, ok := hitlWaiters.Load(runID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ch, ok := v.(chan HITLDecision)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case ch <- decision:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func readHITLDecisionFromDB(db *database.DB, runID string) (HITLDecision, bool, error) {
|
||||
if db == nil {
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
run, err := db.GetWorkflowRun(runID)
|
||||
if err != nil {
|
||||
return HITLDecision{}, false, err
|
||||
}
|
||||
if run == nil || strings.TrimSpace(run.PendingHITLJSON) == "" {
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
var pending map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(run.PendingHITLJSON), &pending); err != nil {
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
raw, ok := pending["decision"]
|
||||
if !ok {
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
decision := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw)))
|
||||
switch decision {
|
||||
case "approved", "approve":
|
||||
comment := ""
|
||||
if v, ok := pending["comment"]; ok {
|
||||
comment = strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
return HITLDecision{Approved: true, Comment: comment}, true, nil
|
||||
case "rejected", "reject":
|
||||
comment := ""
|
||||
if v, ok := pending["comment"]; ok {
|
||||
comment = strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
return HITLDecision{Approved: false, Comment: comment}, true, nil
|
||||
default:
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitWorkflowHITLDecision(ctx context.Context, db *database.DB, runID string) (HITLDecision, error) {
|
||||
ch := registerHITLWaiter(runID)
|
||||
defer unregisterHITLWaiter(runID, ch)
|
||||
return waitWorkflowHITLDecisionWithChannel(ctx, db, runID, ch)
|
||||
}
|
||||
|
||||
func waitWorkflowHITLDecisionWithChannel(ctx context.Context, db *database.DB, runID string, ch chan HITLDecision) (HITLDecision, error) {
|
||||
if d, ok, err := readHITLDecisionFromDB(db, runID); err != nil {
|
||||
return HITLDecision{}, err
|
||||
} else if ok {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return HITLDecision{}, ctx.Err()
|
||||
case d := <-ch:
|
||||
return d, nil
|
||||
case <-ticker.C:
|
||||
if d, ok, err := readHITLDecisionFromDB(db, runID); err != nil {
|
||||
return HITLDecision{}, err
|
||||
} else if ok {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
JoinAllMerge = "all_merge"
|
||||
JoinLastByCanvas = "last_by_canvas"
|
||||
JoinFirstNonEmpty = "first_non_empty"
|
||||
JoinFailFast = "fail_fast"
|
||||
)
|
||||
|
||||
var allowedJoinStrategies = map[string]bool{
|
||||
JoinAllMerge: true,
|
||||
JoinLastByCanvas: true,
|
||||
JoinFirstNonEmpty: true,
|
||||
JoinFailFast: true,
|
||||
}
|
||||
|
||||
func joinStrategy(node graphNode) string {
|
||||
strategy := strings.ToLower(cfgString(node.Config, "join_strategy"))
|
||||
if strategy == "" {
|
||||
return JoinAllMerge
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
|
||||
func prepareNodeInputState(rt *workflowRuntime, node graphNode) error {
|
||||
if rt == nil || rt.idx == nil || rt.state == nil {
|
||||
return nil
|
||||
}
|
||||
incoming := rt.idx.incoming[node.ID]
|
||||
if len(incoming) <= 1 {
|
||||
return nil
|
||||
}
|
||||
strategy := joinStrategy(node)
|
||||
if !allowedJoinStrategies[strategy] {
|
||||
return fmt.Errorf("节点「%s」使用了未知汇聚策略: %s", firstNonEmpty(node.Label, node.ID), strategy)
|
||||
}
|
||||
upstreams := make([]map[string]any, 0, len(incoming))
|
||||
for _, edge := range incoming {
|
||||
out := rt.state.NodeOutputs[edge.Source]
|
||||
if out == nil {
|
||||
continue
|
||||
}
|
||||
if isFailedNodeOutput(out) && strategy == JoinFailFast {
|
||||
return fmt.Errorf("上游节点「%s」失败,汇聚策略 fail_fast 中止", edge.Source)
|
||||
}
|
||||
upstreams = append(upstreams, out)
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
return nil
|
||||
}
|
||||
rt.state.LastOutput = mergeUpstreamOutputs(strategy, upstreams)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeUpstreamOutputs(strategy string, upstreams []map[string]any) map[string]any {
|
||||
switch strategy {
|
||||
case JoinLastByCanvas:
|
||||
return cloneNodeOutput(upstreams[len(upstreams)-1])
|
||||
case JoinFirstNonEmpty:
|
||||
for _, out := range upstreams {
|
||||
if !isEmptyOutputValue(out["output"]) {
|
||||
return cloneNodeOutput(out)
|
||||
}
|
||||
}
|
||||
return cloneNodeOutput(upstreams[0])
|
||||
default:
|
||||
merged := map[string]any{
|
||||
"kind": "join",
|
||||
"strategy": strategy,
|
||||
"upstreams": upstreams,
|
||||
}
|
||||
values := make([]any, 0, len(upstreams))
|
||||
for _, out := range upstreams {
|
||||
values = append(values, out["output"])
|
||||
for k, v := range out {
|
||||
if _, exists := merged[k]; !exists {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
merged["output"] = values
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
func cloneNodeOutput(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isEmptyOutputValue(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v)) == ""
|
||||
}
|
||||
|
||||
func isFailedNodeOutput(out map[string]any) bool {
|
||||
if out == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := out["error"]; ok && strings.TrimSpace(fmt.Sprint(v)) != "" {
|
||||
return true
|
||||
}
|
||||
if v, ok := out["is_error"]; ok {
|
||||
return strings.EqualFold(fmt.Sprint(v), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func evalJSONPathValue(input any, path string) any {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || path == "$" || path == "." {
|
||||
return input
|
||||
}
|
||||
if strings.HasPrefix(path, "$.") {
|
||||
path = strings.TrimPrefix(path, "$.")
|
||||
} else if strings.HasPrefix(path, ".") {
|
||||
path = strings.TrimPrefix(path, ".")
|
||||
} else if strings.HasPrefix(path, "$") {
|
||||
path = strings.TrimPrefix(path, "$")
|
||||
}
|
||||
cur := normalizeJSONInput(input)
|
||||
for _, token := range parseJSONPathTokens(path) {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
switch v := cur.(type) {
|
||||
case map[string]any:
|
||||
cur = v[token]
|
||||
case []any:
|
||||
idx, err := strconv.Atoi(token)
|
||||
if err != nil || idx < 0 || idx >= len(v) {
|
||||
return ""
|
||||
}
|
||||
cur = v[idx]
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if cur == nil {
|
||||
return ""
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func normalizeJSONInput(input any) any {
|
||||
switch v := input.(type) {
|
||||
case string:
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(v), &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return v
|
||||
case []byte:
|
||||
var decoded any
|
||||
if err := json.Unmarshal(v, &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return string(v)
|
||||
default:
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONPathTokens(path string) []string {
|
||||
var tokens []string
|
||||
var buf strings.Builder
|
||||
for i := 0; i < len(path); i++ {
|
||||
ch := path[i]
|
||||
switch ch {
|
||||
case '.':
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
case '[':
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(path) && path[j] != ']' {
|
||||
j++
|
||||
}
|
||||
if j <= len(path) {
|
||||
token := strings.Trim(path[i+1:j], `"' `)
|
||||
tokens = append(tokens, token)
|
||||
i = j
|
||||
}
|
||||
default:
|
||||
buf.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func validateJSONPathSyntax(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("JSONPath 不能为空")
|
||||
}
|
||||
if !strings.HasPrefix(path, "$") && !strings.HasPrefix(path, ".") {
|
||||
return fmt.Errorf("JSONPath/JQ 路径必须以 $ 或 . 开头")
|
||||
}
|
||||
if strings.Contains(path, "..") || strings.ContainsAny(path, "*?()|") {
|
||||
return fmt.Errorf("仅支持安全路径子集,不支持通配符、递归或表达式")
|
||||
}
|
||||
if strings.Count(path, "[") != strings.Count(path, "]") {
|
||||
return fmt.Errorf("JSONPath 方括号不匹配")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func accumulateWorkflowMetric(state *WorkflowLocalState, key string, delta any) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
if state.Metrics == nil {
|
||||
state.Metrics = make(map[string]any)
|
||||
}
|
||||
current := numericMetric(state.Metrics[key])
|
||||
state.Metrics[key] = current + numericMetric(delta)
|
||||
}
|
||||
|
||||
func numericMetric(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n)
|
||||
case int32:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case float32:
|
||||
return float64(n)
|
||||
case float64:
|
||||
return n
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(n, 64)
|
||||
return f
|
||||
default:
|
||||
f, _ := strconv.ParseFloat(fmt.Sprint(v), 64)
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
func collectAgentMetrics(state *WorkflowLocalState, data interface{}) {
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok || state == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens", "cost", "input_tokens", "output_tokens"} {
|
||||
if v, ok := m[key]; ok {
|
||||
accumulateWorkflowMetric(state, key, v)
|
||||
}
|
||||
}
|
||||
if usage, ok := m["usage"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens", "input_tokens", "output_tokens"} {
|
||||
if v, ok := usage[key]; ok {
|
||||
accumulateWorkflowMetric(state, key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode, state *WorkflowLocalState) (map[string]any, bool, error) {
|
||||
label := node.Label
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = node.ID
|
||||
}
|
||||
nodeRunID := uuid.NewString()
|
||||
startedAt := time.Now()
|
||||
incomingCount := 0
|
||||
if rt := workflowRuntimeFrom(ctx); rt != nil && rt.idx != nil {
|
||||
incomingCount = len(rt.idx.incoming[node.ID])
|
||||
}
|
||||
input := map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": label,
|
||||
"inputs": state.Inputs,
|
||||
"previous": state.LastOutput,
|
||||
"join": map[string]any{
|
||||
"strategy": joinStrategy(node),
|
||||
"incoming": incomingCount,
|
||||
},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
if err := args.DB.CreateWorkflowNodeRun(&database.WorkflowNodeRun{
|
||||
ID: nodeRunID,
|
||||
RunID: runID,
|
||||
NodeID: node.ID,
|
||||
Status: "running",
|
||||
InputJSON: string(inputJSON),
|
||||
StartedAt: startedAt,
|
||||
}); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_node_start", fmt.Sprintf("开始节点:%s", label), map[string]any{
|
||||
"workflowRunId": runID,
|
||||
"nodeRunId": nodeRunID,
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": label,
|
||||
})
|
||||
}
|
||||
|
||||
result, proceed, status, errText := runBuiltinNode(ctx, args, node, state)
|
||||
duration := time.Since(startedAt)
|
||||
if result == nil {
|
||||
result = map[string]any{}
|
||||
}
|
||||
result["duration_ms"] = duration.Milliseconds()
|
||||
result["finished_at"] = time.Now().Format(time.RFC3339Nano)
|
||||
result["status"] = status
|
||||
accumulateWorkflowMetric(state, "node_count", 1)
|
||||
accumulateWorkflowMetric(state, "duration_ms", duration.Milliseconds())
|
||||
if strings.EqualFold(node.Type, "tool") {
|
||||
accumulateWorkflowMetric(state, "tool_call_count", 1)
|
||||
}
|
||||
outputJSON, _ := json.Marshal(result)
|
||||
if err := args.DB.FinishWorkflowNodeRun(nodeRunID, status, string(outputJSON), errText); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if status == "skipped" {
|
||||
state.Skipped = append(state.Skipped, label)
|
||||
} else {
|
||||
state.Executed = append(state.Executed, label)
|
||||
}
|
||||
if args.Progress != nil {
|
||||
progressData := map[string]any{
|
||||
"workflowRunId": runID,
|
||||
"nodeRunId": nodeRunID,
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": label,
|
||||
"status": status,
|
||||
"durationMs": duration.Milliseconds(),
|
||||
"output": result,
|
||||
}
|
||||
progressMsg := fmt.Sprintf("节点完成:%s(%s)", label, status)
|
||||
if strings.EqualFold(node.Type, "condition") {
|
||||
matched := false
|
||||
if v, ok := result["matched"].(bool); ok {
|
||||
matched = v
|
||||
}
|
||||
expr := cfgString(node.Config, "expression")
|
||||
if matched {
|
||||
progressMsg = fmt.Sprintf("条件判断:%s → 是", label)
|
||||
} else {
|
||||
progressMsg = fmt.Sprintf("条件判断:%s → 否", label)
|
||||
}
|
||||
progressData["expression"] = expr
|
||||
progressData["matched"] = matched
|
||||
}
|
||||
args.Progress("workflow_node_result", progressMsg, progressData)
|
||||
}
|
||||
state.NodeProceed[node.ID] = proceed
|
||||
return result, proceed, nil
|
||||
}
|
||||
|
||||
func emitConditionBranchProgress(args RunArgs, runID string, node graphNode, edges []graphEdge, nodes map[string]graphNode, state *WorkflowLocalState) {
|
||||
if args.Progress == nil || len(edges) == 0 {
|
||||
return
|
||||
}
|
||||
for edgeIdx, edge := range edges {
|
||||
allowed := edgeAllowed(edge, node, edgeIdx, state)
|
||||
target := nodes[edge.Target]
|
||||
targetLabel := strings.TrimSpace(target.Label)
|
||||
if targetLabel == "" {
|
||||
targetLabel = edge.Target
|
||||
}
|
||||
branchLabel := strings.TrimSpace(edge.Label)
|
||||
if branchLabel == "" {
|
||||
switch edgeIdx {
|
||||
case 0:
|
||||
branchLabel = "是"
|
||||
case 1:
|
||||
branchLabel = "否"
|
||||
default:
|
||||
branchLabel = fmt.Sprintf("分支 %d", edgeIdx+1)
|
||||
}
|
||||
}
|
||||
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
|
||||
eventType := "workflow_branch_skipped"
|
||||
msg := fmt.Sprintf("跳过分支「%s」→ %s", branchLabel, targetLabel)
|
||||
if allowed {
|
||||
eventType = "workflow_branch_taken"
|
||||
msg = fmt.Sprintf("执行分支「%s」→ %s", branchLabel, targetLabel)
|
||||
}
|
||||
args.Progress(eventType, msg, map[string]any{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": node.Label,
|
||||
"branchLabel": branchLabel,
|
||||
"targetId": edge.Target,
|
||||
"targetLabel": targetLabel,
|
||||
"edgeCondition": cond,
|
||||
"matched": conditionMatched(state),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
)
|
||||
|
||||
func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
cfg := node.Config
|
||||
switch strings.ToLower(strings.TrimSpace(node.Type)) {
|
||||
case "start":
|
||||
return startOutputMap(node, state.Inputs["message"], state.Inputs["conversationId"], state.Inputs["projectId"]), true, "completed", ""
|
||||
case "condition":
|
||||
expr := cfgString(cfg, "expression")
|
||||
ok := evalCondition(expr, state)
|
||||
return conditionOutputMap(node, expr, ok), true, "completed", ""
|
||||
case "output":
|
||||
key := cfgString(cfg, "output_key")
|
||||
if key == "" {
|
||||
key = "result"
|
||||
}
|
||||
var value any
|
||||
if v := cfgString(cfg, "static_value"); v != "" {
|
||||
value = v
|
||||
} else {
|
||||
value = resolveOutputSourceBinding(cfg, state)
|
||||
}
|
||||
state.Outputs[key] = value
|
||||
return outputNodeOutputMap(node, key, value), true, "completed", ""
|
||||
case "end":
|
||||
value := resolveOutputSourceBinding(cfg, state)
|
||||
if b, ok := parseFieldBinding(cfg, "result_binding"); ok {
|
||||
value = resolveBinding(b, state)
|
||||
}
|
||||
return endOutputMap(node, value), false, "completed", ""
|
||||
case "tool":
|
||||
return runToolNode(ctx, args, node, state)
|
||||
case "agent":
|
||||
return runAgentNode(ctx, args, node, state)
|
||||
case "hitl":
|
||||
return runHITLNode(args, node, state)
|
||||
default:
|
||||
reason := "未知节点类型"
|
||||
out := outputMap(envelope("unknown", node.ID, node.Type, "skipped", ""), map[string]any{"skipped": true, "reason": reason})
|
||||
return out, true, "skipped", reason
|
||||
}
|
||||
}
|
||||
|
||||
func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
toolName := cfgString(node.Config, "tool_name")
|
||||
if toolName == "" {
|
||||
errText := "工具节点未选择 MCP 工具"
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
|
||||
}
|
||||
if args.Agent == nil {
|
||||
errText := "工具节点执行失败:Agent 为空"
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "error": errText}), false, "failed", errText
|
||||
}
|
||||
toolArgs, err := resolveToolArguments(node.Config, state)
|
||||
if err != nil {
|
||||
errText := fmt.Sprintf("工具参数不是合法 JSON:%v", err)
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "error": errText}), false, "failed", errText
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_tool_start", fmt.Sprintf("调用工具:%s", toolName), map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"tool": toolName,
|
||||
"args": toolArgs,
|
||||
})
|
||||
}
|
||||
result, err := args.Agent.ExecuteMCPToolForConversation(ctx, args.ConversationID, toolName, toolArgs)
|
||||
if err != nil {
|
||||
errText := err.Error()
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "arguments": toolArgs, "error": errText}), false, "failed", errText
|
||||
}
|
||||
output := ""
|
||||
executionID := ""
|
||||
isError := false
|
||||
if result != nil {
|
||||
output = result.Result
|
||||
executionID = result.ExecutionID
|
||||
isError = result.IsError
|
||||
}
|
||||
out := toolOutputMap(node, output, toolName, toolArgs, executionID, isError)
|
||||
if key := cfgString(node.Config, "output_key"); key != "" {
|
||||
state.Outputs[key] = output
|
||||
}
|
||||
if isError {
|
||||
errText := strings.TrimSpace(output)
|
||||
if errText == "" {
|
||||
errText = "工具返回错误"
|
||||
}
|
||||
return out, false, "failed", errText
|
||||
}
|
||||
return out, true, "completed", ""
|
||||
}
|
||||
|
||||
func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
if args.AppCfg == nil || args.Agent == nil {
|
||||
errText := "Agent 节点执行失败:应用配置或 Agent 为空"
|
||||
return outputMap(envelope("agent", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
|
||||
}
|
||||
mode := strings.ToLower(cfgString(node.Config, "agent_mode"))
|
||||
if mode == "" {
|
||||
mode = "eino_single"
|
||||
}
|
||||
inputSource := resolveNodeInputBinding(node.Config, state)
|
||||
message := buildAgentNodeMessage(node, state, inputSource)
|
||||
var result *multiagent.RunResult
|
||||
var err error
|
||||
state.SegmentMaxIteration = 0
|
||||
agentProgress := workflowAgentProgress(args.Progress, state, node)
|
||||
switch mode {
|
||||
case "eino_single", "single", "chat":
|
||||
result, err = multiagent.RunEinoSingleChatModelAgent(
|
||||
ctx,
|
||||
args.AppCfg,
|
||||
&args.AppCfg.MultiAgent,
|
||||
args.Agent,
|
||||
args.DB,
|
||||
args.Logger,
|
||||
args.ConversationID,
|
||||
args.ProjectID,
|
||||
message,
|
||||
args.History,
|
||||
args.RoleTools,
|
||||
agentProgress,
|
||||
nil,
|
||||
args.SystemPromptExtra,
|
||||
)
|
||||
default:
|
||||
result, err = multiagent.RunDeepAgent(
|
||||
ctx,
|
||||
args.AppCfg,
|
||||
&args.AppCfg.MultiAgent,
|
||||
args.Agent,
|
||||
args.DB,
|
||||
args.Logger,
|
||||
args.ConversationID,
|
||||
args.ProjectID,
|
||||
message,
|
||||
args.History,
|
||||
args.RoleTools,
|
||||
agentProgress,
|
||||
args.AgentsMarkdownDir,
|
||||
mode,
|
||||
nil,
|
||||
args.SystemPromptExtra,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
errText := err.Error()
|
||||
state.MainIterationOffset += state.SegmentMaxIteration
|
||||
return outputMap(envelope("agent", node.ID, node.Type, "failed", ""), map[string]any{"mode": mode, "error": errText}), false, "failed", errText
|
||||
}
|
||||
state.MainIterationOffset += state.SegmentMaxIteration
|
||||
response := ""
|
||||
mcpIDs := []string{}
|
||||
if result != nil {
|
||||
response = result.Response
|
||||
mcpIDs = result.MCPExecutionIDs
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_agent_output", response, map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"label": firstNonEmpty(node.Label, node.ID),
|
||||
"mode": mode,
|
||||
"inputSource": inputSource,
|
||||
"inputPreview": truncateWorkflowPreview(inputSource, 500),
|
||||
"mcpExecutionIds": mcpIDs,
|
||||
})
|
||||
}
|
||||
if key := cfgString(node.Config, "output_key"); key != "" {
|
||||
state.Outputs[key] = response
|
||||
}
|
||||
return agentOutputMap(node, response, mode, mcpIDs), true, "completed", ""
|
||||
}
|
||||
|
||||
func buildAgentNodeMessage(node graphNode, state *WorkflowLocalState, upstreamInput string) string {
|
||||
instruction := strings.TrimSpace(cfgString(node.Config, "instruction"))
|
||||
upstreamInput = strings.TrimSpace(upstreamInput)
|
||||
if instruction == "" {
|
||||
if upstreamInput != "" {
|
||||
return fmt.Sprintf("请基于上游节点输出继续处理:\n%s", upstreamInput)
|
||||
}
|
||||
return fmt.Sprintf("请基于上游节点输出继续处理:\n%v", state.LastOutput["output"])
|
||||
}
|
||||
if upstreamInput == "" {
|
||||
return instruction
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("上游输入:\n%s\n\n节点指令:\n%s", upstreamInput, instruction))
|
||||
}
|
||||
|
||||
func workflowAgentProgress(progress agent.ProgressCallback, state *WorkflowLocalState, node graphNode) agent.ProgressCallback {
|
||||
if progress == nil {
|
||||
return nil
|
||||
}
|
||||
return func(eventType, message string, data interface{}) {
|
||||
switch eventType {
|
||||
case "response_start", "response_delta", "response", "done":
|
||||
return
|
||||
default:
|
||||
enrichWorkflowAgentEventData(data, state, node)
|
||||
collectAgentMetrics(state, data)
|
||||
if eventType == "iteration" {
|
||||
applyWorkflowMainIterationOffset(data, state)
|
||||
}
|
||||
progress(eventType, message, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enrichWorkflowAgentEventData(data interface{}, state *WorkflowLocalState, node graphNode) {
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok || m == nil {
|
||||
return
|
||||
}
|
||||
if node.ID != "" {
|
||||
m["workflowNodeId"] = node.ID
|
||||
}
|
||||
if state != nil && strings.TrimSpace(state.WorkflowRunID) != "" {
|
||||
m["workflowRunId"] = state.WorkflowRunID
|
||||
}
|
||||
}
|
||||
|
||||
func applyWorkflowMainIterationOffset(data interface{}, state *WorkflowLocalState) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok || m == nil {
|
||||
return
|
||||
}
|
||||
scope, _ := m["einoScope"].(string)
|
||||
if strings.TrimSpace(scope) != "main" {
|
||||
return
|
||||
}
|
||||
raw := iterationNumberFromProgressData(m)
|
||||
if raw <= 0 {
|
||||
return
|
||||
}
|
||||
if raw > state.SegmentMaxIteration {
|
||||
state.SegmentMaxIteration = raw
|
||||
}
|
||||
m["iteration"] = raw + state.MainIterationOffset
|
||||
}
|
||||
|
||||
func iterationNumberFromProgressData(m map[string]interface{}) int {
|
||||
switch v := m["iteration"].(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case float32:
|
||||
return int(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func runHITLNode(args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
prompt := resolveHITLPromptBinding(node.Config, state)
|
||||
reviewer := cfgString(node.Config, "reviewer")
|
||||
if reviewer == "" {
|
||||
reviewer = "human"
|
||||
}
|
||||
approved := true
|
||||
if state != nil && state.Inputs != nil {
|
||||
if v, ok := state.Inputs["_hitl_approved"]; ok {
|
||||
approved = fmt.Sprint(v) == "true"
|
||||
}
|
||||
}
|
||||
if !approved {
|
||||
reason := "人工审批已拒绝"
|
||||
if state != nil && state.Inputs != nil {
|
||||
if v, ok := state.Inputs["_hitl_comment"]; ok {
|
||||
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" {
|
||||
reason = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return hitlOutputMap(node, "failed", "", prompt, reviewer, false), false, "failed", reason
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_checkpoint", "人工确认节点已通过", map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"prompt": prompt,
|
||||
"reviewer": reviewer,
|
||||
"mode": "interactive",
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
return hitlOutputMap(node, "completed", prompt, prompt, reviewer, true), true, "completed", ""
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ShouldAutoRunRoleWorkflow returns true when a role explicitly binds a workflow
|
||||
// and does not turn it off. Empty policy defaults to auto to keep role UX simple.
|
||||
func ShouldAutoRunRoleWorkflow(role config.RoleConfig) bool {
|
||||
if strings.TrimSpace(role.WorkflowID) == "" {
|
||||
return false
|
||||
}
|
||||
policy := strings.ToLower(strings.TrimSpace(role.WorkflowPolicy))
|
||||
return policy == "" || policy == "auto"
|
||||
}
|
||||
|
||||
// RunRoleBoundWorkflow executes the persisted role-bound workflow via cached Eino Workflow.
|
||||
func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) {
|
||||
if args.DB == nil {
|
||||
return nil, fmt.Errorf("workflow db is nil")
|
||||
}
|
||||
workflowID := strings.TrimSpace(args.Role.WorkflowID)
|
||||
if workflowID == "" {
|
||||
return nil, fmt.Errorf("角色未绑定工作流")
|
||||
}
|
||||
wf, err := args.DB.GetWorkflowDefinition(workflowID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wf == nil {
|
||||
return nil, fmt.Errorf("角色绑定的工作流不存在: %s", workflowID)
|
||||
}
|
||||
if !wf.Enabled {
|
||||
return nil, fmt.Errorf("角色绑定的工作流已禁用: %s", workflowID)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
runID := uuid.NewString()
|
||||
input := map[string]interface{}{
|
||||
"message": args.UserMessage,
|
||||
"conversationId": args.ConversationID,
|
||||
"projectId": args.ProjectID,
|
||||
"role": args.Role.Name,
|
||||
"workflowId": wf.ID,
|
||||
"workflowVersion": wf.Version,
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
run := &database.WorkflowRun{
|
||||
ID: runID,
|
||||
WorkflowID: wf.ID,
|
||||
WorkflowVersion: wf.Version,
|
||||
ConversationID: args.ConversationID,
|
||||
ProjectID: args.ProjectID,
|
||||
RoleID: args.Role.Name,
|
||||
Status: "running",
|
||||
InputJSON: string(inputJSON),
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
if err := args.DB.CreateWorkflowRun(run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_start", fmt.Sprintf("开始运行流程「%s」", wf.Name), map[string]interface{}{
|
||||
"workflowId": wf.ID,
|
||||
"workflowName": wf.Name,
|
||||
"workflowVersion": wf.Version,
|
||||
"workflowRunId": runID,
|
||||
"conversationId": args.ConversationID,
|
||||
"engine": "eino_workflow",
|
||||
})
|
||||
}
|
||||
|
||||
graph, err := parseGraph(wf.GraphJSON)
|
||||
if err != nil {
|
||||
_ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
state := newWorkflowLocalState(input, runID)
|
||||
streaming := args.Progress != nil
|
||||
resuming := false
|
||||
for {
|
||||
_, err := invokeEinoGraph(ctx, args, runID, wf.ID, wf.Version, graph, state, resuming)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !IsAwaitingHITL(err) {
|
||||
_ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
hitl := err.(*AwaitingHITLError)
|
||||
partial := map[string]interface{}{
|
||||
"workflowId": wf.ID,
|
||||
"workflowName": wf.Name,
|
||||
"workflowVersion": wf.Version,
|
||||
"workflowRunId": runID,
|
||||
"status": "awaiting_hitl",
|
||||
"outputs": state.Outputs,
|
||||
"executedNodes": state.Executed,
|
||||
"skippedNodes": state.Skipped,
|
||||
"pendingHitl": map[string]interface{}{
|
||||
"nodeId": hitl.NodeID,
|
||||
"label": hitl.NodeLabel,
|
||||
"prompt": hitl.Prompt,
|
||||
},
|
||||
"engine": "eino_workflow",
|
||||
}
|
||||
partialJSON, _ := json.Marshal(partial)
|
||||
_ = args.DB.SetWorkflowRunAwaitingHITL(runID, hitl.NodeID, string(partialJSON))
|
||||
response := fmt.Sprintf("工作流「%s」已在节点「%s」暂停,等待人工审批。\n运行 ID:%s", wf.Name, firstNonEmpty(hitl.NodeLabel, hitl.NodeID), runID)
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_paused", response, map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"status": "awaiting_hitl",
|
||||
"nodeId": hitl.NodeID,
|
||||
"resumeApi": fmt.Sprintf("/api/workflows/runs/%s/resume", runID),
|
||||
})
|
||||
}
|
||||
if !streaming {
|
||||
return &RunResult{
|
||||
Response: response,
|
||||
RunID: runID,
|
||||
Status: "awaiting_hitl",
|
||||
AwaitingHITL: true,
|
||||
}, nil
|
||||
}
|
||||
ch := registerHITLWaiter(runID)
|
||||
decision, waitErr := waitWorkflowHITLDecisionWithChannel(ctx, args.DB, runID, ch)
|
||||
unregisterHITLWaiter(runID, ch)
|
||||
if waitErr != nil {
|
||||
_ = args.DB.FinishWorkflowRun(runID, "cancelled", "", waitErr.Error())
|
||||
return nil, waitErr
|
||||
}
|
||||
if !decision.Approved {
|
||||
errText := strings.TrimSpace(decision.Comment)
|
||||
if errText == "" {
|
||||
errText = "人工审批拒绝"
|
||||
}
|
||||
_ = args.DB.FinishWorkflowRun(runID, "rejected", "", errText)
|
||||
rejectResponse := fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", firstNonEmpty(hitl.NodeLabel, hitl.NodeID))
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_rejected", rejectResponse, map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": hitl.NodeID,
|
||||
"comment": errText,
|
||||
})
|
||||
}
|
||||
return &RunResult{
|
||||
Response: rejectResponse,
|
||||
RunID: runID,
|
||||
Status: "rejected",
|
||||
}, nil
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_resumed", "人工审批已通过,继续执行", map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": hitl.NodeID,
|
||||
"comment": decision.Comment,
|
||||
})
|
||||
}
|
||||
if state.Inputs == nil {
|
||||
state.Inputs = map[string]any{}
|
||||
}
|
||||
state.Inputs["_hitl_approved"] = true
|
||||
state.Inputs["_hitl_comment"] = decision.Comment
|
||||
state.Inputs["_hitl_node_id"] = hitl.NodeID
|
||||
_ = args.DB.SetWorkflowRunStatus(runID, "running")
|
||||
resuming = true
|
||||
}
|
||||
|
||||
output := map[string]interface{}{
|
||||
"workflowId": wf.ID,
|
||||
"workflowName": wf.Name,
|
||||
"workflowVersion": wf.Version,
|
||||
"workflowRunId": runID,
|
||||
"status": "completed",
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"executedNodes": state.Executed,
|
||||
"skippedNodes": state.Skipped,
|
||||
"engine": "eino_workflow",
|
||||
}
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
|
||||
response := renderWorkflowResponse(args.Role.Name, wf.Name, wf.Version, runID, state)
|
||||
if err := args.DB.FinishWorkflowRun(runID, "completed", string(outputJSON), ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_done", fmt.Sprintf("流程「%s」运行完成", wf.Name), map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"workflowId": wf.ID,
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"response": response,
|
||||
"engine": "eino_workflow",
|
||||
})
|
||||
}
|
||||
if args.Logger != nil {
|
||||
args.Logger.Info("role-bound workflow completed",
|
||||
zap.String("workflow_id", wf.ID),
|
||||
zap.String("workflow_run_id", runID),
|
||||
zap.String("conversation_id", args.ConversationID),
|
||||
zap.String("role", args.Role.Name),
|
||||
zap.String("engine", "eino_workflow"),
|
||||
)
|
||||
}
|
||||
return &RunResult{Response: response, RunID: runID, Status: "completed"}, nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func init() {
|
||||
schema.RegisterName[*WorkflowLocalState]("_cyberstrike_workflow_local_state")
|
||||
}
|
||||
|
||||
// WorkflowLocalState is the Eino WithGenLocalState payload (checkpoint-serializable).
|
||||
type WorkflowLocalState struct {
|
||||
Inputs map[string]any `json:"inputs,omitempty"`
|
||||
Outputs map[string]any `json:"outputs,omitempty"`
|
||||
NodeOutputs map[string]map[string]any `json:"nodeOutputs,omitempty"`
|
||||
NodeProceed map[string]bool `json:"nodeProceed,omitempty"`
|
||||
LastOutput map[string]any `json:"lastOutput,omitempty"`
|
||||
Metrics map[string]any `json:"metrics,omitempty"`
|
||||
Executed []string `json:"executed,omitempty"`
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
WorkflowRunID string `json:"workflowRunId,omitempty"`
|
||||
MainIterationOffset int `json:"mainIterationOffset,omitempty"`
|
||||
SegmentMaxIteration int `json:"segmentMaxIteration,omitempty"`
|
||||
}
|
||||
|
||||
func newWorkflowLocalState(inputs map[string]interface{}, runID string) *WorkflowLocalState {
|
||||
in := make(map[string]any, len(inputs))
|
||||
for k, v := range inputs {
|
||||
in[k] = v
|
||||
}
|
||||
return &WorkflowLocalState{
|
||||
Inputs: in,
|
||||
Outputs: make(map[string]any),
|
||||
NodeOutputs: make(map[string]map[string]any),
|
||||
NodeProceed: make(map[string]bool),
|
||||
Metrics: make(map[string]any),
|
||||
WorkflowRunID: runID,
|
||||
}
|
||||
}
|
||||
|
||||
var templateVarRe = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}`)
|
||||
|
||||
func resolveTemplate(s string, state *WorkflowLocalState) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fmt.Sprint(valueFromPath("previous.output", state))
|
||||
}
|
||||
return templateVarRe.ReplaceAllStringFunc(s, func(match string) string {
|
||||
m := templateVarRe.FindStringSubmatch(match)
|
||||
if len(m) != 2 {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprint(valueFromPath(m[1], state))
|
||||
})
|
||||
}
|
||||
|
||||
func valueFromPath(path string, state *WorkflowLocalState) any {
|
||||
parts := strings.Split(path, ".")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
var cur any
|
||||
switch parts[0] {
|
||||
case "inputs", "input":
|
||||
cur = state.Inputs
|
||||
case "previous", "prev":
|
||||
cur = state.LastOutput
|
||||
case "outputs":
|
||||
cur = state.Outputs
|
||||
default:
|
||||
if v, ok := state.Inputs[parts[0]]; ok {
|
||||
cur = v
|
||||
} else if v, ok := state.NodeOutputs[parts[0]]; ok {
|
||||
cur = v
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
for _, p := range parts[1:] {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur = m[p]
|
||||
}
|
||||
if cur == nil {
|
||||
return ""
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func cleanComparable(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.Trim(s, `"'`)
|
||||
return s
|
||||
}
|
||||
|
||||
func edgeAllowed(edge graphEdge, sourceNode graphNode, edgeIndex int, state *WorkflowLocalState) bool {
|
||||
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
|
||||
if cond != "" {
|
||||
return evalCondition(cond, state)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(sourceNode.Type), "condition") {
|
||||
return conditionBranchAllowed(edge, edgeIndex, state)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func conditionBranchAllowed(edge graphEdge, edgeIndex int, state *WorkflowLocalState) bool {
|
||||
matched := conditionMatched(state)
|
||||
if branch := conditionBranchHint(edge); branch != "" {
|
||||
return (branch == "true" && matched) || (branch == "false" && !matched)
|
||||
}
|
||||
switch edgeIndex {
|
||||
case 0:
|
||||
return matched
|
||||
case 1:
|
||||
return !matched
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func conditionMatched(state *WorkflowLocalState) bool {
|
||||
v := strings.ToLower(cleanComparable(fmt.Sprint(valueFromPath("previous.matched", state))))
|
||||
return v == "true" || v == "1"
|
||||
}
|
||||
|
||||
func conditionBranchHint(edge graphEdge) string {
|
||||
if edge.Config != nil {
|
||||
switch strings.ToLower(strings.TrimSpace(cfgString(edge.Config, "branch"))) {
|
||||
case "true", "yes", "y", "是":
|
||||
return "true"
|
||||
case "false", "no", "n", "否":
|
||||
return "false"
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(edge.Label)) {
|
||||
case "true", "yes", "y", "是":
|
||||
return "true"
|
||||
case "false", "no", "n", "否":
|
||||
return "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cfgString(cfg map[string]any, key string) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := cfg[key]; ok {
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if s := strings.TrimSpace(value); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateWorkflowPreview(s string, limit int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if limit <= 0 || len([]rune(s)) <= limit {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func renderWorkflowResponse(roleName, workflowName string, version int, runID string, state *WorkflowLocalState) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("角色「%s」已完成工作流「%s」(版本 %d)。\n\n", roleName, workflowName, version))
|
||||
sb.WriteString(fmt.Sprintf("运行 ID:%s\n", runID))
|
||||
sb.WriteString(fmt.Sprintf("已执行节点:%d", len(state.Executed)))
|
||||
if len(state.Skipped) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(",跳过节点:%d", len(state.Skipped)))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
if len(state.Outputs) > 0 {
|
||||
sb.WriteString("输出:\n")
|
||||
keys := make([]string, 0, len(state.Outputs))
|
||||
for k := range state.Outputs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
sb.WriteString(fmt.Sprintf("- %s:%v\n", k, state.Outputs[k]))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("暂无输出。请检查是否配置了输出节点,或条件分支是否命中。\n")
|
||||
}
|
||||
if len(state.Skipped) > 0 {
|
||||
sb.WriteString("\n未执行的节点类型仍会保留运行记录:")
|
||||
sb.WriteString(strings.Join(state.Skipped, "、"))
|
||||
sb.WriteString("。")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package workflow
|
||||
|
||||
type NodeOutputEnvelope struct {
|
||||
Kind string `json:"kind"`
|
||||
NodeID string `json:"node_id"`
|
||||
NodeType string `json:"node_type"`
|
||||
Status string `json:"status"`
|
||||
Output any `json:"output"`
|
||||
}
|
||||
|
||||
type StartOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Message any `json:"message"`
|
||||
ConversationID any `json:"conversationId"`
|
||||
ProjectID any `json:"projectId"`
|
||||
}
|
||||
|
||||
type ConditionOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Condition string `json:"condition"`
|
||||
Matched bool `json:"matched"`
|
||||
}
|
||||
|
||||
type ToolOutput struct {
|
||||
NodeOutputEnvelope
|
||||
ToolName string `json:"tool_name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
type AgentOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Mode string `json:"mode"`
|
||||
MCPExecutionIDs []string `json:"mcp_execution_ids"`
|
||||
}
|
||||
|
||||
type HITLOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Prompt string `json:"prompt"`
|
||||
Reviewer string `json:"reviewer"`
|
||||
Approved bool `json:"approved"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type OutputNodeOutput struct {
|
||||
NodeOutputEnvelope
|
||||
OutputKey string `json:"output_key"`
|
||||
Outputs map[string]any `json:"outputs"`
|
||||
}
|
||||
|
||||
func envelope(kind, nodeID, nodeType, status string, output any) NodeOutputEnvelope {
|
||||
return NodeOutputEnvelope{Kind: kind, NodeID: nodeID, NodeType: nodeType, Status: status, Output: output}
|
||||
}
|
||||
|
||||
func outputMap(env NodeOutputEnvelope, extra map[string]any) map[string]any {
|
||||
out := map[string]any{
|
||||
"kind": env.Kind,
|
||||
"node_id": env.NodeID,
|
||||
"node_type": env.NodeType,
|
||||
"status": env.Status,
|
||||
"output": env.Output,
|
||||
"typed": env,
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func startOutputMap(node graphNode, message, conversationID, projectID any) map[string]any {
|
||||
typed := StartOutput{
|
||||
NodeOutputEnvelope: envelope("start", node.ID, node.Type, "completed", message),
|
||||
Message: message,
|
||||
ConversationID: conversationID,
|
||||
ProjectID: projectID,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"message": typed.Message,
|
||||
"conversationId": typed.ConversationID,
|
||||
"projectId": typed.ProjectID,
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
|
||||
func conditionOutputMap(node graphNode, expr string, matched bool) map[string]any {
|
||||
typed := ConditionOutput{
|
||||
NodeOutputEnvelope: envelope("condition", node.ID, node.Type, "completed", matched),
|
||||
Condition: expr,
|
||||
Matched: matched,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"condition": expr, "matched": matched, "typed": typed})
|
||||
}
|
||||
|
||||
func outputNodeOutputMap(node graphNode, key string, value any) map[string]any {
|
||||
typed := OutputNodeOutput{
|
||||
NodeOutputEnvelope: envelope("output", node.ID, node.Type, "completed", value),
|
||||
OutputKey: key,
|
||||
Outputs: map[string]any{key: value},
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"output_key": key, "outputs": typed.Outputs, "typed": typed})
|
||||
}
|
||||
|
||||
func endOutputMap(node graphNode, value any) map[string]any {
|
||||
typed := envelope("end", node.ID, node.Type, "completed", value)
|
||||
return outputMap(typed, nil)
|
||||
}
|
||||
|
||||
func toolOutputMap(node graphNode, output string, toolName string, args map[string]any, executionID string, isError bool) map[string]any {
|
||||
typed := ToolOutput{
|
||||
NodeOutputEnvelope: envelope("tool", node.ID, node.Type, "completed", output),
|
||||
ToolName: toolName,
|
||||
Arguments: args,
|
||||
ExecutionID: executionID,
|
||||
IsError: isError,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"tool_name": toolName,
|
||||
"arguments": args,
|
||||
"execution_id": executionID,
|
||||
"is_error": isError,
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
|
||||
func agentOutputMap(node graphNode, response, mode string, mcpIDs []string) map[string]any {
|
||||
typed := AgentOutput{
|
||||
NodeOutputEnvelope: envelope("agent", node.ID, node.Type, "completed", response),
|
||||
Mode: mode,
|
||||
MCPExecutionIDs: mcpIDs,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"mode": mode, "mcp_execution_ids": mcpIDs, "typed": typed})
|
||||
}
|
||||
|
||||
func hitlOutputMap(node graphNode, status string, output string, prompt string, reviewer string, approved bool) map[string]any {
|
||||
typed := HITLOutput{
|
||||
NodeOutputEnvelope: envelope("hitl", node.ID, node.Type, status, output),
|
||||
Prompt: prompt,
|
||||
Reviewer: reviewer,
|
||||
Approved: approved,
|
||||
Mode: "interactive",
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"prompt": prompt,
|
||||
"reviewer": reviewer,
|
||||
"approved": approved,
|
||||
"mode": "interactive",
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// WorkflowInput is the typed entry for Eino compose.Workflow[I,O].
|
||||
type WorkflowInput struct {
|
||||
Message string `json:"message"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Role string `json:"role"`
|
||||
WorkflowID string `json:"workflowId"`
|
||||
WorkflowVersion int `json:"workflowVersion"`
|
||||
}
|
||||
|
||||
// WorkflowOutput aggregates terminal node payloads keyed by canvas node id.
|
||||
type WorkflowOutput map[string]any
|
||||
|
||||
// WorkflowNodeOutput is the per-node lambda payload (alias for Eino edge type alignment).
|
||||
type WorkflowNodeOutput = map[string]interface{}
|
||||
|
||||
func workflowInputFromMap(m map[string]interface{}) WorkflowInput {
|
||||
in := WorkflowInput{}
|
||||
if m == nil {
|
||||
return in
|
||||
}
|
||||
if v, ok := m["message"].(string); ok {
|
||||
in.Message = v
|
||||
} else if m["message"] != nil {
|
||||
in.Message = fmt.Sprint(m["message"])
|
||||
}
|
||||
if v, ok := m["conversationId"].(string); ok {
|
||||
in.ConversationID = v
|
||||
}
|
||||
if v, ok := m["projectId"].(string); ok {
|
||||
in.ProjectID = v
|
||||
}
|
||||
if v, ok := m["role"].(string); ok {
|
||||
in.Role = v
|
||||
}
|
||||
if v, ok := m["workflowId"].(string); ok {
|
||||
in.WorkflowID = v
|
||||
}
|
||||
switch v := m["workflowVersion"].(type) {
|
||||
case int:
|
||||
in.WorkflowVersion = v
|
||||
case int64:
|
||||
in.WorkflowVersion = int(v)
|
||||
case float64:
|
||||
in.WorkflowVersion = int(v)
|
||||
case string:
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
in.WorkflowVersion = n
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func (in WorkflowInput) toStateInputs() map[string]any {
|
||||
return map[string]any{
|
||||
"message": in.Message,
|
||||
"conversationId": in.ConversationID,
|
||||
"projectId": in.ProjectID,
|
||||
"role": in.Role,
|
||||
"workflowId": in.WorkflowID,
|
||||
"workflowVersion": in.WorkflowVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func cacheKey(workflowID string, version int) string {
|
||||
return workflowID + ":" + strconv.Itoa(version)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var allowedWorkflowNodeTypes = map[string]bool{
|
||||
"start": true,
|
||||
"tool": true,
|
||||
"agent": true,
|
||||
"condition": true,
|
||||
"hitl": true,
|
||||
"output": true,
|
||||
"end": true,
|
||||
}
|
||||
|
||||
func validateGraphDefinition(g *graphDef, idx *graphIndex) error {
|
||||
if g == nil || idx == nil {
|
||||
return fmt.Errorf("工作流图为空")
|
||||
}
|
||||
if err := validateNodeIDsAndTypes(g); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEdges(g, idx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNodeTopology(idx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNodeConfigs(idx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDAG(idx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateReachability(idx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNodeIDsAndTypes(g *graphDef) error {
|
||||
seen := make(map[string]bool, len(g.Nodes))
|
||||
for _, node := range g.Nodes {
|
||||
id := strings.TrimSpace(node.ID)
|
||||
if id == "" {
|
||||
return fmt.Errorf("工作流存在空节点 ID")
|
||||
}
|
||||
if seen[id] {
|
||||
return fmt.Errorf("工作流存在重复节点 ID: %s", id)
|
||||
}
|
||||
seen[id] = true
|
||||
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
|
||||
if nodeType == "" {
|
||||
return fmt.Errorf("节点「%s」缺少节点类型", id)
|
||||
}
|
||||
if !allowedWorkflowNodeTypes[nodeType] {
|
||||
return fmt.Errorf("节点「%s」使用了未知节点类型: %s", id, node.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEdges(g *graphDef, idx *graphIndex) error {
|
||||
seen := make(map[string]bool, len(g.Edges))
|
||||
for _, edge := range g.Edges {
|
||||
if id := strings.TrimSpace(edge.ID); id != "" {
|
||||
if seen[id] {
|
||||
return fmt.Errorf("工作流存在重复连线 ID: %s", id)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
source := strings.TrimSpace(edge.Source)
|
||||
target := strings.TrimSpace(edge.Target)
|
||||
if source == "" || target == "" {
|
||||
return fmt.Errorf("工作流存在源或目标为空的连线")
|
||||
}
|
||||
if source == target {
|
||||
return fmt.Errorf("连线「%s」不能自环", firstNonEmpty(edge.ID, source))
|
||||
}
|
||||
if _, ok := idx.nodes[source]; !ok {
|
||||
return fmt.Errorf("连线「%s」引用了不存在的源节点: %s", firstNonEmpty(edge.ID, source), source)
|
||||
}
|
||||
if _, ok := idx.nodes[target]; !ok {
|
||||
return fmt.Errorf("连线「%s」引用了不存在的目标节点: %s", firstNonEmpty(edge.ID, target), target)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNodeTopology(idx *graphIndex) error {
|
||||
starts := explicitStartNodeIDs(idx)
|
||||
if len(starts) == 0 {
|
||||
return fmt.Errorf("工作流至少需要一个开始节点")
|
||||
}
|
||||
outputs := outputNodeIDs(idx)
|
||||
if len(outputs) == 0 {
|
||||
return fmt.Errorf("工作流至少需要一个输出节点")
|
||||
}
|
||||
for id, node := range idx.nodes {
|
||||
inDegree := len(idx.incoming[id])
|
||||
outDegree := len(idx.outgoing[id])
|
||||
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
|
||||
switch nodeType {
|
||||
case "start":
|
||||
if inDegree > 0 {
|
||||
return fmt.Errorf("开始节点「%s」不能有入边", firstNonEmpty(node.Label, id))
|
||||
}
|
||||
if outDegree == 0 {
|
||||
return fmt.Errorf("开始节点「%s」至少需要一条出边", firstNonEmpty(node.Label, id))
|
||||
}
|
||||
case "output", "end":
|
||||
if outDegree > 0 {
|
||||
return fmt.Errorf("%s 节点「%s」不能有出边", displayNodeType(nodeType), firstNonEmpty(node.Label, id))
|
||||
}
|
||||
if inDegree == 0 {
|
||||
return fmt.Errorf("%s 节点「%s」至少需要一条入边", displayNodeType(nodeType), firstNonEmpty(node.Label, id))
|
||||
}
|
||||
default:
|
||||
if inDegree == 0 {
|
||||
return fmt.Errorf("节点「%s」不可达:非开始节点必须有入边", firstNonEmpty(node.Label, id))
|
||||
}
|
||||
if outDegree == 0 {
|
||||
return fmt.Errorf("节点「%s」没有出边;请连接到 output/end 节点", firstNonEmpty(node.Label, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNodeConfigs(idx *graphIndex) error {
|
||||
for id, node := range idx.nodes {
|
||||
label := firstNonEmpty(node.Label, id)
|
||||
switch strings.ToLower(strings.TrimSpace(node.Type)) {
|
||||
case "tool":
|
||||
if cfgString(node.Config, "tool_name") == "" {
|
||||
return fmt.Errorf("工具节点「%s」必须选择 MCP 工具", label)
|
||||
}
|
||||
if err := validateToolConfig(node); err != nil {
|
||||
return err
|
||||
}
|
||||
case "agent":
|
||||
if cfgString(node.Config, "instruction") == "" {
|
||||
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
|
||||
return fmt.Errorf("Agent 节点「%s」必须填写节点指令或输入绑定", label)
|
||||
}
|
||||
}
|
||||
if cfgString(node.Config, "output_key") == "" {
|
||||
return fmt.Errorf("Agent 节点「%s」必须填写输出变量名", label)
|
||||
}
|
||||
case "condition":
|
||||
if cfgString(node.Config, "expression") == "" {
|
||||
return fmt.Errorf("条件节点「%s」必须填写表达式", label)
|
||||
}
|
||||
if err := validateConditionExpression(cfgString(node.Config, "expression")); err != nil {
|
||||
return fmt.Errorf("条件节点「%s」表达式非法: %w", label, err)
|
||||
}
|
||||
if n := len(idx.outgoing[id]); n < 1 || n > 2 {
|
||||
return fmt.Errorf("条件节点「%s」需要 1 到 2 条出边(是/否)", label)
|
||||
}
|
||||
if err := validateConditionBranchLabels(idx, id, node); err != nil {
|
||||
return err
|
||||
}
|
||||
case "output":
|
||||
if cfgString(node.Config, "output_key") == "" {
|
||||
return fmt.Errorf("输出节点「%s」必须填写输出变量名", label)
|
||||
}
|
||||
}
|
||||
if err := validateJoinConfig(idx, id, node); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasConditionalOutgoingEdges(idx, id) {
|
||||
if err := validateConditionalOutgoingEdges(idx, id, node); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConditionalOutgoingEdges(idx *graphIndex, nodeID string, node graphNode) error {
|
||||
unconditional := 0
|
||||
for _, edge := range idx.outgoing[nodeID] {
|
||||
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
|
||||
if cond != "" {
|
||||
if err := validateConditionExpression(cond); err != nil {
|
||||
return fmt.Errorf("节点「%s」的连线条件非法: %w", firstNonEmpty(node.Label, nodeID), err)
|
||||
}
|
||||
}
|
||||
if cond == "" {
|
||||
unconditional++
|
||||
}
|
||||
}
|
||||
if unconditional > 1 {
|
||||
return fmt.Errorf("节点「%s」的条件出边最多只能有一条默认分支", firstNonEmpty(node.Label, nodeID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateToolConfig(node graphNode) error {
|
||||
rawArgs := cfgString(node.Config, "arguments")
|
||||
if rawArgs != "" {
|
||||
if _, err := resolveToolArguments(node.Config, &WorkflowLocalState{}); err != nil {
|
||||
return fmt.Errorf("工具节点「%s」参数 JSON 非法: %w", firstNonEmpty(node.Label, node.ID), err)
|
||||
}
|
||||
}
|
||||
if timeout := cfgString(node.Config, "timeout_seconds"); timeout != "" {
|
||||
if _, err := parsePositiveInt(timeout); err != nil {
|
||||
return fmt.Errorf("工具节点「%s」超时时间必须是正整数", firstNonEmpty(node.Label, node.ID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateJoinConfig(idx *graphIndex, nodeID string, node graphNode) error {
|
||||
strategy := joinStrategy(node)
|
||||
if !allowedJoinStrategies[strategy] {
|
||||
return fmt.Errorf("节点「%s」使用了未知汇聚策略: %s", firstNonEmpty(node.Label, nodeID), strategy)
|
||||
}
|
||||
if len(idx.incoming[nodeID]) > 1 && strategy == "" {
|
||||
return fmt.Errorf("节点「%s」有多个上游时必须声明汇聚策略", firstNonEmpty(node.Label, nodeID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConditionBranchLabels(idx *graphIndex, nodeID string, node graphNode) error {
|
||||
seen := map[string]bool{}
|
||||
for _, edge := range idx.outgoing[nodeID] {
|
||||
hint := conditionBranchHint(edge)
|
||||
if hint == "" {
|
||||
return fmt.Errorf("条件节点「%s」的出边必须标记为是/否或 true/false", firstNonEmpty(node.Label, nodeID))
|
||||
}
|
||||
if seen[hint] {
|
||||
return fmt.Errorf("条件节点「%s」存在重复分支标签: %s", firstNonEmpty(node.Label, nodeID), hint)
|
||||
}
|
||||
seen[hint] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDAG(idx *graphIndex) error {
|
||||
color := make(map[string]int, len(idx.nodes))
|
||||
var visit func(string) error
|
||||
visit = func(id string) error {
|
||||
switch color[id] {
|
||||
case 1:
|
||||
return fmt.Errorf("工作流存在环路,Workflow 编排必须是 DAG: %s", id)
|
||||
case 2:
|
||||
return nil
|
||||
}
|
||||
color[id] = 1
|
||||
for _, edge := range idx.outgoing[id] {
|
||||
if err := visit(edge.Target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
color[id] = 2
|
||||
return nil
|
||||
}
|
||||
for id := range idx.nodes {
|
||||
if err := visit(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateReachability(idx *graphIndex) error {
|
||||
starts := explicitStartNodeIDs(idx)
|
||||
reached := make(map[string]bool, len(idx.nodes))
|
||||
queue := append([]string(nil), starts...)
|
||||
for len(queue) > 0 {
|
||||
id := queue[0]
|
||||
queue = queue[1:]
|
||||
if reached[id] {
|
||||
continue
|
||||
}
|
||||
reached[id] = true
|
||||
for _, edge := range idx.outgoing[id] {
|
||||
queue = append(queue, edge.Target)
|
||||
}
|
||||
}
|
||||
for id, node := range idx.nodes {
|
||||
if !reached[id] {
|
||||
return fmt.Errorf("节点「%s」不可达:没有从开始节点连通到该节点", firstNonEmpty(node.Label, id))
|
||||
}
|
||||
}
|
||||
|
||||
canReachTerminal := make(map[string]bool, len(idx.nodes))
|
||||
visiting := make(map[string]bool, len(idx.nodes))
|
||||
var reachesTerminal func(string) bool
|
||||
reachesTerminal = func(id string) bool {
|
||||
if canReachTerminal[id] {
|
||||
return true
|
||||
}
|
||||
if visiting[id] {
|
||||
return false
|
||||
}
|
||||
visiting[id] = true
|
||||
node := idx.nodes[id]
|
||||
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
|
||||
if nodeType == "output" || nodeType == "end" {
|
||||
canReachTerminal[id] = true
|
||||
visiting[id] = false
|
||||
return true
|
||||
}
|
||||
for _, edge := range idx.outgoing[id] {
|
||||
if reachesTerminal(edge.Target) {
|
||||
canReachTerminal[id] = true
|
||||
visiting[id] = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
visiting[id] = false
|
||||
return false
|
||||
}
|
||||
for id, node := range idx.nodes {
|
||||
if !reachesTerminal(id) {
|
||||
return fmt.Errorf("节点「%s」无法到达 output/end 终点", firstNonEmpty(node.Label, id))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func explicitStartNodeIDs(idx *graphIndex) []string {
|
||||
var ids []string
|
||||
for id, node := range idx.nodes {
|
||||
if strings.EqualFold(node.Type, "start") {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sortNodeIDsByCanvas(ids, idx.nodes)
|
||||
return ids
|
||||
}
|
||||
|
||||
func outputNodeIDs(idx *graphIndex) []string {
|
||||
var ids []string
|
||||
for id, node := range idx.nodes {
|
||||
if strings.EqualFold(node.Type, "output") {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sortNodeIDsByCanvas(ids, idx.nodes)
|
||||
return ids
|
||||
}
|
||||
|
||||
func displayNodeType(nodeType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(nodeType)) {
|
||||
case "output":
|
||||
return "输出"
|
||||
case "end":
|
||||
return "结束"
|
||||
default:
|
||||
return nodeType
|
||||
}
|
||||
}
|
||||
|
||||
func parsePositiveInt(s string) (int, error) {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("not positive integer")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
+107
-4
@@ -33,6 +33,93 @@
|
||||
--c2-mono: 'SF Mono', 'Fira Code', 'JetBrains Mono', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--c2-accent: #60a5fa;
|
||||
--c2-accent-hover: #93c5fd;
|
||||
--c2-accent-dim: rgba(96, 165, 250, 0.14);
|
||||
--c2-accent-glow: rgba(96, 165, 250, 0.28);
|
||||
--c2-green: #34d399;
|
||||
--c2-green-dim: rgba(52, 211, 153, 0.14);
|
||||
--c2-red: #f87171;
|
||||
--c2-red-dim: rgba(248, 113, 113, 0.14);
|
||||
--c2-amber: #fbbf24;
|
||||
--c2-amber-dim: rgba(251, 191, 36, 0.14);
|
||||
--c2-purple: #a78bfa;
|
||||
--c2-purple-dim: rgba(167, 139, 250, 0.14);
|
||||
--c2-surface: #111827;
|
||||
--c2-surface-alt: #0b1120;
|
||||
--c2-border: #263244;
|
||||
--c2-border-hover: #3b4a63;
|
||||
--c2-text: #e5e7eb;
|
||||
--c2-text-dim: #a7b0c0;
|
||||
--c2-text-muted: #6b7280;
|
||||
--c2-shadow-sm: 0 1px 3px rgba(0,0,0,0.34);
|
||||
--c2-shadow-md: 0 8px 24px rgba(0,0,0,0.38);
|
||||
--c2-shadow-lg: 0 18px 48px rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .c2-modal,
|
||||
html[data-theme="dark"] .c2-modal-content,
|
||||
html[data-theme="dark"] .c2-tab-panel--card,
|
||||
html[data-theme="dark"] .c2-session-detail,
|
||||
html[data-theme="dark"] .c2-payload-card,
|
||||
html[data-theme="dark"] .c2-profile-card,
|
||||
html[data-theme="dark"] .c2-event-card,
|
||||
html[data-theme="dark"] .c2-task-row,
|
||||
html[data-theme="dark"] .c2-listener-card {
|
||||
background: var(--c2-surface);
|
||||
color: var(--c2-text);
|
||||
border-color: var(--c2-border);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .c2-listener-card:hover,
|
||||
html[data-theme="dark"] .c2-payload-card:hover,
|
||||
html[data-theme="dark"] .c2-profile-card:hover {
|
||||
border-color: var(--c2-border-hover);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .c2-session-chip,
|
||||
html[data-theme="dark"] .c2-listener-pill,
|
||||
html[data-theme="dark"] .c2-task-type-badge,
|
||||
html[data-theme="dark"] .c2-tab-btn {
|
||||
background: var(--c2-surface-alt);
|
||||
border-color: var(--c2-border);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] #page-c2-listeners,
|
||||
html[data-theme="dark"] #page-c2-sessions,
|
||||
html[data-theme="dark"] #page-c2-tasks,
|
||||
html[data-theme="dark"] #page-c2-payloads,
|
||||
html[data-theme="dark"] #page-c2-events,
|
||||
html[data-theme="dark"] #page-c2-profiles,
|
||||
html[data-theme="dark"] #page-c2-listeners .page-content,
|
||||
html[data-theme="dark"] #page-c2-sessions .page-content,
|
||||
html[data-theme="dark"] #page-c2-tasks .page-content,
|
||||
html[data-theme="dark"] #page-c2-payloads .page-content,
|
||||
html[data-theme="dark"] #page-c2-events .page-content,
|
||||
html[data-theme="dark"] #page-c2-profiles .page-content,
|
||||
html[data-theme="dark"] .c2-session-layout,
|
||||
html[data-theme="dark"] .c2-session-main {
|
||||
background: var(--c2-surface-alt) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .c2-session-sidebar-wrap,
|
||||
html[data-theme="dark"] .c2-sessions-toolbar,
|
||||
html[data-theme="dark"] .c2-session-sidebar {
|
||||
background: #0b1120 !important;
|
||||
border-color: var(--c2-border) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .c2-session-main-empty {
|
||||
background: transparent !important;
|
||||
color: var(--c2-text-dim) !important;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .c2-session-main-empty__icon {
|
||||
background: var(--c2-accent-dim) !important;
|
||||
border-color: rgba(96, 165, 250, 0.35) !important;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Form Controls (scoped to C2 pages)
|
||||
============================================================================ */
|
||||
@@ -533,7 +620,7 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 12px 16px 16px;
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #ffffff 180px);
|
||||
background: linear-gradient(180deg, var(--c2-surface-alt) 0%, var(--c2-surface) 180px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -2324,13 +2411,21 @@
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--c2-shadow-lg);
|
||||
border: 1px solid var(--c2-border);
|
||||
animation: c2-slide-up 0.18s ease-out;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.c2-modal > #c2-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-height: inherit;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@keyframes c2-slide-up {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
@@ -2340,6 +2435,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 24px 28px 20px;
|
||||
border-bottom: 1px solid var(--c2-border);
|
||||
}
|
||||
@@ -2352,15 +2448,22 @@
|
||||
|
||||
/* .c2-modal-close 样式见 style.css 统一关闭按钮 */
|
||||
|
||||
.c2-modal-body { padding: 24px 28px; }
|
||||
.c2-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.c2-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
padding: 16px 28px;
|
||||
border-top: 1px solid var(--c2-border);
|
||||
background: var(--c2-surface-alt);
|
||||
background: var(--c2-surface);
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
|
||||
+4546
-25
File diff suppressed because it is too large
Load Diff
+262
-22
@@ -39,6 +39,15 @@
|
||||
"version": "Current version",
|
||||
"toggleSidebar": "Collapse/expand sidebar"
|
||||
},
|
||||
"theme": {
|
||||
"system": "System",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"titleSystem": "Current: system theme. Click to switch to light.",
|
||||
"titleLight": "Current: light theme. Click to switch to dark.",
|
||||
"titleDark": "Current: dark theme. Click to switch to system.",
|
||||
"toggle": "Toggle theme"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"empty": "No new events",
|
||||
@@ -76,6 +85,7 @@
|
||||
"agentsManagement": "Agent management",
|
||||
"roles": "Roles",
|
||||
"rolesManagement": "Roles Management",
|
||||
"workflows": "Graph Orchestration",
|
||||
"settings": "System settings",
|
||||
"hitl": "Human-in-the-loop",
|
||||
"c2": "C2",
|
||||
@@ -504,6 +514,12 @@
|
||||
"filterByProject": "Filter by project",
|
||||
"filterAllProjects": "All projects",
|
||||
"filterUnboundProjects": "Unbound",
|
||||
"filterProjectSearch": "Search projects…",
|
||||
"filterProjectSearchEmpty": "No matching projects",
|
||||
"filterProjectSearchHint": "Type to search projects",
|
||||
"filterProjectSearchMore": "Type to find more projects",
|
||||
"filterProjectSearchLoading": "Searching…",
|
||||
"filterProjectSearchFailed": "Failed to load projects. Try again.",
|
||||
"projectConversationsTitle": "{{name}} · Conversations",
|
||||
"unboundConversationsTitle": "Unbound conversations",
|
||||
"noProjectConversations": "No conversations in this project",
|
||||
@@ -581,6 +597,7 @@
|
||||
"knowledgeRetrievalTag": "Knowledge retrieval",
|
||||
"error": "Error",
|
||||
"streamNetworkErrorHint": "Connection lost ({{detail}}). A long task may still be running on the server; check running tasks at the top or refresh this conversation later.",
|
||||
"streamEndedWithoutDone": "The connection ended before a completion signal was received. The task may still be running on the server; check running tasks at the top or refresh this conversation.",
|
||||
"taskCancelled": "Task cancelled",
|
||||
"userInterruptContinueTitle": "⏸️ User interrupt & continue",
|
||||
"unknownTool": "Unknown tool",
|
||||
@@ -619,15 +636,15 @@
|
||||
"agentModeEinoSingle": "Eino single (ADK)",
|
||||
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner with MCP tools (/api/eino-agent)",
|
||||
"agentModeDeep": "Deep (DeepAgent)",
|
||||
"agentModeDeepHint": "Eino DeepAgent with task delegation to sub-agents",
|
||||
"agentModeDeepHint": "Eino DeepAgent for complex security testing, multi-stage task delegation, and synthesis",
|
||||
"agentModePlanExecuteLabel": "Plan-Execute",
|
||||
"agentModePlanExecuteHint": "Plan → execute → replan (single executor with tools)",
|
||||
"agentModeSupervisorLabel": "Supervisor",
|
||||
"agentModeSupervisorHint": "Supervisor coordinates via transfer to sub-agents",
|
||||
"agentModeSupervisorLabel": "Supervisor (Expert routing)",
|
||||
"agentModeSupervisorHint": "Expert-routing scenarios: route across multiple specialist sub-agents via transfer",
|
||||
"agentModeSingle": "Single-agent",
|
||||
"agentModeMulti": "Multi-agent",
|
||||
"agentModeSingleHint": "Eino ADK single-agent for chat and tool use",
|
||||
"agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks",
|
||||
"agentModeMultiHint": "Eino prebuilt orchestration: Deep for complex tasks, Plan-Execute for structured loops, Supervisor for expert routing",
|
||||
"reasoningModeLabel": "Model reasoning",
|
||||
"reasoningEffortLabel": "Reasoning effort",
|
||||
"reasoningModeDefault": "Use system default",
|
||||
@@ -668,7 +685,7 @@
|
||||
"hitl": {
|
||||
"pageTitle": "HITL approvals",
|
||||
"pageReviewerLabel": "Current reviewer",
|
||||
"pageReviewerHint": "Applies to the selected conversation. Without a conversation, saved locally for new chats. Takes effect immediately.",
|
||||
"pageReviewerHint": "Applies to the selected conversation. Without a conversation, saved to config.yaml as the global default for new chats. Takes effect immediately.",
|
||||
"pageReviewerSaved": "Reviewer saved.",
|
||||
"whitelistLabel": "Tool whitelist (no approval)",
|
||||
"whitelistHint": "One per line or comma-separated. Saved to config.yaml global whitelist and takes effect immediately (synced with chat sidebar).",
|
||||
@@ -1227,6 +1244,17 @@
|
||||
"robots": {
|
||||
"title": "Bot settings",
|
||||
"description": "Configure WeChat (iLink), WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
|
||||
"managerTitle": "Bot management",
|
||||
"managerDesc": "Choose a bot type first, then configure only the fields that belong to that platform.",
|
||||
"newBot": "New bot",
|
||||
"configure": "Configure",
|
||||
"statusNotConfigured": "Not configured",
|
||||
"statusConfigured": "Configured",
|
||||
"statusEnabled": "Enabled",
|
||||
"emptyTitle": "Select a bot to configure",
|
||||
"emptyDesc": "Pick a platform above, or use New bot to choose by type.",
|
||||
"createTitle": "New bot",
|
||||
"createDesc": "Choose a bot type to open its dedicated setup. This version supports one built-in configuration per platform.",
|
||||
"wechat": {
|
||||
"title": "WeChat / iLink",
|
||||
"subtitle": "Bind personal WeChat via QR code and chat with CyberStrikeAI on your phone",
|
||||
@@ -1255,6 +1283,7 @@
|
||||
},
|
||||
"wecom": {
|
||||
"title": "WeCom",
|
||||
"subtitle": "HTTP callback mode for enterprise internal app bots",
|
||||
"enabled": "Enable WeCom bot",
|
||||
"token": "Token",
|
||||
"tokenPlaceholder": "Token",
|
||||
@@ -1269,6 +1298,7 @@
|
||||
},
|
||||
"dingtalk": {
|
||||
"title": "DingTalk",
|
||||
"subtitle": "Enterprise internal app bot using Stream connection",
|
||||
"enabled": "Enable DingTalk bot",
|
||||
"clientIdLabel": "Client ID (AppKey)",
|
||||
"clientIdPlaceholder": "DingTalk App Key",
|
||||
@@ -1278,6 +1308,7 @@
|
||||
},
|
||||
"lark": {
|
||||
"title": "Lark",
|
||||
"subtitle": "Enterprise app bot for direct chat and group mentions",
|
||||
"enabled": "Enable Lark bot",
|
||||
"appIdLabel": "App ID",
|
||||
"appIdPlaceholder": "Lark/Feishu App ID",
|
||||
@@ -1285,6 +1316,40 @@
|
||||
"appSecretPlaceholder": "Lark/Feishu App Secret",
|
||||
"verifyTokenLabel": "Verify Token (Optional)",
|
||||
"verifyTokenPlaceholder": "Event subscription Verification Token"
|
||||
},
|
||||
"telegram": {
|
||||
"title": "Telegram",
|
||||
"subtitle": "Bot API long polling; direct chat and group @",
|
||||
"enabled": "Enable Telegram bot",
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "Receives via getUpdates long poll; no public callback URL needed",
|
||||
"botUsername": "Bot Username (optional)",
|
||||
"allowGroup": "Allow group chat (respond to @ only)"
|
||||
},
|
||||
"slack": {
|
||||
"title": "Slack",
|
||||
"subtitle": "Socket Mode; no public callback URL needed",
|
||||
"enabled": "Enable Slack bot",
|
||||
"botToken": "Bot Token (xoxb-)",
|
||||
"appToken": "App-Level Token (xapp-)",
|
||||
"appTokenHint": "Requires connections:write scope for Socket Mode"
|
||||
},
|
||||
"discord": {
|
||||
"title": "Discord",
|
||||
"subtitle": "Gateway WebSocket; DMs and server @",
|
||||
"enabled": "Enable Discord bot",
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "Create bot in Developer Portal; enable Message Content Intent",
|
||||
"allowGuild": "Allow guild channels (respond to @ only)"
|
||||
},
|
||||
"qq": {
|
||||
"title": "QQ Bot",
|
||||
"subtitle": "QQ Open Platform WebSocket; C2C and group @",
|
||||
"enabled": "Enable QQ bot",
|
||||
"appId": "App ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretHint": "From QQ Bot Open Platform; use sandbox before going live",
|
||||
"sandbox": "Sandbox environment"
|
||||
}
|
||||
},
|
||||
"apply": {
|
||||
@@ -1900,7 +1965,7 @@
|
||||
},
|
||||
"chatFilesPage": {
|
||||
"title": "File Management",
|
||||
"intro": "Files uploaded in chat appear here. Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.",
|
||||
"intro": "Files uploaded in chat appear here. Drag files into the list below, or click Upload to pick files (multiple allowed). Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.",
|
||||
"upload": "Upload",
|
||||
"conversationFilter": "Conversation ID",
|
||||
"conversationPlaceholder": "Leave empty for all",
|
||||
@@ -2012,7 +2077,7 @@
|
||||
"exportNoResults": "No vulnerabilities match the current filters",
|
||||
"exportStarted": "Started downloading {{count}} file(s)",
|
||||
"exportFailed": "Export failed",
|
||||
"saveRequiredFields": "Please fill in conversation ID, title, and severity",
|
||||
"saveRequiredFields": "Please fill in conversation ID, title, description, severity, type, target, reproduction steps, evidence/POC, impact, and remediation",
|
||||
"saveFailed": "Save failed",
|
||||
"fetchFailed": "Failed to fetch vulnerability",
|
||||
"deleteFailed": "Delete failed",
|
||||
@@ -2031,9 +2096,12 @@
|
||||
"detailTaskQueueId": "Task queue ID",
|
||||
"detailConversationTag": "Conversation tag",
|
||||
"detailTaskTag": "Task tag",
|
||||
"detailProof": "Proof",
|
||||
"detailPreconditions": "Preconditions",
|
||||
"detailReproductionSteps": "Reproduction steps",
|
||||
"detailEvidence": "Evidence / POC",
|
||||
"detailImpact": "Impact",
|
||||
"detailRecommendation": "Remediation",
|
||||
"detailRetestNotes": "Retest method",
|
||||
"downloadOkTitle": "Downloaded",
|
||||
"exportFailedMessage": "Export failed",
|
||||
"downloadFailed": "Download failed"
|
||||
@@ -2141,10 +2209,10 @@
|
||||
"enableMultiAgent": "Enable Eino multi-agent",
|
||||
"enableMultiAgentHint": "After enabling, the chat page can use multi-agent mode; sub-agents are set in multi_agent.sub_agents or the agents/ directory. Orchestration is configured below.",
|
||||
"multiAgentOrchestration": "Multi-agent orchestration",
|
||||
"multiAgentOrchestrationHint": "deep = DeepAgent + task; plan_execute = plan / execute / replan (single executor tool loop); supervisor = supervisor + transfer. Takes effect after save & apply.",
|
||||
"multiAgentOrchestrationHint": "deep = DeepAgent + task; plan_execute = plan / execute / replan; supervisor = expert routing (transfer, specific scenarios). Takes effect after save & apply.",
|
||||
"multiAgentOrchDeep": "deep — DeepAgent (task sub-agents)",
|
||||
"multiAgentOrchPlanExecute": "plan_execute — plan / execute / replan",
|
||||
"multiAgentOrchSupervisor": "supervisor — supervisor + transfer",
|
||||
"multiAgentOrchSupervisor": "supervisor — expert routing (transfer)",
|
||||
"multiAgentPeLoop": "plan_execute outer loop limit",
|
||||
"multiAgentPeLoopPlaceholder": "0 uses Eino default (10)",
|
||||
"multiAgentPeLoopHint": "Only for plan_execute; max execute↔replan rounds.",
|
||||
@@ -2172,11 +2240,27 @@
|
||||
"subIndexFilter": "Sub-index filter (optional)",
|
||||
"subIndexFilterPlaceholder": "e.g. prod, must match an indexing sub_indexes tag",
|
||||
"subIndexFilterHint": "Empty = no filter. When set, only rows whose sub_indexes contain this tag (legacy rows with empty sub_indexes still match).",
|
||||
"ragPipelineHeader": "RAG pipeline (MultiQuery + Rerank)",
|
||||
"ragPipelineHint": "MultiQuery and rerank are always on: LLM query rewrite → vector prefetch & fusion → HTTP rerank → dedupe & budget truncate.",
|
||||
"multiQueryMaxQueries": "MultiQuery rewrite variant limit",
|
||||
"multiQueryMaxQueriesPlaceholder": "4",
|
||||
"multiQueryMaxQueriesHint": "Max LLM-generated retrieval variants (including paraphrases of the original query). Recommended 3–4, max 8.",
|
||||
"rerankProvider": "Rerank provider",
|
||||
"rerankProviderAuto": "Auto (infer from Base URL)",
|
||||
"rerankProviderCohere": "Cohere-compatible API",
|
||||
"rerankProviderHint": "DashScope uses gte-rerank; other compatible endpoints use /v1/rerank. Leave empty to infer from Base URL below.",
|
||||
"rerankModel": "Rerank model (optional)",
|
||||
"rerankModelPlaceholder": "Empty: DashScope→gte-rerank, Cohere→rerank-multilingual-v3.0",
|
||||
"rerankBaseUrl": "Rerank Base URL (optional)",
|
||||
"rerankBaseUrlPlaceholder": "Leave empty to reuse embedding / OpenAI base_url",
|
||||
"rerankApiKey": "Rerank API Key (optional)",
|
||||
"rerankApiKeyPlaceholder": "Leave empty to reuse embedding / OpenAI api_key",
|
||||
"rerankApiKeyHint": "On rerank failure, results fall back to fusion order; search still works.",
|
||||
"postRetrieveHeader": "Post-retrieval (dedupe / budget)",
|
||||
"postRetrieveDedupeAuto": "Results are always deduped by normalized text (whitespace-collapsed bodies). No setting required.",
|
||||
"prefetchTopK": "Prefetch candidates (vector stage)",
|
||||
"prefetchTopKPlaceholder": "0",
|
||||
"prefetchTopKHint": "0 = same as Top-K; larger values fetch more vector hits before dedupe/truncate (max 200).",
|
||||
"prefetchTopKPlaceholder": "20",
|
||||
"prefetchTopKHint": "Vector candidates per MultiQuery variant; 0 uses built-in max(top_k×4, 20) (max 200).",
|
||||
"maxContextChars": "Max returned characters (Unicode)",
|
||||
"maxContextCharsPlaceholder": "0",
|
||||
"maxContextCharsHint": "0 = unlimited; keeps whole chunks in rank order until the budget is exceeded.",
|
||||
@@ -2481,6 +2565,8 @@
|
||||
},
|
||||
"settingsRobotsExtra": {
|
||||
"botCommandsTitle": "Bot command instructions",
|
||||
"botCommandsEntryDesc": "View the Chinese and English commands available in robot chats, including common conversation, role, and project actions.",
|
||||
"viewAllCommands": "View all commands",
|
||||
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
|
||||
"botCmdCategoryGeneral": "General",
|
||||
"botCmdCategoryConversation": "Conversation",
|
||||
@@ -2627,6 +2713,7 @@
|
||||
"conversationName": "Conversation name",
|
||||
"project": "Project",
|
||||
"noProject": "No project",
|
||||
"unknownProject": "Unknown project",
|
||||
"filterByProject": "Filter by project",
|
||||
"lastTime": "Last activity",
|
||||
"action": "Action",
|
||||
@@ -2768,9 +2855,9 @@
|
||||
"taskTag": "Task tag",
|
||||
"taskTagPlaceholder": "e.g. batch scan Q2, retest",
|
||||
"title": "Title",
|
||||
"titlePlaceholder": "Vulnerability title",
|
||||
"titlePlaceholder": "/api/login is vulnerable to SQL injection",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Detailed description",
|
||||
"descriptionPlaceholder": "Describe the summary, trigger point, observed abnormal behavior, and why it is exploitable.",
|
||||
"severity": "Severity",
|
||||
"pleaseSelect": "Please select",
|
||||
"severityCritical": "Critical",
|
||||
@@ -2787,13 +2874,19 @@
|
||||
"type": "Vulnerability type",
|
||||
"typePlaceholder": "e.g. SQL injection, XSS, CSRF",
|
||||
"target": "Target",
|
||||
"targetPlaceholder": "Affected target (URL, IP, etc.)",
|
||||
"proof": "Proof (POC)",
|
||||
"proofPlaceholder": "Proof: request/response, screenshots, etc.",
|
||||
"targetPlaceholder": "Be specific: URL, IP:port, endpoint path, and parameter name.",
|
||||
"preconditions": "Preconditions",
|
||||
"preconditionsPlaceholder": "Login state, permissions, account, headers/cookies, required data, environment/version; write none if not needed.",
|
||||
"reproductionSteps": "Reproduction steps",
|
||||
"reproductionStepsPlaceholder": "Number the steps and include entry point, parameter, payload, command, and observation point.",
|
||||
"evidence": "Evidence / POC",
|
||||
"evidencePlaceholder": "Raw request/response, curl/tool command, screenshot notes, logs, DNSLog/callback records, database results, file paths, timestamps, etc.",
|
||||
"impact": "Impact",
|
||||
"impactPlaceholder": "Impact description",
|
||||
"impactPlaceholder": "Describe the verified real-world impact, such as which data can be read or changed.",
|
||||
"recommendation": "Recommendation",
|
||||
"recommendationPlaceholder": "Remediation"
|
||||
"recommendationPlaceholder": "Write the concrete fix and retest criteria.",
|
||||
"retestNotes": "Retest method",
|
||||
"retestNotesPlaceholder": "How to verify the fix, including expected status code, error message, or access-control result."
|
||||
},
|
||||
"vulnerabilityMd": {
|
||||
"headingBasic": "Basic information",
|
||||
@@ -2810,9 +2903,12 @@
|
||||
"labelCreated": "Created at",
|
||||
"labelUpdated": "Updated at",
|
||||
"headingDescription": "Description",
|
||||
"headingProof": "Proof (POC)",
|
||||
"headingPreconditions": "Preconditions",
|
||||
"headingReproductionSteps": "Reproduction steps",
|
||||
"headingEvidence": "Evidence / POC",
|
||||
"headingImpact": "Impact",
|
||||
"headingRecommendation": "Remediation"
|
||||
"headingRecommendation": "Remediation",
|
||||
"headingRetestNotes": "Retest method"
|
||||
},
|
||||
"roleModal": {
|
||||
"addRole": "Add role",
|
||||
@@ -2871,7 +2967,151 @@
|
||||
"mcpDisabledBadgeTitle": "Off in MCP Management; check only expresses role linkage—turn on in MCP to run",
|
||||
"roleFilterOnBanner": "These tools are checked and linked to this role (independent of MCP-wide enable).",
|
||||
"roleFilterOffBanner": "These tools are unchecked and not linked to this role.",
|
||||
"checkboxLinkTitle": "Check to link this tool to this role"
|
||||
"checkboxLinkTitle": "Check to link this tool to this role",
|
||||
"bindWorkflow": "Bind graph workflow",
|
||||
"bindWorkflowHint": "When a workflow is selected, conversations with this role automatically run the bound graph; workflow fields are configured freely in the graph JSON.",
|
||||
"workflowPolicy": "Workflow trigger policy",
|
||||
"workflowPolicyAuto": "Auto trigger",
|
||||
"workflowPolicyOff": "Off",
|
||||
"noWorkflowBind": "No workflow",
|
||||
"workflowDisabledSuffix": " (disabled)"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "Graph Orchestration",
|
||||
"newGraph": "New graph",
|
||||
"processLibrary": "Process library",
|
||||
"nodeLibrary": "Node library",
|
||||
"emptyList": "No graph workflows yet",
|
||||
"statusEnabled": "Enabled",
|
||||
"statusDisabled": "Disabled",
|
||||
"metaId": "ID",
|
||||
"metaName": "Name",
|
||||
"metaDescription": "Description",
|
||||
"metaEnabled": "Enabled",
|
||||
"metaIdHint": "Cannot be changed after creation; used for API and role binding",
|
||||
"metaModalTitle": "Workflow details",
|
||||
"editMeta": "Edit",
|
||||
"untitled": "Untitled workflow",
|
||||
"toggleEnabled": "Enable/disable",
|
||||
"metaEnabledHint": "Disabled workflows cannot be auto-triggered by roles",
|
||||
"enabledUpdated": "Enabled status updated",
|
||||
"enabledUpdateFailed": "Failed to update enabled status",
|
||||
"namePlaceholder": "Basic Web scan",
|
||||
"descriptionPlaceholder": "Optional",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting",
|
||||
"deleteSelected": "Delete selected",
|
||||
"autoLayout": "Auto layout",
|
||||
"dryRun": "Dry run",
|
||||
"canvasEmpty": "Drag nodes from the left onto the canvas, or click node buttons to add quickly",
|
||||
"properties": "Properties",
|
||||
"nodeProperties": "Node properties",
|
||||
"edgeProperties": "Edge properties",
|
||||
"deleteNode": "Delete node",
|
||||
"deleteEdge": "Delete edge",
|
||||
"propertyEmpty": "Select a node or edge to edit properties",
|
||||
"propLabel": "Name",
|
||||
"propType": "Type",
|
||||
"customFields": "Custom fields",
|
||||
"addField": "Add field",
|
||||
"noCustomFields": "No custom fields",
|
||||
"nodes": {
|
||||
"start": "Start",
|
||||
"tool": "Tool",
|
||||
"agent": "Agent",
|
||||
"condition": "Condition",
|
||||
"hitl": "Approval",
|
||||
"output": "Output",
|
||||
"end": "End",
|
||||
"default": "Node"
|
||||
},
|
||||
"edges": {
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"config": {
|
||||
"inputKeys": "Input variables",
|
||||
"mcpTool": "MCP tool",
|
||||
"selectTool": "Select a tool",
|
||||
"toolDisabled": " (disabled)",
|
||||
"argumentsTemplate": "Arguments template",
|
||||
"argumentsStatic": "Tool arguments (JSON)",
|
||||
"timeoutSeconds": "Timeout (seconds)",
|
||||
"optional": "Optional",
|
||||
"agentMode": "Agent mode",
|
||||
"inputSource": "Input source",
|
||||
"inputBinding": "Input field binding",
|
||||
"inputBindingHint": "from = data source, field = field name (e.g. output, message)",
|
||||
"nodeInstruction": "Node instruction",
|
||||
"instructionPlaceholder": "Describe what this node should accomplish",
|
||||
"outputKey": "Output variable name",
|
||||
"conditionExpression": "Condition expression",
|
||||
"conditionHint": "The node computes matched (true/false); outgoing edges define branches: first edge is \"Yes\", second is \"No\". You can also write <code>{{previous.matched}} == \"true\"</code> on the edge.",
|
||||
"conditionGuideTitle": "Available syntax",
|
||||
"conditionGuideVars": "Variables: {{previous.output}}, {{previous.matched}}, {{inputs.message}}, {{outputs.name}}, {{nodeId.output}}",
|
||||
"conditionGuideOps": "Operators: ==, !=, >, >=, <, <=, contains, matches, &&, ||",
|
||||
"conditionGuideJson": "Nested fields: jsonpath(value, \"$.status\") or jq(value, \".severity\")",
|
||||
"edgeCondition": "Edge condition",
|
||||
"edgeBranch": "Condition branch",
|
||||
"selectBranch": "Select",
|
||||
"edgeConditionHintCondition": "{{previous.matched}} == \"true\" (Yes) or == \"false\" (No)",
|
||||
"edgeConditionHintExample": "e.g. {{previous.output}} == \"ok\"",
|
||||
"edgeBranchHint": "The first edge from a condition node defaults to the \"Yes\" branch, the second to \"No\"; you can customize conditions here.",
|
||||
"joinStrategy": "Join strategy",
|
||||
"joinStrategyHint": "When multiple upstream nodes enter this node, choose how to build previous: merge all, last by canvas order, first non-empty, or fail fast.",
|
||||
"hitlPrompt": "Approval prompt",
|
||||
"hitlPromptPlaceholder": "Approve to continue",
|
||||
"hitlReviewer": "Reviewer",
|
||||
"hitlInteractiveHint": "The run pauses at this node; approve or reject via API or the monitor panel to continue.",
|
||||
"promptBinding": "Prompt field binding",
|
||||
"promptBindingHint": "When prompt text is empty, read approval text from the bound field",
|
||||
"outputSource": "Variable source",
|
||||
"sourceBinding": "Output field binding",
|
||||
"sourceBindingHint": "Write the bound field value to the output variable; static value below overrides when set",
|
||||
"staticValue": "Static output value",
|
||||
"resultBinding": "End summary binding",
|
||||
"resultBindingHint": "Field shown in the end node summary",
|
||||
"endTemplate": "End summary template"
|
||||
},
|
||||
"defaultHitlPrompt": "Please approve whether this step should continue",
|
||||
"nodeFallback": "Node {{n}}",
|
||||
"loadFailed": "Failed to load workflows",
|
||||
"saveFailed": "Failed to save workflow",
|
||||
"dryRunPrompt": "Input message for dry-run",
|
||||
"dryRunFailed": "Dry-run failed",
|
||||
"dryRunDone": "Dry-run completed",
|
||||
"dryRunTrace": "Dry-run trace",
|
||||
"dryRunNoTrace": "No trace",
|
||||
"deleteFailed": "Failed to delete workflow",
|
||||
"saved": "Workflow saved",
|
||||
"deleted": "Workflow deleted",
|
||||
"idNameRequired": "Workflow ID and name are required",
|
||||
"selectToDelete": "Select a workflow to delete",
|
||||
"confirmDelete": "Delete workflow {{id}}?",
|
||||
"duplicateEdge": "An edge already exists between these two nodes",
|
||||
"connectModeOn": "Connect mode: click source node then target node",
|
||||
"connectModeOff": "Exited connect mode",
|
||||
"validation": {
|
||||
"needStart": "At least one Start node is required",
|
||||
"needOutput": "At least one Output node is required",
|
||||
"edgeSelfLoop": "Edge {{id}} cannot point to itself",
|
||||
"edgeSourceMissing": "Edge {{id}} source node does not exist",
|
||||
"edgeTargetMissing": "Edge {{id}} target node does not exist",
|
||||
"startIncoming": "Start node {{label}} must not have incoming edges",
|
||||
"outputOutgoing": "Output node {{label}} must not have outgoing edges",
|
||||
"toolNeedsMcp": "Tool node {{label}} requires an MCP tool",
|
||||
"conditionNeedsExpr": "Condition node {{label}} requires a condition expression",
|
||||
"conditionNeedsOutEdge": "Condition node {{label}} needs at least one outgoing edge (Yes/No branch)",
|
||||
"conditionTooManyEdges": "Condition node {{label}} should have at most two outgoing edges (Yes/No); configure edge conditions for a third and beyond",
|
||||
"conditionBranchLabel": "Condition node {{label}} outgoing edges must be marked Yes/No",
|
||||
"conditionBranchDuplicate": "Condition node {{label}} has duplicate branches",
|
||||
"nodeNeedsIncoming": "Node {{label}} needs an incoming edge",
|
||||
"nodeNeedsOutgoing": "Node {{label}} needs an outgoing edge",
|
||||
"nodeUnreachable": "Node {{label}} cannot be reached from Start",
|
||||
"graphCycle": "Workflow contains a cycle; keep it as a DAG",
|
||||
"serverFailed": "Server graph validation failed",
|
||||
"outputNeedsKey": "Output node {{label}} requires an output variable name"
|
||||
}
|
||||
},
|
||||
"c2": {
|
||||
"clipboardCopied": "Copied to clipboard",
|
||||
|
||||
+262
-22
@@ -39,6 +39,15 @@
|
||||
"version": "当前版本",
|
||||
"toggleSidebar": "折叠/展开侧边栏"
|
||||
},
|
||||
"theme": {
|
||||
"system": "跟随系统",
|
||||
"light": "浅色",
|
||||
"dark": "暗色",
|
||||
"titleSystem": "当前:跟随系统主题。点击切换为浅色。",
|
||||
"titleLight": "当前:浅色主题。点击切换为暗色。",
|
||||
"titleDark": "当前:暗色主题。点击切换为跟随系统。",
|
||||
"toggle": "切换主题"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "事件通知",
|
||||
"empty": "暂无新事件",
|
||||
@@ -76,6 +85,7 @@
|
||||
"agentsManagement": "Agent管理",
|
||||
"roles": "角色",
|
||||
"rolesManagement": "角色管理",
|
||||
"workflows": "图编排",
|
||||
"settings": "系统设置",
|
||||
"hitl": "人机协同",
|
||||
"c2": "C2",
|
||||
@@ -492,6 +502,12 @@
|
||||
"filterByProject": "按项目筛选",
|
||||
"filterAllProjects": "全部项目",
|
||||
"filterUnboundProjects": "未绑定项目",
|
||||
"filterProjectSearch": "搜索项目…",
|
||||
"filterProjectSearchEmpty": "没有匹配的项目",
|
||||
"filterProjectSearchHint": "输入关键字搜索项目",
|
||||
"filterProjectSearchMore": "更多项目请输入关键字搜索",
|
||||
"filterProjectSearchLoading": "搜索中…",
|
||||
"filterProjectSearchFailed": "加载项目失败,请重试",
|
||||
"projectConversationsTitle": "{{name}} · 对话",
|
||||
"unboundConversationsTitle": "未绑定项目",
|
||||
"noProjectConversations": "该项目暂无对话",
|
||||
@@ -569,6 +585,7 @@
|
||||
"knowledgeRetrievalTag": "知识检索",
|
||||
"error": "错误",
|
||||
"streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。",
|
||||
"streamEndedWithoutDone": "连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部「运行中」任务或刷新当前对话。",
|
||||
"taskCancelled": "任务已取消",
|
||||
"userInterruptContinueTitle": "⏸️ 用户中断并继续",
|
||||
"unknownTool": "未知工具",
|
||||
@@ -607,15 +624,15 @@
|
||||
"agentModeEinoSingle": "Eino 单代理(ADK)",
|
||||
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)",
|
||||
"agentModeDeep": "Deep(DeepAgent)",
|
||||
"agentModeDeepHint": "Eino DeepAgent,task 调度子代理",
|
||||
"agentModeDeepHint": "Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总",
|
||||
"agentModePlanExecuteLabel": "Plan-Execute",
|
||||
"agentModePlanExecuteHint": "规划 → 执行 → 重规划(单执行器带工具)",
|
||||
"agentModeSupervisorLabel": "Supervisor",
|
||||
"agentModeSupervisorHint": "监督者协调,transfer 委派子代理",
|
||||
"agentModeSupervisorLabel": "Supervisor(专家路由)",
|
||||
"agentModeSupervisorHint": "专家路由场景:监督者通过 transfer 动态分派多个专业子代理",
|
||||
"agentModeSingle": "单代理",
|
||||
"agentModeMulti": "多代理",
|
||||
"agentModeSingleHint": "Eino ADK 单代理,适合常规对话与工具调用",
|
||||
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
|
||||
"agentModeMultiHint": "Eino 预置编排:Deep 适合复杂任务,Plan-Execute 适合结构化闭环,Supervisor 适合专家路由",
|
||||
"reasoningModeLabel": "模型推理",
|
||||
"reasoningEffortLabel": "推理强度",
|
||||
"reasoningModeDefault": "跟随系统",
|
||||
@@ -656,7 +673,7 @@
|
||||
"hitl": {
|
||||
"pageTitle": "人机协同审批",
|
||||
"pageReviewerLabel": "当前审批方",
|
||||
"pageReviewerHint": "作用于当前选中会话;未选会话时保存到本机,新建会话时沿用。切换后立即生效。",
|
||||
"pageReviewerHint": "作用于当前选中会话;未选会话时写入 config.yaml 作为全局默认,新建会话时沿用。切换后立即生效。",
|
||||
"pageReviewerSaved": "审批方已保存。",
|
||||
"whitelistLabel": "免审批工具白名单",
|
||||
"whitelistHint": "每行一个或逗号分隔;保存后写入 config.yaml 全局白名单并立即生效(与聊天侧栏同步展示)。",
|
||||
@@ -1215,6 +1232,17 @@
|
||||
"robots": {
|
||||
"title": "机器人设置",
|
||||
"description": "配置微信、企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
|
||||
"managerTitle": "机器人管理",
|
||||
"managerDesc": "先选择机器人类型,再进入对应配置;已配置的平台会显示连接状态。",
|
||||
"newBot": "新建机器人",
|
||||
"configure": "配置",
|
||||
"statusNotConfigured": "未配置",
|
||||
"statusConfigured": "已配置",
|
||||
"statusEnabled": "已启用",
|
||||
"emptyTitle": "选择一个机器人开始配置",
|
||||
"emptyDesc": "从上方列表点击已有平台,或点击“新建机器人”按类型创建。",
|
||||
"createTitle": "新建机器人",
|
||||
"createDesc": "选择机器人类型后进入专属配置。当前版本每个平台支持一个内置机器人配置。",
|
||||
"wechat": {
|
||||
"title": "微信 / iLink",
|
||||
"subtitle": "扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话",
|
||||
@@ -1243,6 +1271,7 @@
|
||||
},
|
||||
"wecom": {
|
||||
"title": "企业微信",
|
||||
"subtitle": "HTTP 回调模式,适合企业内部应用机器人",
|
||||
"enabled": "启用企业微信机器人",
|
||||
"token": "Token",
|
||||
"tokenPlaceholder": "Token",
|
||||
@@ -1257,6 +1286,7 @@
|
||||
},
|
||||
"dingtalk": {
|
||||
"title": "钉钉",
|
||||
"subtitle": "企业内部应用机器人,使用 Stream 长连接",
|
||||
"enabled": "启用钉钉机器人",
|
||||
"clientIdLabel": "Client ID (AppKey)",
|
||||
"clientIdPlaceholder": "钉钉应用 AppKey",
|
||||
@@ -1266,6 +1296,7 @@
|
||||
},
|
||||
"lark": {
|
||||
"title": "飞书 (Lark)",
|
||||
"subtitle": "飞书企业应用机器人,支持单聊与群聊 @",
|
||||
"enabled": "启用飞书机器人",
|
||||
"appIdLabel": "App ID",
|
||||
"appIdPlaceholder": "飞书应用 App ID",
|
||||
@@ -1273,6 +1304,40 @@
|
||||
"appSecretPlaceholder": "飞书应用 App Secret",
|
||||
"verifyTokenLabel": "Verify Token(可选)",
|
||||
"verifyTokenPlaceholder": "事件订阅 Verification Token"
|
||||
},
|
||||
"telegram": {
|
||||
"title": "Telegram",
|
||||
"subtitle": "Bot API 长轮询,私聊与群聊 @",
|
||||
"enabled": "启用 Telegram 机器人",
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "通过 getUpdates 长轮询收消息,无需公网回调",
|
||||
"botUsername": "Bot Username(可选)",
|
||||
"allowGroup": "允许群聊(仅响应 @ 机器人)"
|
||||
},
|
||||
"slack": {
|
||||
"title": "Slack",
|
||||
"subtitle": "Socket Mode,无需公网回调",
|
||||
"enabled": "启用 Slack 机器人",
|
||||
"botToken": "Bot Token (xoxb-)",
|
||||
"appToken": "App-Level Token (xapp-)",
|
||||
"appTokenHint": "需 connections:write 权限,用于 Socket Mode"
|
||||
},
|
||||
"discord": {
|
||||
"title": "Discord",
|
||||
"subtitle": "Gateway WebSocket,私聊与服务器 @",
|
||||
"enabled": "启用 Discord 机器人",
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "开发者门户创建 Bot,开启 Message Content Intent",
|
||||
"allowGuild": "允许服务器频道(仅响应 @ 机器人)"
|
||||
},
|
||||
"qq": {
|
||||
"title": "QQ 机器人",
|
||||
"subtitle": "QQ 开放平台 WebSocket,C2C 与群 @",
|
||||
"enabled": "启用 QQ 机器人",
|
||||
"appId": "App ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretHint": "从 QQ 机器人开放平台获取;上线前可勾选沙箱",
|
||||
"sandbox": "沙箱环境"
|
||||
}
|
||||
},
|
||||
"apply": {
|
||||
@@ -1888,7 +1953,7 @@
|
||||
},
|
||||
"chatFilesPage": {
|
||||
"title": "文件管理",
|
||||
"intro": "管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
|
||||
"intro": "管理在对话中上传的文件。可将文件拖拽到下方列表区域,或点击「上传文件」选择文件(支持多选)。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
|
||||
"upload": "上传文件",
|
||||
"conversationFilter": "会话 ID",
|
||||
"conversationPlaceholder": "留空表示全部",
|
||||
@@ -2000,7 +2065,7 @@
|
||||
"exportNoResults": "当前筛选条件下无可导出漏洞",
|
||||
"exportStarted": "已开始下载 {{count}} 份报告",
|
||||
"exportFailed": "导出失败",
|
||||
"saveRequiredFields": "请填写必填字段:会话ID、标题和严重程度",
|
||||
"saveRequiredFields": "请填写必填字段:会话ID、标题、描述、严重程度、漏洞类型、目标、复现步骤、证据/POC、影响和修复建议",
|
||||
"saveFailed": "保存失败",
|
||||
"fetchFailed": "获取漏洞失败",
|
||||
"deleteFailed": "删除失败",
|
||||
@@ -2019,9 +2084,12 @@
|
||||
"detailTaskQueueId": "任务队列ID",
|
||||
"detailConversationTag": "对话标签",
|
||||
"detailTaskTag": "任务标签",
|
||||
"detailProof": "证明",
|
||||
"detailPreconditions": "前置条件",
|
||||
"detailReproductionSteps": "复现步骤",
|
||||
"detailEvidence": "证据 / POC",
|
||||
"detailImpact": "影响",
|
||||
"detailRecommendation": "修复建议",
|
||||
"detailRetestNotes": "复测方式",
|
||||
"downloadOkTitle": "下载成功",
|
||||
"exportFailedMessage": "导出失败",
|
||||
"downloadFailed": "下载失败"
|
||||
@@ -2129,10 +2197,10 @@
|
||||
"enableMultiAgent": "启用 Eino 多代理",
|
||||
"enableMultiAgentHint": "开启后对话页可选「多代理」模式;子代理在 multi_agent.sub_agents 或 agents 目录配置;编排方式见下方「预置编排」。",
|
||||
"multiAgentOrchestration": "多代理预置编排",
|
||||
"multiAgentOrchestrationHint": "deep=DeepAgent+task;plan_execute=规划/执行/重规划(单执行器工具链);supervisor=监督者+transfer。保存并应用后生效。",
|
||||
"multiAgentOrchestrationHint": "deep=DeepAgent+task;plan_execute=规划/执行/重规划;supervisor=专家路由(transfer,特定场景)。保存并应用后生效。",
|
||||
"multiAgentOrchDeep": "deep — DeepAgent(task 子代理)",
|
||||
"multiAgentOrchPlanExecute": "plan_execute — 规划 / 执行 / 重规划",
|
||||
"multiAgentOrchSupervisor": "supervisor — 监督者 + transfer",
|
||||
"multiAgentOrchSupervisor": "supervisor — 专家路由(transfer)",
|
||||
"multiAgentPeLoop": "plan_execute 外层循环上限",
|
||||
"multiAgentPeLoopPlaceholder": "0 表示 Eino 默认 10",
|
||||
"multiAgentPeLoopHint": "仅 plan_execute 有效;execute 与 replan 之间的最大轮次。",
|
||||
@@ -2160,11 +2228,27 @@
|
||||
"subIndexFilter": "子索引过滤(可选)",
|
||||
"subIndexFilterPlaceholder": "如 prod,与索引 sub_indexes 一致",
|
||||
"subIndexFilterHint": "留空不过滤;填写后仅检索向量行 sub_indexes 中含该标签的结果(未打标旧行仍保留)。",
|
||||
"ragPipelineHeader": "RAG 管线(MultiQuery + Rerank)",
|
||||
"ragPipelineHint": "MultiQuery 与精排始终启用:LLM 改写多路检索 → 向量预取与融合 → HTTP 精排 → 去重与预算截断。",
|
||||
"multiQueryMaxQueries": "MultiQuery 改写变体上限",
|
||||
"multiQueryMaxQueriesPlaceholder": "4",
|
||||
"multiQueryMaxQueriesHint": "LLM 生成的检索变体数量上限(含原问语义覆盖);建议 3~4,最大 8。",
|
||||
"rerankProvider": "精排提供商",
|
||||
"rerankProviderAuto": "自动(按 Base URL 推断)",
|
||||
"rerankProviderCohere": "Cohere 兼容 API",
|
||||
"rerankProviderHint": "DashScope 使用 gte-rerank;其他兼容端点走 /v1/rerank。留空时按下方 Base URL 自动推断。",
|
||||
"rerankModel": "精排模型(可选)",
|
||||
"rerankModelPlaceholder": "留空:DashScope→gte-rerank,Cohere→rerank-multilingual-v3.0",
|
||||
"rerankBaseUrl": "精排 Base URL(可选)",
|
||||
"rerankBaseUrlPlaceholder": "留空则复用嵌入 / OpenAI 的 base_url",
|
||||
"rerankApiKey": "精排 API Key(可选)",
|
||||
"rerankApiKeyPlaceholder": "留空则复用嵌入 / OpenAI 的 api_key",
|
||||
"rerankApiKeyHint": "精排失败时自动降级为融合排序,检索仍可用。",
|
||||
"postRetrieveHeader": "检索后处理(去重 / 预算)",
|
||||
"postRetrieveDedupeAuto": "检索结果会自动按正文规范化去重(合并仅空白不同的重复片段),无需配置。",
|
||||
"prefetchTopK": "预取候选数(向量阶段)",
|
||||
"prefetchTopKPlaceholder": "0",
|
||||
"prefetchTopKHint": "0 表示与 Top-K 相同;大于 Top-K 时先多取候选再经去重/截断回到 Top-K(上限 200)。",
|
||||
"prefetchTopKPlaceholder": "20",
|
||||
"prefetchTopKHint": "每条 MultiQuery 变体的向量候选数;0 表示内置 max(top_k×4, 20)(上限 200)。",
|
||||
"maxContextChars": "返回内容最大字符数(Unicode)",
|
||||
"maxContextCharsPlaceholder": "0",
|
||||
"maxContextCharsHint": "0 表示不限制;按检索顺序整段保留 chunk,超出则丢弃后续。",
|
||||
@@ -2469,6 +2553,8 @@
|
||||
},
|
||||
"settingsRobotsExtra": {
|
||||
"botCommandsTitle": "机器人命令说明",
|
||||
"botCommandsEntryDesc": "查看机器人对话中可用的中英文命令,包括对话、角色、项目等常用操作。",
|
||||
"viewAllCommands": "查看全部命令",
|
||||
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
|
||||
"botCmdCategoryGeneral": "通用",
|
||||
"botCmdCategoryConversation": "对话",
|
||||
@@ -2615,6 +2701,7 @@
|
||||
"conversationName": "对话名称",
|
||||
"project": "项目",
|
||||
"noProject": "无项目",
|
||||
"unknownProject": "未知项目",
|
||||
"filterByProject": "按项目筛选",
|
||||
"lastTime": "最近一次对话时间",
|
||||
"action": "操作",
|
||||
@@ -2756,9 +2843,9 @@
|
||||
"taskTag": "任务标签",
|
||||
"taskTagPlaceholder": "如:批量扫描Q2、专项复测",
|
||||
"title": "标题",
|
||||
"titlePlaceholder": "漏洞标题",
|
||||
"titlePlaceholder": "/api/login 存在 SQL 注入",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "漏洞详细描述",
|
||||
"descriptionPlaceholder": "说明漏洞摘要、触发点、异常现象和为什么可被利用。",
|
||||
"severity": "严重程度",
|
||||
"pleaseSelect": "请选择",
|
||||
"severityCritical": "严重",
|
||||
@@ -2775,13 +2862,19 @@
|
||||
"type": "漏洞类型",
|
||||
"typePlaceholder": "如:SQL注入、XSS、CSRF等",
|
||||
"target": "目标",
|
||||
"targetPlaceholder": "受影响的目标(URL、IP地址等)",
|
||||
"proof": "证明(POC)",
|
||||
"proofPlaceholder": "漏洞证明,如请求/响应、截图等",
|
||||
"targetPlaceholder": "精确到 URL/IP:端口/接口路径/参数名",
|
||||
"preconditions": "前置条件",
|
||||
"preconditionsPlaceholder": "登录状态、权限、账号、Header/Cookie、特定数据、环境/版本;无则写无。",
|
||||
"reproductionSteps": "复现步骤",
|
||||
"reproductionStepsPlaceholder": "按 1/2/3 编号,写清入口、参数、payload、执行命令、观察点。",
|
||||
"evidence": "证据 / POC",
|
||||
"evidencePlaceholder": "原始请求/响应、curl/工具命令、截图说明、日志、DNSLog/回连记录、数据库结果、文件路径、时间戳等。",
|
||||
"impact": "影响",
|
||||
"impactPlaceholder": "漏洞影响说明",
|
||||
"impactPlaceholder": "结合已验证事实说明实际影响,例如越权读取哪些数据。",
|
||||
"recommendation": "修复建议",
|
||||
"recommendationPlaceholder": "修复建议"
|
||||
"recommendationPlaceholder": "写具体修复点和复测标准。",
|
||||
"retestNotes": "复测方式",
|
||||
"retestNotesPlaceholder": "修复后如何验证漏洞已关闭,包括应返回的状态码、错误信息或访问控制结果。"
|
||||
},
|
||||
"vulnerabilityMd": {
|
||||
"headingBasic": "基本信息",
|
||||
@@ -2798,9 +2891,12 @@
|
||||
"labelCreated": "创建时间",
|
||||
"labelUpdated": "更新时间",
|
||||
"headingDescription": "描述",
|
||||
"headingProof": "证明(POC)",
|
||||
"headingPreconditions": "前置条件",
|
||||
"headingReproductionSteps": "复现步骤",
|
||||
"headingEvidence": "证据 / POC",
|
||||
"headingImpact": "影响",
|
||||
"headingRecommendation": "修复建议"
|
||||
"headingRecommendation": "修复建议",
|
||||
"headingRetestNotes": "复测方式"
|
||||
},
|
||||
"roleModal": {
|
||||
"addRole": "添加角色",
|
||||
@@ -2859,7 +2955,151 @@
|
||||
"mcpDisabledBadgeTitle": "MCP 管理里该工具为关闭;勾选只表示想关联到本角色,实际调用需先在 MCP 中打开",
|
||||
"roleFilterOnBanner": "以下为「已勾选、关联到本角色」的工具(与 MCP 管理里全局开/关无关)。",
|
||||
"roleFilterOffBanner": "以下为「未勾选、未关联到本角色」的工具。",
|
||||
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
|
||||
"checkboxLinkTitle": "勾选表示本角色关联使用该工具",
|
||||
"bindWorkflow": "绑定图编排流程",
|
||||
"bindWorkflowHint": "选中流程后,对话页使用该角色会自动触发绑定图;流程字段由图定义 JSON 自由配置。",
|
||||
"workflowPolicy": "流程触发策略",
|
||||
"workflowPolicyAuto": "自动触发",
|
||||
"workflowPolicyOff": "关闭",
|
||||
"noWorkflowBind": "不绑定流程",
|
||||
"workflowDisabledSuffix": "(已禁用)"
|
||||
},
|
||||
"workflows": {
|
||||
"title": "图编排",
|
||||
"newGraph": "新建图",
|
||||
"processLibrary": "流程库",
|
||||
"nodeLibrary": "节点库",
|
||||
"emptyList": "暂无图编排流程",
|
||||
"statusEnabled": "启用",
|
||||
"statusDisabled": "禁用",
|
||||
"metaId": "ID",
|
||||
"metaName": "名称",
|
||||
"metaDescription": "描述",
|
||||
"metaEnabled": "启用",
|
||||
"metaIdHint": "创建后不可修改,用于 API 与角色绑定",
|
||||
"metaModalTitle": "流程信息",
|
||||
"editMeta": "编辑",
|
||||
"untitled": "未命名流程",
|
||||
"toggleEnabled": "启用/禁用",
|
||||
"metaEnabledHint": "禁用后无法被角色自动触发",
|
||||
"enabledUpdated": "启用状态已更新",
|
||||
"enabledUpdateFailed": "更新启用状态失败",
|
||||
"namePlaceholder": "基础 Web 扫描",
|
||||
"descriptionPlaceholder": "可选",
|
||||
"connect": "连线",
|
||||
"connecting": "连线中",
|
||||
"deleteSelected": "删除选中",
|
||||
"autoLayout": "自动布局",
|
||||
"dryRun": "试运行",
|
||||
"canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加",
|
||||
"properties": "属性",
|
||||
"nodeProperties": "节点属性",
|
||||
"edgeProperties": "连线属性",
|
||||
"deleteNode": "删除节点",
|
||||
"deleteEdge": "删除连线",
|
||||
"propertyEmpty": "选择一个节点或连线后编辑属性",
|
||||
"propLabel": "名称",
|
||||
"propType": "类型",
|
||||
"customFields": "自定义字段",
|
||||
"addField": "添加字段",
|
||||
"noCustomFields": "暂无自定义字段",
|
||||
"nodes": {
|
||||
"start": "开始",
|
||||
"tool": "工具",
|
||||
"agent": "Agent",
|
||||
"condition": "条件",
|
||||
"hitl": "审批",
|
||||
"output": "输出",
|
||||
"end": "结束",
|
||||
"default": "节点"
|
||||
},
|
||||
"edges": {
|
||||
"yes": "是",
|
||||
"no": "否"
|
||||
},
|
||||
"config": {
|
||||
"inputKeys": "输入变量",
|
||||
"mcpTool": "MCP 工具",
|
||||
"selectTool": "请选择工具",
|
||||
"toolDisabled": "(未启用)",
|
||||
"argumentsTemplate": "参数模板",
|
||||
"argumentsStatic": "工具参数(JSON)",
|
||||
"timeoutSeconds": "超时秒数",
|
||||
"optional": "可选",
|
||||
"agentMode": "Agent 模式",
|
||||
"inputSource": "输入来源",
|
||||
"inputBinding": "输入字段绑定",
|
||||
"inputBindingHint": "from 选数据来源,field 为字段名(如 output、message)",
|
||||
"nodeInstruction": "节点指令",
|
||||
"instructionPlaceholder": "描述该节点要完成的任务",
|
||||
"outputKey": "输出变量名",
|
||||
"conditionExpression": "条件表达式",
|
||||
"conditionHint": "节点会计算 matched(true/false),由出边决定分支:第一条线为「是」,第二条为「否」;也可在连线上写 <code>{{previous.matched}} == \"true\"</code>。",
|
||||
"conditionGuideTitle": "可用语法",
|
||||
"conditionGuideVars": "变量:{{previous.output}}、{{previous.matched}}、{{inputs.message}}、{{outputs.变量名}}、{{节点ID.output}}",
|
||||
"conditionGuideOps": "操作符:==、!=、>、>=、<、<=、contains、matches、&&、||",
|
||||
"conditionGuideJson": "嵌套字段:jsonpath(value, \"$.status\") 或 jq(value, \".severity\")",
|
||||
"edgeCondition": "连线条件",
|
||||
"edgeBranch": "条件分支",
|
||||
"selectBranch": "请选择",
|
||||
"edgeConditionHintCondition": "{{previous.matched}} == \"true\"(是)或 == \"false\"(否)",
|
||||
"edgeConditionHintExample": "例如: {{previous.output}} == \"ok\"",
|
||||
"edgeBranchHint": "从条件节点连出的第一条线默认为「是」分支,第二条为「否」分支;也可在此自定义条件。",
|
||||
"joinStrategy": "汇聚策略",
|
||||
"joinStrategyHint": "当多个上游同时进入该节点时,决定如何生成 previous:合并全部、按画布顺序取最后、取第一个非空或上游失败即中止。",
|
||||
"hitlPrompt": "审批提示",
|
||||
"hitlPromptPlaceholder": "请审批是否继续",
|
||||
"hitlReviewer": "审批方",
|
||||
"hitlInteractiveHint": "运行时在审批节点暂停,需通过 API 或监控面板人工通过/拒绝后继续。",
|
||||
"promptBinding": "提示字段绑定",
|
||||
"promptBindingHint": "留空提示文案时,从绑定字段读取审批说明",
|
||||
"outputSource": "变量来源",
|
||||
"sourceBinding": "输出字段绑定",
|
||||
"sourceBindingHint": "将绑定字段的值写入输出变量;也可填写下方固定值覆盖",
|
||||
"staticValue": "固定输出值",
|
||||
"resultBinding": "结束摘要绑定",
|
||||
"resultBindingHint": "结束节点展示的摘要字段",
|
||||
"endTemplate": "结束摘要模板"
|
||||
},
|
||||
"defaultHitlPrompt": "请审批该步骤是否继续执行",
|
||||
"nodeFallback": "节点 {{n}}",
|
||||
"loadFailed": "加载工作流失败",
|
||||
"saveFailed": "保存工作流失败",
|
||||
"dryRunPrompt": "输入试运行消息",
|
||||
"dryRunFailed": "试运行失败",
|
||||
"dryRunDone": "试运行完成",
|
||||
"dryRunTrace": "试运行轨迹",
|
||||
"dryRunNoTrace": "暂无轨迹",
|
||||
"deleteFailed": "删除工作流失败",
|
||||
"saved": "工作流已保存",
|
||||
"deleted": "工作流已删除",
|
||||
"idNameRequired": "工作流 ID 和名称不能为空",
|
||||
"selectToDelete": "请选择要删除的工作流",
|
||||
"confirmDelete": "确定删除工作流 {{id}}?",
|
||||
"duplicateEdge": "这两个节点之间已经有连线",
|
||||
"connectModeOn": "连线模式:依次点击源节点和目标节点",
|
||||
"connectModeOff": "已退出连线模式",
|
||||
"validation": {
|
||||
"needStart": "至少需要一个开始节点",
|
||||
"needOutput": "至少需要一个输出节点",
|
||||
"edgeSelfLoop": "连线 {{id}} 不能指向自身",
|
||||
"edgeSourceMissing": "连线 {{id}} 的源节点不存在",
|
||||
"edgeTargetMissing": "连线 {{id}} 的目标节点不存在",
|
||||
"startIncoming": "开始节点 {{label}} 不应有入边",
|
||||
"outputOutgoing": "输出节点 {{label}} 不应有出边",
|
||||
"toolNeedsMcp": "工具节点 {{label}} 需要选择 MCP 工具",
|
||||
"conditionNeedsExpr": "条件节点 {{label}} 需要条件表达式",
|
||||
"conditionNeedsOutEdge": "条件节点 {{label}} 至少需要一条出边(是/否分支)",
|
||||
"conditionTooManyEdges": "条件节点 {{label}} 建议最多两条出边(是/否);第三条及以后需配置连线条件",
|
||||
"conditionBranchLabel": "条件节点 {{label}} 的出边必须标记为是/否",
|
||||
"conditionBranchDuplicate": "条件节点 {{label}} 存在重复分支",
|
||||
"nodeNeedsIncoming": "节点 {{label}} 需要入边",
|
||||
"nodeNeedsOutgoing": "节点 {{label}} 需要出边",
|
||||
"nodeUnreachable": "节点 {{label}} 无法从开始节点到达",
|
||||
"graphCycle": "工作流存在环路,请保持 DAG",
|
||||
"serverFailed": "后端图校验失败",
|
||||
"outputNeedsKey": "输出节点 {{label}} 需要输出变量名"
|
||||
}
|
||||
},
|
||||
"c2": {
|
||||
"clipboardCopied": "已复制到剪贴板",
|
||||
|
||||
+83
-12
@@ -84,6 +84,7 @@ function initChatFilesPage() {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setupChatFilesDragDrop();
|
||||
loadChatFilesPage();
|
||||
}
|
||||
|
||||
@@ -1226,21 +1227,31 @@ function chatFilesUploadToFolderClick(ev, btn) {
|
||||
if (inp) inp.click();
|
||||
}
|
||||
|
||||
async function onChatFilesUploadPick(ev) {
|
||||
const input = ev.target;
|
||||
const file = input && input.files && input.files[0];
|
||||
if (!file) return;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
function chatFilesResolveUploadTarget() {
|
||||
const pendingDir = chatFilesPendingUploadDir;
|
||||
chatFilesPendingUploadDir = '';
|
||||
if (pendingDir) {
|
||||
form.append('relativeDir', pendingDir);
|
||||
} else {
|
||||
const conv = document.getElementById('chat-files-filter-conv');
|
||||
if (conv && conv.value.trim()) {
|
||||
form.append('conversationId', conv.value.trim());
|
||||
}
|
||||
return { relativeDir: pendingDir };
|
||||
}
|
||||
if (chatFilesGetGroupByMode() === 'folder') {
|
||||
const dir = chatFilesBrowsePath.join('/');
|
||||
return dir ? { relativeDir: dir } : {};
|
||||
}
|
||||
const conv = document.getElementById('chat-files-filter-conv');
|
||||
if (conv && conv.value.trim()) {
|
||||
return { conversationId: conv.value.trim() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function chatFilesUploadFile(file, target) {
|
||||
if (!file || chatFilesXHRUploadBusy) return false;
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (target && target.relativeDir) {
|
||||
form.append('relativeDir', target.relativeDir);
|
||||
} else if (target && target.conversationId) {
|
||||
form.append('conversationId', target.conversationId);
|
||||
}
|
||||
chatFilesSetUploadBusy(true);
|
||||
chatFilesSetUploadProgressUI(true, 0, file.name);
|
||||
@@ -1265,15 +1276,75 @@ async function onChatFilesUploadPick(ev) {
|
||||
: '上传成功。在列表中点击「复制路径」即可粘贴到对话中引用。';
|
||||
chatFilesShowToast(msg);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
alert((e && e.message) ? e.message : String(e));
|
||||
return false;
|
||||
} finally {
|
||||
chatFilesSetUploadBusy(false);
|
||||
chatFilesSetUploadProgressUI(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function chatFilesUploadFiles(fileList) {
|
||||
if (!fileList || !fileList.length || chatFilesXHRUploadBusy) return;
|
||||
const files = Array.from(fileList).filter(function (f) {
|
||||
return f && (f.name || f.size > 0);
|
||||
});
|
||||
if (!files.length) return;
|
||||
const target = chatFilesResolveUploadTarget();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const ok = await chatFilesUploadFile(files[i], target);
|
||||
if (!ok) break;
|
||||
}
|
||||
}
|
||||
|
||||
async function onChatFilesUploadPick(ev) {
|
||||
const input = ev.target;
|
||||
const files = input && input.files;
|
||||
if (!files || !files.length) return;
|
||||
try {
|
||||
await chatFilesUploadFiles(files);
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
let chatFilesDragDropBound = false;
|
||||
|
||||
function setupChatFilesDragDrop() {
|
||||
if (chatFilesDragDropBound) return;
|
||||
const wrap = document.getElementById('chat-files-list-wrap');
|
||||
if (!wrap) return;
|
||||
chatFilesDragDropBound = true;
|
||||
|
||||
wrap.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (chatFilesXHRUploadBusy) return;
|
||||
this.classList.add('drag-over');
|
||||
});
|
||||
wrap.addEventListener('dragleave', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!this.contains(e.relatedTarget)) {
|
||||
this.classList.remove('drag-over');
|
||||
}
|
||||
});
|
||||
wrap.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.classList.remove('drag-over');
|
||||
if (chatFilesXHRUploadBusy) return;
|
||||
const files = e.dataTransfer && e.dataTransfer.files;
|
||||
if (files && files.length) {
|
||||
chatFilesUploadFiles(files).catch(function (err) {
|
||||
if (err) alert((err && err.message) ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 语言切换后重新渲染列表:表头与「更多」菜单由 JS 拼接,无 data-i18n,需用当前语言的 t() 再生成一遍
|
||||
document.addEventListener('languagechange', function () {
|
||||
if (typeof window.currentPage !== 'function') return;
|
||||
|
||||
+371
-127
@@ -137,9 +137,12 @@ function normalizeHitlMode(mode) {
|
||||
}
|
||||
|
||||
function defaultHitlConfig() {
|
||||
const serverReviewer = (typeof window !== 'undefined' && window.csaiHitlDefaultReviewer)
|
||||
? window.csaiHitlDefaultReviewer
|
||||
: 'human';
|
||||
return {
|
||||
mode: HITL_MODE_OFF,
|
||||
reviewer: 'human',
|
||||
reviewer: normalizeHitlReviewer(serverReviewer),
|
||||
sensitiveTools: '',
|
||||
updatedAt: ''
|
||||
};
|
||||
@@ -315,16 +318,18 @@ async function onHitlReviewerChanged(reviewer) {
|
||||
const cfg = readHitlConfigFromForm();
|
||||
const cid = typeof currentConversationId === 'string' ? currentConversationId.trim() : '';
|
||||
saveHitlConfigForConversation(cid, cfg, { syncGlobalLast: true });
|
||||
if (cid && typeof window.saveHitlConversationConfig === 'function') {
|
||||
try {
|
||||
try {
|
||||
if (cid && typeof window.saveHitlConversationConfig === 'function') {
|
||||
await window.saveHitlConversationConfig(cid, cfg);
|
||||
const ok = typeof window.t === 'function' ? window.t('hitl.pageReviewerSaved') : '审批方已保存。';
|
||||
showChatToast(ok, 'success');
|
||||
} catch (e) {
|
||||
console.warn('onHitlReviewerChanged', e);
|
||||
const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败';
|
||||
showChatToast(prefix, 'error');
|
||||
} else if (typeof window.putHitlDefaultReviewer === 'function') {
|
||||
await window.putHitlDefaultReviewer(cfg.reviewer);
|
||||
}
|
||||
const ok = typeof window.t === 'function' ? window.t('hitl.pageReviewerSaved') : '审批方已保存。';
|
||||
showChatToast(ok, 'success');
|
||||
} catch (e) {
|
||||
console.warn('onHitlReviewerChanged', e);
|
||||
const prefix = typeof window.t === 'function' ? window.t('chat.hitlApplyFail') : '同步到服务器失败';
|
||||
showChatToast(prefix, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +512,7 @@ function chatAgentModeNormalizeStored(stored, cfg) {
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlGlobalToolWhitelist = window.csaiHitlGlobalToolWhitelist || [];
|
||||
window.csaiHitlDefaultReviewer = window.csaiHitlDefaultReviewer || 'human';
|
||||
window.csaiChatAgentMode = {
|
||||
EINO_MODES: CHAT_AGENT_EINO_MODES,
|
||||
EINO_SINGLE: CHAT_AGENT_MODE_EINO_SINGLE,
|
||||
@@ -518,6 +524,7 @@ if (typeof window !== 'undefined') {
|
||||
window.applyHitlSidebarConfig = applyHitlSidebarConfig;
|
||||
window.readHitlConfigFromForm = readHitlConfigFromForm;
|
||||
window.applyHitlConfigToUI = applyHitlConfigToUI;
|
||||
window.refreshHitlConfigByCurrentConversation = refreshHitlConfigByCurrentConversation;
|
||||
window.saveHitlConfigForConversation = saveHitlConfigForConversation;
|
||||
window.getHitlConfigForConversation = getHitlConfigForConversation;
|
||||
bindHitlSidebarModeListener();
|
||||
@@ -1098,7 +1105,11 @@ async function sendMessage() {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let streamSawDone = false;
|
||||
const dispatchStreamEvent = function (eventData) {
|
||||
if (eventData && eventData.type === 'done') {
|
||||
streamSawDone = true;
|
||||
}
|
||||
handleStreamEvent(eventData, progressElement, progressId,
|
||||
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
|
||||
@@ -1135,6 +1146,23 @@ async function sendMessage() {
|
||||
const lines = buffer.split('\n');
|
||||
await processSseLines(lines, dispatchStreamEvent);
|
||||
}
|
||||
if (!streamSawDone) {
|
||||
if (typeof loadActiveTasks === 'function') {
|
||||
loadActiveTasks();
|
||||
}
|
||||
const convId = currentConversationId || (body && body.conversationId) || null;
|
||||
let attached = false;
|
||||
if (convId && typeof window.attachRunningTaskEventStream === 'function') {
|
||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||
attached = await window.attachRunningTaskEventStream(convId).catch(() => false);
|
||||
}
|
||||
if (!attached) {
|
||||
const hint = typeof window.t === 'function'
|
||||
? window.t('chat.streamEndedWithoutDone')
|
||||
: '连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部运行中任务或刷新当前对话。';
|
||||
addMessage('system', hint);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
@@ -2020,6 +2048,19 @@ function refreshSystemReadyMessageBubbles() {
|
||||
});
|
||||
}
|
||||
|
||||
function createMessageAvatar(role) {
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'message-avatar';
|
||||
if (role === 'user') {
|
||||
avatar.innerHTML = '<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="12" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
} else if (role === 'assistant') {
|
||||
avatar.innerHTML = '<img src="/static/logo.png" alt="" class="message-avatar-img">';
|
||||
} else {
|
||||
avatar.textContent = 'S';
|
||||
}
|
||||
return avatar;
|
||||
}
|
||||
|
||||
// 添加消息(options.systemReadyMessage 为 true 时,语言切换会刷新该条文案)
|
||||
function addMessage(role, content, mcpExecutionIds = null, progressId = null, createdAt = null, options = null) {
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
@@ -2030,16 +2071,7 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
||||
messageDiv.className = 'message ' + role;
|
||||
|
||||
// 创建头像
|
||||
const avatar = document.createElement('div');
|
||||
avatar.className = 'message-avatar';
|
||||
if (role === 'user') {
|
||||
avatar.textContent = 'U';
|
||||
} else if (role === 'assistant') {
|
||||
avatar.textContent = 'A';
|
||||
} else {
|
||||
avatar.textContent = 'S';
|
||||
}
|
||||
messageDiv.appendChild(avatar);
|
||||
messageDiv.appendChild(createMessageAvatar(role));
|
||||
|
||||
// 创建消息内容容器
|
||||
const contentWrapper = document.createElement('div');
|
||||
@@ -2400,6 +2432,15 @@ function processDetailRowFingerprint(d) {
|
||||
return et + '\0' + msg + '\0' + dataKey;
|
||||
}
|
||||
|
||||
function compactWorkflowProcessDetails(details) {
|
||||
if (!Array.isArray(details) || details.length === 0) return details || [];
|
||||
return details.filter((detail) => {
|
||||
const eventType = detail && detail.eventType ? String(detail.eventType) : '';
|
||||
// workflow_node_start 已经表达了节点进入;这些事件只用于实时状态,落到详情里会让 Agent 节点看起来重复启动。
|
||||
return eventType !== 'workflow_agent_start';
|
||||
});
|
||||
}
|
||||
|
||||
// 渲染过程详情
|
||||
// options.append=true 时分页追加;options.markLoaded=false 时保留 lazy 标记(分页加载中)
|
||||
function renderProcessDetails(messageId, processDetails, options) {
|
||||
@@ -2491,6 +2532,7 @@ function renderProcessDetails(messageId, processDetails, options) {
|
||||
if (typeof window.coalesceProcessDetailsToolPairs === 'function') {
|
||||
processDetails = window.coalesceProcessDetailsToolPairs(processDetails);
|
||||
}
|
||||
processDetails = compactWorkflowProcessDetails(processDetails);
|
||||
// 如果没有processDetails或为空,显示空状态
|
||||
if (!processDetails || processDetails.length === 0) {
|
||||
if (!appendMode) {
|
||||
@@ -2518,7 +2560,44 @@ function renderProcessDetails(messageId, processDetails, options) {
|
||||
const agPx = processDetailAgentPrefix(data);
|
||||
|
||||
let itemTitle = title;
|
||||
if (eventType === 'iteration') {
|
||||
if (eventType === 'workflow_start') {
|
||||
const name = data.workflowName || data.workflowId || '';
|
||||
itemTitle = '🧭 工作流开始' + (name ? (' · ' + name) : '');
|
||||
} else if (eventType === 'workflow_done') {
|
||||
const name = data.workflowName || data.workflowId || '';
|
||||
itemTitle = '✅ 工作流完成' + (name ? (' · ' + name) : '');
|
||||
} else if (eventType === 'workflow_node_start') {
|
||||
const label = data.label || title || data.nodeId || '';
|
||||
itemTitle = '▶ 节点开始' + (label ? (' · ' + label) : '');
|
||||
} else if (eventType === 'workflow_node_result') {
|
||||
const label = data.label || data.nodeId || '';
|
||||
const status = data.status || '';
|
||||
const nodeType = data.nodeType != null ? String(data.nodeType).toLowerCase() : '';
|
||||
if (nodeType === 'condition') {
|
||||
const matched = data.matched === true || data.matched === 'true' || (data.output && (data.output.matched === true || data.output.matched === 'true'));
|
||||
itemTitle = (matched ? '✅' : '🔀') + ' 条件判断' + (label ? (' · ' + label) : '') + ' → ' + (matched ? '是' : '否');
|
||||
} else {
|
||||
const icon = status === 'failed' ? '❌' : (status === 'skipped' ? '⏭️' : '✅');
|
||||
itemTitle = icon + ' 节点完成' + (label ? (' · ' + label) : '') + (status ? ('(' + status + ')') : '');
|
||||
}
|
||||
} else if (eventType === 'workflow_branch_taken' || eventType === 'workflow_branch_skipped') {
|
||||
const branch = data.branchLabel || '';
|
||||
const target = data.targetLabel || data.targetId || '';
|
||||
const taken = eventType === 'workflow_branch_taken';
|
||||
itemTitle = (taken ? '➡️' : '⏭️') + (taken ? ' 执行分支' : ' 跳过分支') + (branch ? (' · ' + branch) : '') + (target ? (' → ' + target) : '');
|
||||
} else if (eventType === 'workflow_tool_start') {
|
||||
const tool = data.tool || data.toolName || '';
|
||||
itemTitle = '🔧 工具节点' + (tool ? (' · ' + tool) : '');
|
||||
} else if (eventType === 'workflow_agent_output') {
|
||||
const label = data.label || data.nodeId || '';
|
||||
itemTitle = '🤖 Agent 输出' + (label ? (' · ' + label) : '');
|
||||
} else if (eventType === 'workflow_hitl_checkpoint') {
|
||||
itemTitle = '🧑⚖️ 人工确认检查点';
|
||||
} else if (eventType === 'workflow_hitl_waiting') {
|
||||
itemTitle = '🧑⚖️ 工作流等待审批';
|
||||
} else if (eventType === 'workflow_paused') {
|
||||
itemTitle = '⏸️ 工作流已暂停';
|
||||
} else if (eventType === 'iteration') {
|
||||
const n = data.iteration || 1;
|
||||
if (data.orchestration === 'plan_execute' && data.einoScope === 'main') {
|
||||
const phase = typeof window.translatePlanExecuteAgentName === 'function'
|
||||
@@ -2649,16 +2728,23 @@ function finishProcessDetailsRender(messageElement, processDetails, isLazyNotLoa
|
||||
}
|
||||
|
||||
const hasPendingHitlInDetails = processDetails.some(d => d && d.eventType === 'hitl_interrupt');
|
||||
const hasPendingWorkflowHitl = processDetails.some(d => d && d.eventType === 'workflow_hitl_waiting');
|
||||
const hasErrorOrCancelled = processDetails.some(d =>
|
||||
d.eventType === 'error' || d.eventType === 'cancelled'
|
||||
);
|
||||
if (hasErrorOrCancelled && !hasPendingHitlInDetails) {
|
||||
if (hasErrorOrCancelled && !hasPendingHitlInDetails && !hasPendingWorkflowHitl) {
|
||||
timeline.classList.remove('expanded');
|
||||
const processDetailBtn = messageElement.querySelector('.process-detail-btn');
|
||||
if (processDetailBtn) {
|
||||
processDetailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') + '</span>';
|
||||
}
|
||||
}
|
||||
if (hasPendingWorkflowHitl && messageElement && messageElement.id) {
|
||||
const convId = typeof window.currentConversationId === 'string' ? window.currentConversationId : '';
|
||||
if (convId && typeof window.restoreWorkflowHitlInlineForConversation === 'function') {
|
||||
window.restoreWorkflowHitlInlineForConversation(convId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 懒加载折叠态:后台拉摘要,提示迭代规模而不加载全量详情 */
|
||||
@@ -4210,6 +4296,7 @@ function renderAttackChain(chainData) {
|
||||
const nodeCount = chainData.nodes.length;
|
||||
const edgeCount = chainData.edges.length;
|
||||
const isComplexGraph = nodeCount > 15 || edgeCount > 25;
|
||||
const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
|
||||
// 优化节点标签:智能截断和换行
|
||||
chainData.nodes.forEach(node => {
|
||||
@@ -4313,6 +4400,29 @@ function renderAttackChain(chainData) {
|
||||
iconType = 'vulnerability';
|
||||
}
|
||||
|
||||
const labelTextColor = isDarkTheme ? '#E5E7EB' : '#0F172A';
|
||||
if (isDarkTheme) {
|
||||
typeColor = '#E5E7EB';
|
||||
bgGradientStart = '#111827';
|
||||
if (nodeType === 'target') {
|
||||
bgGradientEnd = '#1E1B4B';
|
||||
} else if (nodeType === 'action') {
|
||||
bgGradientEnd = accentColor === '#10B981' ? '#052E2B' : '#172033';
|
||||
} else if (nodeType === 'vulnerability') {
|
||||
if (riskScore >= 80) {
|
||||
bgGradientEnd = '#3F101C';
|
||||
} else if (riskScore >= 60) {
|
||||
bgGradientEnd = '#3B1D0D';
|
||||
} else if (riskScore >= 40) {
|
||||
bgGradientEnd = '#3A2A0A';
|
||||
} else {
|
||||
bgGradientEnd = '#063A36';
|
||||
}
|
||||
} else {
|
||||
bgGradientEnd = '#172033';
|
||||
}
|
||||
}
|
||||
|
||||
// 为每个节点生成图标 background-image(data URL)
|
||||
const iconSvg = _acBuildNodeIconDataUrl(iconType, accentColor, accentDark);
|
||||
|
||||
@@ -4345,6 +4455,7 @@ function renderAttackChain(chainData) {
|
||||
accentDark: accentDark,
|
||||
bgGradientStart: bgGradientStart,
|
||||
bgGradientEnd: bgGradientEnd,
|
||||
labelTextColor: labelTextColor,
|
||||
iconDataUrl: iconSvg,
|
||||
badgeText: badgeText,
|
||||
riskScore: riskScore,
|
||||
@@ -4444,7 +4555,9 @@ function renderAttackChain(chainData) {
|
||||
},
|
||||
'border-opacity': 0.5,
|
||||
// 文字样式
|
||||
'color': '#0f172a',
|
||||
'color': function(ele) {
|
||||
return ele.data('labelTextColor') || '#0f172a';
|
||||
},
|
||||
'font-size': function(ele) {
|
||||
return isComplexGraph ? '13px' : '14px';
|
||||
},
|
||||
@@ -5048,7 +5161,7 @@ function showNodeDetails(nodeData) {
|
||||
if (nodeData.metadata.ai_analysis) {
|
||||
html += `
|
||||
<div class="node-detail-item">
|
||||
<strong>AI分析:</strong> <div style="margin-top: 5px; padding: 8px; background: #f5f5f5; border-radius: 4px;">${escapeHtml(nodeData.metadata.ai_analysis)}</div>
|
||||
<strong>AI分析:</strong> <div class="node-detail-ai-analysis">${escapeHtml(nodeData.metadata.ai_analysis)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -6169,49 +6282,183 @@ const BATCH_PROJECT_FILTER_SELECT_ID = 'batch-project-filter';
|
||||
const projectFilterCustomSelectRegistry = {};
|
||||
let projectFilterCustomSelectDocBound = false;
|
||||
|
||||
function projectFilterT(key, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
const value = window.t(key);
|
||||
if (value && value !== key) return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function closeProjectFilterCustomSelect(selectId) {
|
||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||
if (!reg || !reg.wrapper) return;
|
||||
reg.wrapper.classList.remove('open');
|
||||
if (reg.trigger) reg.trigger.setAttribute('aria-expanded', 'false');
|
||||
if (reg.filterSearchTimer) {
|
||||
clearTimeout(reg.filterSearchTimer);
|
||||
reg.filterSearchTimer = null;
|
||||
}
|
||||
reg.filterSearchSeq = (reg.filterSearchSeq || 0) + 1;
|
||||
if (reg.searchInput) reg.searchInput.value = '';
|
||||
}
|
||||
|
||||
function closeAllProjectFilterCustomSelects() {
|
||||
Object.keys(projectFilterCustomSelectRegistry).forEach(closeProjectFilterCustomSelect);
|
||||
}
|
||||
|
||||
function ensureProjectFilterSearchUi(reg) {
|
||||
if (reg.searchInput && reg.optionsList) return;
|
||||
const { dropdown } = reg;
|
||||
dropdown.innerHTML = '';
|
||||
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'conversation-project-filter-search';
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'search';
|
||||
searchInput.className = 'conversation-project-filter-search-input';
|
||||
searchInput.setAttribute('autocomplete', 'off');
|
||||
searchInput.setAttribute('data-i18n', 'chat.filterProjectSearch');
|
||||
searchInput.setAttribute('data-i18n-attr', 'placeholder');
|
||||
searchInput.placeholder = projectFilterT('chat.filterProjectSearch', '搜索项目…');
|
||||
searchWrap.appendChild(searchInput);
|
||||
dropdown.appendChild(searchWrap);
|
||||
reg.searchInput = searchInput;
|
||||
|
||||
const optionsList = document.createElement('div');
|
||||
optionsList.className = 'conversation-project-filter-options';
|
||||
dropdown.appendChild(optionsList);
|
||||
reg.optionsList = optionsList;
|
||||
reg.filterSearchSeq = 0;
|
||||
reg.filterSearchTimer = null;
|
||||
|
||||
searchInput.addEventListener('input', () => loadProjectFilterLocalOptions(reg.select.id));
|
||||
searchInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') closeProjectFilterCustomSelect(reg.select.id);
|
||||
});
|
||||
}
|
||||
|
||||
function createProjectFilterOptionButton(value, label, selectedValue) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'conversation-project-filter-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', value);
|
||||
item.title = label;
|
||||
if (value === selectedValue) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'conversation-project-filter-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
const labelEl = document.createElement('span');
|
||||
labelEl.className = 'conversation-project-filter-option-label';
|
||||
labelEl.textContent = label;
|
||||
labelEl.title = label;
|
||||
item.appendChild(check);
|
||||
item.appendChild(labelEl);
|
||||
return item;
|
||||
}
|
||||
|
||||
function appendProjectFilterStatusMessage(optionsList, className, text) {
|
||||
const el = document.createElement('div');
|
||||
el.className = className;
|
||||
el.textContent = text;
|
||||
optionsList.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderProjectFilterPinnedOptions(reg) {
|
||||
const { select, optionsList } = reg;
|
||||
optionsList.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, (opt) => {
|
||||
if (opt.value === '' || opt.value === CONVERSATION_PROJECT_FILTER_NONE) {
|
||||
optionsList.appendChild(createProjectFilterOptionButton(opt.value, opt.textContent || '', select.value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ensureNativeProjectFilterOption(select, projectId, label) {
|
||||
if (!projectId || projectId === CONVERSATION_PROJECT_FILTER_NONE) return;
|
||||
if (Array.prototype.some.call(select.options, (opt) => opt.value === projectId)) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = projectId;
|
||||
opt.textContent = label || projectId;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
async function loadProjectFilterLocalOptions(selectId) {
|
||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||
if (!reg || !reg.optionsList) return;
|
||||
const query = (reg.searchInput?.value || '').trim();
|
||||
const seq = ++reg.filterSearchSeq;
|
||||
|
||||
const needsFetch = typeof window.isProjectsCacheReady === 'function' && !window.isProjectsCacheReady();
|
||||
let loadingEl = null;
|
||||
if (needsFetch) {
|
||||
renderProjectFilterPinnedOptions(reg);
|
||||
loadingEl = appendProjectFilterStatusMessage(
|
||||
reg.optionsList,
|
||||
'conversation-project-filter-status',
|
||||
projectFilterT('common.loading', '加载中…')
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const ensureLoaded = typeof window.ensureProjectsLoaded === 'function'
|
||||
? window.ensureProjectsLoaded
|
||||
: null;
|
||||
const filterLocal = typeof window.filterActiveProjectsLocal === 'function'
|
||||
? window.filterActiveProjectsLocal
|
||||
: null;
|
||||
if (!ensureLoaded || !filterLocal) throw new Error('projects cache unavailable');
|
||||
|
||||
const all = await ensureLoaded();
|
||||
if (seq !== reg.filterSearchSeq) return;
|
||||
|
||||
renderProjectFilterPinnedOptions(reg);
|
||||
const selected = reg.select.value;
|
||||
const pinnedValues = new Set(['', CONVERSATION_PROJECT_FILTER_NONE]);
|
||||
const projects = filterLocal(all, query);
|
||||
projects.forEach((p) => {
|
||||
if (pinnedValues.has(p.id)) return;
|
||||
reg.optionsList.appendChild(
|
||||
createProjectFilterOptionButton(p.id, p.name || p.id, selected)
|
||||
);
|
||||
});
|
||||
|
||||
if (query && projects.length === 0) {
|
||||
appendProjectFilterStatusMessage(
|
||||
reg.optionsList,
|
||||
'conversation-project-filter-empty',
|
||||
projectFilterT('chat.filterProjectSearchEmpty', '没有匹配的项目')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== reg.filterSearchSeq) return;
|
||||
renderProjectFilterPinnedOptions(reg);
|
||||
appendProjectFilterStatusMessage(
|
||||
reg.optionsList,
|
||||
'conversation-project-filter-empty',
|
||||
projectFilterT('chat.filterProjectSearchFailed', '加载项目失败,请重试')
|
||||
);
|
||||
} finally {
|
||||
if (loadingEl && loadingEl.parentNode) loadingEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function syncProjectFilterCustomSelect(selectId) {
|
||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||
if (!reg) return;
|
||||
const { select, dropdown, trigger } = reg;
|
||||
ensureProjectFilterSearchUi(reg);
|
||||
const { select, trigger } = reg;
|
||||
const valueSpan = trigger.querySelector('.conversation-project-filter-value');
|
||||
dropdown.innerHTML = '';
|
||||
Array.prototype.forEach.call(select.options, (opt) => {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.className = 'conversation-project-filter-option';
|
||||
item.setAttribute('role', 'option');
|
||||
item.setAttribute('data-value', opt.value);
|
||||
const labelText = opt.textContent || '';
|
||||
item.title = labelText;
|
||||
if (opt.value === select.value) {
|
||||
item.classList.add('is-selected');
|
||||
item.setAttribute('aria-selected', 'true');
|
||||
} else {
|
||||
item.setAttribute('aria-selected', 'false');
|
||||
}
|
||||
const check = document.createElement('span');
|
||||
check.className = 'conversation-project-filter-check';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
check.textContent = '✓';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'conversation-project-filter-option-label';
|
||||
label.textContent = labelText;
|
||||
label.title = labelText;
|
||||
item.appendChild(check);
|
||||
item.appendChild(label);
|
||||
dropdown.appendChild(item);
|
||||
});
|
||||
const selectedOpt = select.options[select.selectedIndex];
|
||||
const selectedText = selectedOpt ? (selectedOpt.textContent || '') : '';
|
||||
if (valueSpan) {
|
||||
@@ -6264,6 +6511,13 @@ function initProjectFilterCustomSelect(selectId) {
|
||||
if (!open) {
|
||||
wrapper.classList.add('open');
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
ensureProjectFilterSearchUi(projectFilterCustomSelectRegistry[selectId]);
|
||||
const reg = projectFilterCustomSelectRegistry[selectId];
|
||||
if (reg?.searchInput) {
|
||||
reg.searchInput.value = '';
|
||||
loadProjectFilterLocalOptions(selectId);
|
||||
requestAnimationFrame(() => reg.searchInput.focus());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6273,6 +6527,8 @@ function initProjectFilterCustomSelect(selectId) {
|
||||
e.stopPropagation();
|
||||
const val = opt.getAttribute('data-value');
|
||||
if (val === null) return;
|
||||
const label = opt.querySelector('.conversation-project-filter-option-label')?.textContent || val;
|
||||
ensureNativeProjectFilterOption(select, val, label);
|
||||
if (select.value !== val) {
|
||||
select.value = val;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
@@ -6319,38 +6575,7 @@ function setConversationProjectFilter(projectId) {
|
||||
updateConversationSidebarFilterUI();
|
||||
}
|
||||
|
||||
function isValidConversationProjectFilter(projectId) {
|
||||
if (!projectId) return true;
|
||||
if (projectId === CONVERSATION_PROJECT_FILTER_NONE) return true;
|
||||
const map = window.projectNameById;
|
||||
if (!map || typeof map !== 'object') return true;
|
||||
return Object.prototype.hasOwnProperty.call(map, projectId);
|
||||
}
|
||||
|
||||
async function refreshConversationProjectFilter() {
|
||||
const sel = document.getElementById('conversation-project-filter');
|
||||
if (!sel) return;
|
||||
const saved = getConversationProjectFilter();
|
||||
let projects = [];
|
||||
if (typeof window.ensureProjectsLoaded === 'function') {
|
||||
try {
|
||||
const list = await window.ensureProjectsLoaded();
|
||||
projects = (list || []).filter((p) => p && p.id && p.status !== 'archived');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
if (!projects.length) {
|
||||
try {
|
||||
const res = await apiFetch('/api/projects?status=active&limit=200');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const items = data.projects || data.items || (Array.isArray(data) ? data : []);
|
||||
projects = items.filter((p) => p && p.id);
|
||||
if (typeof window.rebuildProjectNameMap === 'function') {
|
||||
window.rebuildProjectNameMap(items);
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
function appendProjectFilterPinnedNativeOptions(sel) {
|
||||
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
||||
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
|
||||
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
|
||||
@@ -6365,16 +6590,44 @@ async function refreshConversationProjectFilter() {
|
||||
unboundOpt.textContent = unboundLabel;
|
||||
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
|
||||
sel.appendChild(unboundOpt);
|
||||
projects
|
||||
.slice()
|
||||
.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || '', undefined, { sensitivity: 'base' }))
|
||||
.forEach((p) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.name || p.id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
const normalized = isValidConversationProjectFilter(saved) ? saved : '';
|
||||
}
|
||||
|
||||
async function resolveProjectFilterSelection(projectId) {
|
||||
const saved = (projectId || '').trim();
|
||||
if (!saved || saved === CONVERSATION_PROJECT_FILTER_NONE) return saved;
|
||||
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||
? window.fetchProjectSummary
|
||||
: null;
|
||||
if (!fetchSummary) return saved;
|
||||
const project = await fetchSummary(saved);
|
||||
if (!project || !project.id || project.status === 'archived') return '';
|
||||
return project.id;
|
||||
}
|
||||
|
||||
async function appendSelectedProjectFilterOption(sel, projectId) {
|
||||
const id = (projectId || '').trim();
|
||||
if (!id || id === CONVERSATION_PROJECT_FILTER_NONE) return;
|
||||
if (Array.prototype.some.call(sel.options, (opt) => opt.value === id)) return;
|
||||
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||
? window.fetchProjectSummary
|
||||
: null;
|
||||
const project = fetchSummary ? await fetchSummary(id) : null;
|
||||
const label = (project && (project.name || project.id)) || (window.projectNameById && window.projectNameById[id]) || id;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = label;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
async function refreshConversationProjectFilter() {
|
||||
const sel = document.getElementById('conversation-project-filter');
|
||||
if (!sel) return;
|
||||
const saved = getConversationProjectFilter();
|
||||
appendProjectFilterPinnedNativeOptions(sel);
|
||||
const normalized = await resolveProjectFilterSelection(saved);
|
||||
if (normalized && normalized !== CONVERSATION_PROJECT_FILTER_NONE) {
|
||||
await appendSelectedProjectFilterOption(sel, normalized);
|
||||
}
|
||||
if (normalized !== saved) setConversationProjectFilter(normalized);
|
||||
sel.value = normalized;
|
||||
syncConversationProjectCustomSelect();
|
||||
@@ -8249,47 +8502,37 @@ function getConversationProjectLabel(conv) {
|
||||
if (!pid) {
|
||||
return typeof window.t === 'function' ? window.t('batchManageModal.noProject') : '无项目';
|
||||
}
|
||||
return (window.projectNameById && window.projectNameById[pid]) || pid;
|
||||
const name = window.projectNameById && window.projectNameById[pid];
|
||||
if (name) return name;
|
||||
return typeof window.t === 'function' ? window.t('batchManageModal.unknownProject') : '未知项目';
|
||||
}
|
||||
|
||||
async function prefetchProjectNamesForConversations(conversations) {
|
||||
const missing = new Set();
|
||||
for (const conv of conversations || []) {
|
||||
const pid = getConversationProjectId(conv);
|
||||
if (pid && !(window.projectNameById && window.projectNameById[pid])) {
|
||||
missing.add(pid);
|
||||
}
|
||||
}
|
||||
if (!missing.size) return;
|
||||
const fetchSummary = typeof window.fetchProjectSummary === 'function'
|
||||
? window.fetchProjectSummary
|
||||
: null;
|
||||
if (!fetchSummary) return;
|
||||
await Promise.all([...missing].map((id) => fetchSummary(id).catch(() => null)));
|
||||
}
|
||||
|
||||
async function refreshBatchProjectFilter() {
|
||||
const sel = document.getElementById('batch-project-filter');
|
||||
if (!sel) return;
|
||||
const saved = sel.value || '';
|
||||
if (typeof window.ensureProjectsLoaded === 'function') {
|
||||
try {
|
||||
await window.ensureProjectsLoaded();
|
||||
} catch (e) { /* ignore */ }
|
||||
appendProjectFilterPinnedNativeOptions(sel);
|
||||
const normalized = await resolveProjectFilterSelection(saved);
|
||||
if (normalized && normalized !== CONVERSATION_PROJECT_FILTER_NONE) {
|
||||
await appendSelectedProjectFilterOption(sel, normalized);
|
||||
}
|
||||
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
||||
const allLabel = tFn ? tFn('chat.filterAllProjects') : '全部项目';
|
||||
const unboundLabel = tFn ? tFn('chat.filterUnboundProjects') : '未绑定项目';
|
||||
sel.innerHTML = '';
|
||||
const allOpt = document.createElement('option');
|
||||
allOpt.value = '';
|
||||
allOpt.textContent = allLabel;
|
||||
allOpt.setAttribute('data-i18n', 'chat.filterAllProjects');
|
||||
sel.appendChild(allOpt);
|
||||
const unboundOpt = document.createElement('option');
|
||||
unboundOpt.value = CONVERSATION_PROJECT_FILTER_NONE;
|
||||
unboundOpt.textContent = unboundLabel;
|
||||
unboundOpt.setAttribute('data-i18n', 'chat.filterUnboundProjects');
|
||||
sel.appendChild(unboundOpt);
|
||||
const source = window.projectNameById ? Object.keys(window.projectNameById) : [];
|
||||
source
|
||||
.sort((a, b) => {
|
||||
const na = (window.projectNameById[a] || a).toLowerCase();
|
||||
const nb = (window.projectNameById[b] || b).toLowerCase();
|
||||
return na.localeCompare(nb);
|
||||
})
|
||||
.forEach((id) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = id;
|
||||
opt.textContent = window.projectNameById[id] || id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
const valid = !saved || saved === CONVERSATION_PROJECT_FILTER_NONE || (window.projectNameById && window.projectNameById[saved]);
|
||||
sel.value = valid ? saved : '';
|
||||
sel.value = normalized;
|
||||
syncProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
||||
}
|
||||
|
||||
@@ -8331,6 +8574,7 @@ async function showBatchManageModal() {
|
||||
try {
|
||||
initProjectFilterCustomSelect(BATCH_PROJECT_FILTER_SELECT_ID);
|
||||
allConversationsForBatch = await fetchAllConversations('');
|
||||
await prefetchProjectNamesForConversations(allConversationsForBatch);
|
||||
await refreshBatchProjectFilter();
|
||||
const sidebarFilter = getConversationProjectFilter();
|
||||
const batchSel = document.getElementById('batch-project-filter');
|
||||
|
||||
+57
-19
@@ -1707,7 +1707,7 @@ window.navigateToVulnerabilitiesWithFilter = navigateToVulnerabilitiesWithFilter
|
||||
|
||||
// 漏洞严重程度分布:半环形(donut)渲染
|
||||
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
|
||||
// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad
|
||||
// 段间分隔由 gapRad 几何间隙完成,不使用描边,避免浅色/暗色下白边或黑边过重
|
||||
var SEVERITY_DONUT_CFG = {
|
||||
// viewBox 0 0 480 260:整体保持紧凑,但环厚回到「黄金比例」附近,
|
||||
// 让弧带本身有视觉分量,又不像最早那版那样占太多空间。
|
||||
@@ -1717,7 +1717,7 @@ var SEVERITY_DONUT_CFG = {
|
||||
rOuter: 165,
|
||||
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
|
||||
labelOffset: 14,
|
||||
gapRad: 0.012
|
||||
gapRad: 0.022
|
||||
};
|
||||
|
||||
// 三段渐变:[高光浅调, 中段饱和色, 深色边缘] —— 做出类似 3D 釉面的层次
|
||||
@@ -1759,28 +1759,66 @@ function severityLabel(id) {
|
||||
return SEVERITY_DEFAULT_LABELS[id] || id;
|
||||
}
|
||||
|
||||
function isDashboardDarkTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
}
|
||||
|
||||
function ensureSeverityDonutThemeObserver() {
|
||||
if (severityDonutState.themeObserver) return;
|
||||
severityDonutState.themeObserver = new MutationObserver(function (mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
if (mutations[i].attributeName === 'data-theme') {
|
||||
renderSeverityDonut(severityDonutState.bySeverity, severityDonutState.total);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
severityDonutState.themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme']
|
||||
});
|
||||
}
|
||||
|
||||
function ensureSeverityDonutDefs() {
|
||||
var defsEl = document.getElementById('dashboard-severity-donut-defs');
|
||||
if (!defsEl || defsEl.hasChildNodes()) return;
|
||||
if (!defsEl) return;
|
||||
var dark = isDashboardDarkTheme();
|
||||
var html = '';
|
||||
html += '<linearGradient id="donut-track-face" x1="0%" y1="0%" x2="0%" y2="100%">';
|
||||
html += '<stop offset="0%" stop-color="#f8fafc"/>';
|
||||
html += '<stop offset="55%" stop-color="#e8eef5"/>';
|
||||
html += '<stop offset="100%" stop-color="#dce5ef"/>';
|
||||
if (dark) {
|
||||
html += '<stop offset="0%" stop-color="#334155"/>';
|
||||
html += '<stop offset="55%" stop-color="#1e293b"/>';
|
||||
html += '<stop offset="100%" stop-color="#172033"/>';
|
||||
} else {
|
||||
html += '<stop offset="0%" stop-color="#f8fafc"/>';
|
||||
html += '<stop offset="55%" stop-color="#e8eef5"/>';
|
||||
html += '<stop offset="100%" stop-color="#dce5ef"/>';
|
||||
}
|
||||
html += '</linearGradient>';
|
||||
html += '<radialGradient id="donut-track-vignette" cx="50%" cy="85%" r="75%" fx="50%" fy="85%">';
|
||||
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>';
|
||||
html += '<stop offset="70%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||
if (dark) {
|
||||
html += '<stop offset="0%" stop-color="#0f172a" stop-opacity="0.55"/>';
|
||||
html += '<stop offset="70%" stop-color="#0f172a" stop-opacity="0"/>';
|
||||
} else {
|
||||
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>';
|
||||
html += '<stop offset="70%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||
}
|
||||
html += '</radialGradient>';
|
||||
html += '<radialGradient id="donut-inner-gloss" cx="35%" cy="75%" r="55%">';
|
||||
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>';
|
||||
html += '<stop offset="55%" stop-color="#ffffff" stop-opacity="0.08"/>';
|
||||
html += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||
if (dark) {
|
||||
html += '<stop offset="0%" stop-color="#94a3b8" stop-opacity="0.10"/>';
|
||||
html += '<stop offset="55%" stop-color="#94a3b8" stop-opacity="0.03"/>';
|
||||
html += '<stop offset="100%" stop-color="#94a3b8" stop-opacity="0"/>';
|
||||
} else {
|
||||
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>';
|
||||
html += '<stop offset="55%" stop-color="#ffffff" stop-opacity="0.08"/>';
|
||||
html += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||
}
|
||||
html += '</radialGradient>';
|
||||
html += '<filter id="donut-segment-soften" x="-18%" y="-18%" width="136%" height="136%" color-interpolation-filters="sRGB">';
|
||||
html += '<feGaussianBlur in="SourceAlpha" stdDeviation="0.8" result="blur"/>';
|
||||
html += '<feOffset dx="0" dy="1.5" in="blur" result="off"/>';
|
||||
html += '<feFlood flood-color="#0f172a" flood-opacity="0.13" result="flood"/>';
|
||||
html += '<feFlood flood-color="' + (dark ? '#000000' : '#0f172a') + '" flood-opacity="' + (dark ? '0.28' : '0.13') + '" result="flood"/>';
|
||||
html += '<feComposite in="flood" in2="off" operator="in" result="shadow"/>';
|
||||
html += '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
|
||||
html += '</filter>';
|
||||
@@ -1808,17 +1846,17 @@ function renderSeverityDonut(bySeverity, total) {
|
||||
severityDonutState.total = total || 0;
|
||||
severityDonutState.hoverId = null;
|
||||
|
||||
ensureSeverityDonutThemeObserver();
|
||||
|
||||
var cfg = SEVERITY_DONUT_CFG;
|
||||
ensureSeverityDonutDefs();
|
||||
|
||||
// 背景轨迹(完整半环):双层填充营造凹槽 + 高光
|
||||
if (!trackEl.hasChildNodes()) {
|
||||
var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
|
||||
trackEl.innerHTML =
|
||||
'<path class="donut-track-shadow" d="' + trackPath + '"/>' +
|
||||
'<path class="donut-track" fill="url(#donut-track-face)" d="' + trackPath + '"/>' +
|
||||
'<path class="donut-track-vignette" fill="url(#donut-track-vignette)" d="' + trackPath + '"/>';
|
||||
}
|
||||
var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
|
||||
trackEl.innerHTML =
|
||||
'<path class="donut-track-shadow" d="' + trackPath + '"/>' +
|
||||
'<path class="donut-track" fill="url(#donut-track-face)" d="' + trackPath + '"/>' +
|
||||
'<path class="donut-track-vignette" fill="url(#donut-track-vignette)" d="' + trackPath + '"/>';
|
||||
|
||||
var ids = ['critical', 'high', 'medium', 'low', 'info'];
|
||||
var severities = ids.map(function (id) {
|
||||
|
||||
+175
-10
@@ -231,10 +231,66 @@ async function fetchHitlConversationConfig(conversationId) {
|
||||
if (!data || !data.hitl) return null;
|
||||
return {
|
||||
hitl: data.hitl,
|
||||
defaultReviewer: hitlReviewerNormalize(data.defaultReviewer || 'human'),
|
||||
hitlGlobalToolWhitelist: Array.isArray(data.hitlGlobalToolWhitelist) ? data.hitlGlobalToolWhitelist : []
|
||||
};
|
||||
}
|
||||
|
||||
function applyHitlDefaultReviewerFromServer(reviewer) {
|
||||
const v = hitlReviewerNormalize(reviewer);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlDefaultReviewer = v;
|
||||
}
|
||||
if (typeof window.saveHitlLastGlobalConfig === 'function' && typeof window.getHitlLastGlobalConfig === 'function') {
|
||||
const gl = window.getHitlLastGlobalConfig();
|
||||
const base = gl && typeof gl === 'object'
|
||||
? gl
|
||||
: { mode: 'off', sensitiveTools: '', updatedAt: '' };
|
||||
window.saveHitlLastGlobalConfig(Object.assign({}, base, {
|
||||
reviewer: v,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
async function fetchHitlDefaultReviewer() {
|
||||
const resp = await hitlApiFetch('/api/hitl/default-reviewer', { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
return applyHitlDefaultReviewerFromServer('human');
|
||||
}
|
||||
const data = await resp.json();
|
||||
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
|
||||
}
|
||||
|
||||
async function putHitlDefaultReviewer(reviewer) {
|
||||
const normalized = hitlReviewerNormalize(reviewer);
|
||||
const resp = await hitlApiFetch('/api/hitl/default-reviewer', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reviewer: normalized })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const msg = await readHitlApiError(resp);
|
||||
throw new Error(msg || ('HTTP ' + resp.status));
|
||||
}
|
||||
const data = await resp.json();
|
||||
return applyHitlDefaultReviewerFromServer(data && data.defaultReviewer);
|
||||
}
|
||||
|
||||
async function initHitlDefaultReviewerFromServer() {
|
||||
try {
|
||||
await fetchHitlDefaultReviewer();
|
||||
if (!getCurrentConversationIdForHitl() && typeof window.refreshHitlConfigByCurrentConversation === 'function') {
|
||||
window.refreshHitlConfigByCurrentConversation();
|
||||
}
|
||||
refreshHitlPageReviewerBar();
|
||||
} catch (e) {
|
||||
console.warn('initHitlDefaultReviewerFromServer', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 无会话时:将免审批工具合并进服务端 config.yaml,返回更新后的全局白名单数组 */
|
||||
async function mergeHitlGlobalToolWhitelist(sensitiveTools) {
|
||||
const list = Array.isArray(sensitiveTools) ? sensitiveTools : [];
|
||||
@@ -462,6 +518,9 @@ async function syncHitlConfigFromServer(conversationId) {
|
||||
const pack = await fetchHitlConversationConfig(conversationId);
|
||||
if (!pack || !pack.hitl) return;
|
||||
const cfg = pack.hitl;
|
||||
if (pack.defaultReviewer) {
|
||||
applyHitlDefaultReviewerFromServer(pack.defaultReviewer);
|
||||
}
|
||||
const globalWL = pack.hitlGlobalToolWhitelist || [];
|
||||
if (typeof window !== 'undefined') {
|
||||
window.csaiHitlGlobalToolWhitelist = globalWL;
|
||||
@@ -619,14 +678,9 @@ async function followAgentRunAfterHitlDecision(conversationId) {
|
||||
}
|
||||
|
||||
function renderHitlPendingList(items) {
|
||||
const container = document.getElementById('hitl-pending-list');
|
||||
if (!container) return;
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
if (!list.length) {
|
||||
container.innerHTML = '<div class="empty-state">' + escapeHtml(hitlT('emptyState', 'No pending approvals')) + '</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = list.map(function (item) {
|
||||
if (!list.length) return '';
|
||||
return list.map(function (item) {
|
||||
const payloadObj = hitlParsePayloadObject(item.payload || '');
|
||||
const payload = String(item.payload || '');
|
||||
const contextHtml = hitlRenderContextBlocks(payloadObj);
|
||||
@@ -663,6 +717,86 @@ function renderHitlPendingList(items) {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function hitlWorkflowPendingLabel(run) {
|
||||
const pending = hitlParsePayloadObject(run.pending_hitl_json || run.pendingHitlJson || '');
|
||||
const pendingHitl = pending.pendingHitl && typeof pending.pendingHitl === 'object' ? pending.pendingHitl : pending;
|
||||
return pendingHitl.label || pendingHitl.nodeId || run.pending_hitl_node_id || run.pendingHitlNodeId || run.workflow_id || run.workflowId || run.id || '-';
|
||||
}
|
||||
|
||||
function renderWorkflowHitlPendingList(runs) {
|
||||
const list = Array.isArray(runs) ? runs : [];
|
||||
if (!list.length) return '';
|
||||
return list.map(function (run) {
|
||||
const runId = String(run.id || '').trim();
|
||||
const pending = hitlParsePayloadObject(run.pending_hitl_json || run.pendingHitlJson || '');
|
||||
const pendingHitl = pending.pendingHitl && typeof pending.pendingHitl === 'object' ? pending.pendingHitl : pending;
|
||||
const label = hitlWorkflowPendingLabel(run);
|
||||
const prompt = String(pendingHitl.prompt || '').trim();
|
||||
const convId = String(run.conversation_id || run.conversationId || '').trim();
|
||||
const qRun = JSON.stringify(runId).replace(/"/g, '"');
|
||||
const qConv = JSON.stringify(convId).replace(/"/g, '"');
|
||||
const workflowLabel = hitlT('workflowPendingTitle', 'Workflow approval');
|
||||
const openChatLabel = hitlT('openConversation', 'Open conversation');
|
||||
return (
|
||||
'<div class="hitl-pending-item hitl-pending-item--workflow">' +
|
||||
'<div class="hitl-pending-item-header">' +
|
||||
'<div class="hitl-pending-item-title">' +
|
||||
'<span class="hitl-tool-badge">' + escapeHtml(workflowLabel) + '</span>' +
|
||||
'<span class="hitl-mode-tag hitl-mode-tag--approval">' + escapeHtml(label) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="hitl-pending-meta">' + escapeHtml(hitlT('conversationLabel', 'Conversation:')) + ' ' + escapeHtml(convId || '-') + '</div>' +
|
||||
(prompt ? ('<div class="hitl-input-help">' + escapeHtml(prompt) + '</div>') : '') +
|
||||
'<div class="hitl-input-help">' + escapeHtml(hitlT('commentHelp', 'Comment (optional): briefly note the approval reason.')) + '</div>' +
|
||||
'<input id="workflow-hitl-comment-' + escapeHtml(runId) + '" class="hitl-config-input hitl-inline-comment" type="text" placeholder="' + escapeHtml(hitlT('commentPlaceholder', 'e.g. allow read-only command')) + '">' +
|
||||
'<div class="hitl-pending-actions">' +
|
||||
(convId ? ('<button class="btn-secondary" onclick="openHitlConversation(' + qConv + ')">' + escapeHtml(openChatLabel) + '</button>') : '') +
|
||||
'<button class="btn-secondary" onclick="submitWorkflowHitlDecisionFromPage(' + qRun + ', false, ' + qConv + ')">' + escapeHtml(hitlT('reject', 'Reject')) + '</button>' +
|
||||
'<button class="btn-primary" onclick="submitWorkflowHitlDecisionFromPage(' + qRun + ', true, ' + qConv + ')">' + escapeHtml(hitlT('approve', 'Approve')) + '</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function submitWorkflowHitlDecisionFromPage(runId, approved, conversationId) {
|
||||
const rid = String(runId || '').trim();
|
||||
if (!rid) return;
|
||||
const commentEl = document.getElementById('workflow-hitl-comment-' + rid);
|
||||
const comment = commentEl ? String(commentEl.value || '').trim() : '';
|
||||
try {
|
||||
if (typeof window.submitWorkflowHitlDecision === 'function') {
|
||||
await window.submitWorkflowHitlDecision(rid, approved, comment);
|
||||
} else {
|
||||
const resp = await hitlApiFetch('/api/workflows/runs/' + encodeURIComponent(rid) + '/resume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ approved: !!approved, comment: comment })
|
||||
});
|
||||
const body = await resp.json().catch(function () { return {}; });
|
||||
if (!resp.ok) throw new Error((body && body.error) ? body.error : 'submit failed');
|
||||
}
|
||||
if (conversationId && typeof followAgentRunAfterHitlDecision === 'function') {
|
||||
await followAgentRunAfterHitlDecision(conversationId);
|
||||
}
|
||||
await refreshHitlPending();
|
||||
} catch (e) {
|
||||
alert((e && e.message) ? e.message : hitlT('submitFailed', 'Submit failed'));
|
||||
}
|
||||
}
|
||||
|
||||
function openHitlConversation(conversationId) {
|
||||
const cid = String(conversationId || '').trim();
|
||||
if (!cid) return;
|
||||
if (typeof switchPage === 'function') {
|
||||
switchPage('chat');
|
||||
}
|
||||
if (typeof loadConversation === 'function') {
|
||||
loadConversation(cid);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshHitlPending() {
|
||||
const container = document.getElementById('hitl-pending-list');
|
||||
if (!container) return;
|
||||
@@ -680,7 +814,27 @@ async function refreshHitlPending() {
|
||||
}
|
||||
const data = await resp.json();
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
hitlPendingTotal = typeof data.total === 'number' ? data.total : items.length;
|
||||
let workflowRuns = [];
|
||||
try {
|
||||
const wfResp = await hitlApiFetch('/api/workflows/runs/pending', { credentials: 'same-origin' });
|
||||
if (wfResp.ok) {
|
||||
const wfData = await wfResp.json().catch(function () { return {}; });
|
||||
workflowRuns = Array.isArray(wfData.runs) ? wfData.runs : [];
|
||||
}
|
||||
} catch (wfErr) {
|
||||
console.warn('fetch workflow pending runs failed', wfErr);
|
||||
}
|
||||
const searchQ = q && q.value.trim() ? q.value.trim().toLowerCase() : '';
|
||||
if (searchQ) {
|
||||
workflowRuns = workflowRuns.filter(function (run) {
|
||||
const conv = String(run.conversation_id || run.conversationId || '').toLowerCase();
|
||||
const wfId = String(run.workflow_id || run.workflowId || '').toLowerCase();
|
||||
const runId = String(run.id || '').toLowerCase();
|
||||
const label = hitlWorkflowPendingLabel(run).toLowerCase();
|
||||
return conv.indexOf(searchQ) >= 0 || wfId.indexOf(searchQ) >= 0 || runId.indexOf(searchQ) >= 0 || label.indexOf(searchQ) >= 0;
|
||||
});
|
||||
}
|
||||
hitlPendingTotal = (typeof data.total === 'number' ? data.total : items.length) + workflowRuns.length;
|
||||
const maxPage = Math.max(1, Math.ceil(hitlPendingTotal / hitlPendingPageSize));
|
||||
if (hitlPendingPage > maxPage) {
|
||||
hitlPendingPage = maxPage;
|
||||
@@ -694,7 +848,13 @@ async function refreshHitlPending() {
|
||||
}
|
||||
hitlPendingCache = items;
|
||||
hitlPendingLoaded = true;
|
||||
renderHitlPendingList(items);
|
||||
const workflowHtml = renderWorkflowHitlPendingList(workflowRuns);
|
||||
const toolHtml = items.length ? renderHitlPendingList(items) : '';
|
||||
if (!workflowHtml && !toolHtml) {
|
||||
container.innerHTML = '<div class="empty-state">' + escapeHtml(hitlT('emptyState', 'No pending approvals')) + '</div>';
|
||||
} else {
|
||||
container.innerHTML = workflowHtml + (workflowHtml && toolHtml ? '<div class="hitl-pending-section-divider"></div>' : '') + (toolHtml || '');
|
||||
}
|
||||
renderHitlPendingPagination();
|
||||
} catch (e) {
|
||||
hitlPendingLoaded = false;
|
||||
@@ -1266,7 +1426,7 @@ function refreshHitlLogsI18n() {
|
||||
|
||||
function refreshHitlPendingI18n() {
|
||||
if (!document.getElementById('hitl-pending-list') || !hitlPendingLoaded) return;
|
||||
renderHitlPendingList(hitlPendingCache);
|
||||
refreshHitlPending();
|
||||
}
|
||||
|
||||
function refreshHitlI18n() {
|
||||
@@ -1442,6 +1602,8 @@ window.onHitlLogsPageSizeChange = onHitlLogsPageSizeChange;
|
||||
window.onHitlPendingPageSizeChange = onHitlPendingPageSizeChange;
|
||||
window.submitHitlDecision = submitHitlDecision;
|
||||
window.submitHitlDecisionWithPayload = submitHitlDecisionWithPayload;
|
||||
window.submitWorkflowHitlDecisionFromPage = submitWorkflowHitlDecisionFromPage;
|
||||
window.openHitlConversation = openHitlConversation;
|
||||
window.dismissHitlItem = dismissHitlItem;
|
||||
window.followAgentRunAfterHitlDecision = followAgentRunAfterHitlDecision;
|
||||
|
||||
@@ -1460,6 +1622,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof window.bindHitlReviewerToggleListeners === 'function') {
|
||||
window.bindHitlReviewerToggleListeners();
|
||||
}
|
||||
initHitlDefaultReviewerFromServer();
|
||||
setTimeout(reconcileHitlUiState, 0);
|
||||
});
|
||||
|
||||
@@ -1478,3 +1641,5 @@ window.mergeHitlGlobalToolWhitelist = mergeHitlGlobalToolWhitelist;
|
||||
|
||||
// 由 chat.js 在 loadConversation 内 await 调用;挂到 window 供其它入口显式触发
|
||||
window.syncHitlConfigFromServer = syncHitlConfigFromServer;
|
||||
window.fetchHitlDefaultReviewer = fetchHitlDefaultReviewer;
|
||||
window.putHitlDefaultReviewer = putHitlDefaultReviewer;
|
||||
|
||||
@@ -154,6 +154,9 @@
|
||||
}
|
||||
applyTranslations(document);
|
||||
updateLangLabel();
|
||||
if (typeof window.refreshThemeToggleLabel === 'function') {
|
||||
window.refreshThemeToggleLabel();
|
||||
}
|
||||
try {
|
||||
window.__locale = lang;
|
||||
} catch (e) { /* ignore */ }
|
||||
@@ -180,6 +183,9 @@
|
||||
await loadLanguageResources(initialLang);
|
||||
applyTranslations(document);
|
||||
updateLangLabel();
|
||||
if (typeof window.refreshThemeToggleLabel === 'function') {
|
||||
window.refreshThemeToggleLabel();
|
||||
}
|
||||
try {
|
||||
window.__locale = i18next.language || initialLang;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
@@ -338,8 +338,8 @@ function showFofaParseModal(nlText, parsed) {
|
||||
const explanation = parsed?.explanation != null ? String(parsed.explanation) : '';
|
||||
|
||||
const warningsHtml = warnings.length
|
||||
? `<ul style="margin: 8px 0 0 18px;">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
|
||||
: '<div class="muted" style="margin-top: 8px;">' + _t('infoCollect.none') + '</div>';
|
||||
? `<ul class="info-collect-parse-warnings-list">${warnings.map(w => `<li>${escapeHtml(w)}</li>`).join('')}</ul>`
|
||||
: '<div class="muted info-collect-parse-warnings-empty">' + _t('infoCollect.none') + '</div>';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'fofa-parse-modal';
|
||||
@@ -348,37 +348,37 @@ function showFofaParseModal(nlText, parsed) {
|
||||
openAppModal(modal, { focus: false });
|
||||
deferModalContent(function () {
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content" style="max-width: 900px;">
|
||||
<div class="modal-content info-collect-parse-modal-content" style="max-width: 900px;">
|
||||
<div class="modal-header">
|
||||
<h2>${_t('infoCollect.parseResultTitle')}</h2>
|
||||
<span class="modal-close" id="fofa-parse-modal-close" title="${_t('common.close')}">×</span>
|
||||
</div>
|
||||
<div style="padding: 18px 28px; overflow: auto;">
|
||||
<div class="info-collect-parse-modal-body">
|
||||
<div class="form-group">
|
||||
<label>${_t('infoCollect.naturalLanguageLabel')}</label>
|
||||
<div class="muted" style="margin-top: 6px; white-space: pre-wrap;">${safeNL || '-'}</div>
|
||||
<div class="muted info-collect-parse-nl-text">${safeNL || '-'}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<div class="form-group info-collect-parse-form-group">
|
||||
<label for="fofa-parse-query">${_t('infoCollect.fofaQueryEditable')}</label>
|
||||
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${_t('infoCollect.queryPlaceholder')}"></textarea>
|
||||
<small class="form-hint">${_t('infoCollect.confirmBeforeQuery')}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<div class="form-group info-collect-parse-form-group">
|
||||
<label>${_t('infoCollect.reminder')}</label>
|
||||
<div style="background: #fff8e1; border: 1px solid #ffe8a3; border-radius: 10px; padding: 10px 12px;">
|
||||
<div class="info-collect-parse-warnings">
|
||||
${warningsHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${explanation ? `
|
||||
<div class="form-group" style="margin-top: 14px;">
|
||||
<div class="form-group info-collect-parse-form-group">
|
||||
<label>${_t('infoCollect.explanation')}</label>
|
||||
<pre style="margin-top: 8px; white-space: pre-wrap; background: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 10px; padding: 10px 12px; font-size: 13px;">${escapeHtml(explanation)}</pre>
|
||||
<pre class="info-collect-parse-explanation">${escapeHtml(explanation)}</pre>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="modal-footer" style="padding: 18px 28px;">
|
||||
<div class="modal-footer info-collect-parse-modal-footer">
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-cancel">${_t('infoCollect.parseModalCancel')}</button>
|
||||
<button class="btn-secondary" type="button" id="fofa-parse-apply">${_t('infoCollect.parseModalApply')}</button>
|
||||
<button class="btn-primary" type="button" id="fofa-parse-apply-run">${_t('infoCollect.parseModalApplyRun')}</button>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
'agent-md-modal',
|
||||
'batch-manage-modal',
|
||||
'create-group-modal',
|
||||
'workflow-meta-modal',
|
||||
'login-overlay',
|
||||
]);
|
||||
|
||||
@@ -34,6 +35,9 @@
|
||||
if (el.classList.contains('info-collect-cell-modal')) {
|
||||
return 'flex';
|
||||
}
|
||||
if (el.classList.contains('chat-files-form-modal')) {
|
||||
return 'flex';
|
||||
}
|
||||
if (FLEX_MODAL_IDS.has(el.id)) {
|
||||
return 'flex';
|
||||
}
|
||||
|
||||
+524
-31
@@ -333,6 +333,19 @@ const responseStreamStateByProgressId = new Map();
|
||||
// 主通道当前迭代轮次缓存:progressId -> { iteration, orchestration }
|
||||
const mainIterationStateByProgressId = new Map();
|
||||
|
||||
/** 图编排多 Agent 节点切换时清空流式聚合,避免推理/输出条目覆盖上一节点内容 */
|
||||
function clearTimelineStreamStates(progressId) {
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
thinkingStreamStateByProgressId.delete(progressId);
|
||||
einoAgentReplyStreamStateByProgressId.delete(progressId);
|
||||
const prefix = String(progressId) + '::';
|
||||
for (const key of Array.from(toolResultStreamStateByKey.keys())) {
|
||||
if (String(key).startsWith(prefix)) {
|
||||
toolResultStreamStateByKey.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一段主通道流式输出(Eino 可能重复 response_start) */
|
||||
function sameMainResponseStreamMeta(a, b) {
|
||||
if (!a || !b) return false;
|
||||
@@ -341,7 +354,10 @@ function sameMainResponseStreamMeta(a, b) {
|
||||
if (!agentA || agentA !== agentB) return false;
|
||||
const orchA = String(a.orchestration != null ? a.orchestration : '').trim();
|
||||
const orchB = String(b.orchestration != null ? b.orchestration : '').trim();
|
||||
return orchA === orchB;
|
||||
if (orchA !== orchB) return false;
|
||||
const nodeA = String(a.workflowNodeId != null ? a.workflowNodeId : '').trim();
|
||||
const nodeB = String(b.workflowNodeId != null ? b.workflowNodeId : '').trim();
|
||||
return nodeA === nodeB;
|
||||
}
|
||||
|
||||
function resolveMainIterationTag(progressId, responseData) {
|
||||
@@ -366,7 +382,8 @@ function buildMainResponseStreamIdentity(progressId, responseData) {
|
||||
const agent = String(d.einoAgent != null ? d.einoAgent : '').trim();
|
||||
const orch = String(d.orchestration != null ? d.orchestration : '').trim();
|
||||
const iterTag = resolveMainIterationTag(progressId, d);
|
||||
return agent + '|' + orch + '|iter=' + iterTag;
|
||||
const nodeId = String(d.workflowNodeId != null ? d.workflowNodeId : '').trim();
|
||||
return agent + '|' + orch + '|iter=' + iterTag + '|wfNode=' + nodeId;
|
||||
}
|
||||
|
||||
function extractIterationTagFromStreamIdentity(identity) {
|
||||
@@ -1747,13 +1764,18 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
if (scope !== 'sub') {
|
||||
const prevMainIter = mainIterationStateByProgressId.get(String(progressId));
|
||||
const prevN = prevMainIter && prevMainIter.iteration != null ? prevMainIter.iteration : null;
|
||||
const prevNode = prevMainIter && prevMainIter.workflowNodeId != null
|
||||
? String(prevMainIter.workflowNodeId).trim()
|
||||
: '';
|
||||
const curNode = d.workflowNodeId != null ? String(d.workflowNodeId).trim() : '';
|
||||
mainIterationStateByProgressId.set(String(progressId), {
|
||||
iteration: n,
|
||||
orchestration: d.orchestration != null ? d.orchestration : ''
|
||||
orchestration: d.orchestration != null ? d.orchestration : '',
|
||||
workflowNodeId: curNode
|
||||
});
|
||||
// 主通道进入新轮次后不复用上一轮的「执行输出」时间线条目
|
||||
if (prevN != null && prevN !== n) {
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
// 主通道进入新轮次或图编排切换到新 Agent 节点后,不复用上一段的流式时间线条目
|
||||
if (prevN != null && (n < prevN || prevN !== n || (curNode && prevNode && curNode !== prevNode))) {
|
||||
clearTimelineStreamStates(progressId);
|
||||
}
|
||||
}
|
||||
let iterTitle;
|
||||
@@ -1785,6 +1807,147 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_start': {
|
||||
const d = event.data || {};
|
||||
const name = d.workflowName || d.workflowId || '';
|
||||
addTimelineItem(timeline, 'workflow_start', {
|
||||
title: '🧭 工作流开始' + (name ? (' · ' + name) : ''),
|
||||
message: event.message || '',
|
||||
data: d
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_done': {
|
||||
const d = event.data || {};
|
||||
const name = d.workflowName || d.workflowId || '';
|
||||
addTimelineItem(timeline, 'workflow_done', {
|
||||
title: '✅ 工作流完成' + (name ? (' · ' + name) : ''),
|
||||
message: event.message || '',
|
||||
data: d
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_node_start': {
|
||||
const d = event.data || {};
|
||||
const label = d.label || d.nodeId || '';
|
||||
const nodeType = d.nodeType != null ? String(d.nodeType).toLowerCase() : '';
|
||||
if (nodeType === 'agent') {
|
||||
clearTimelineStreamStates(progressId);
|
||||
}
|
||||
addTimelineItem(timeline, 'workflow_node_start', {
|
||||
title: '▶ 节点开始' + (label ? (' · ' + label) : ''),
|
||||
message: event.message || '',
|
||||
data: d
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_node_result': {
|
||||
const d = event.data || {};
|
||||
const label = d.label || d.nodeId || '';
|
||||
const status = d.status || '';
|
||||
const nodeType = d.nodeType != null ? String(d.nodeType).toLowerCase() : '';
|
||||
let title;
|
||||
if (nodeType === 'condition') {
|
||||
const matched = d.matched === true || d.matched === 'true' || (d.output && (d.output.matched === true || d.output.matched === 'true'));
|
||||
title = (matched ? '✅' : '🔀') + ' 条件判断' + (label ? (' · ' + label) : '') + ' → ' + (matched ? '是' : '否');
|
||||
} else {
|
||||
const icon = status === 'failed' ? '❌' : (status === 'skipped' ? '⏭️' : '✅');
|
||||
title = icon + ' 节点完成' + (label ? (' · ' + label) : '') + (status ? ('(' + status + ')') : '');
|
||||
}
|
||||
addTimelineItem(timeline, 'workflow_node_result', {
|
||||
title: title,
|
||||
message: event.message || '',
|
||||
data: d
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_branch_taken':
|
||||
case 'workflow_branch_skipped': {
|
||||
const d = event.data || {};
|
||||
const branch = d.branchLabel || '';
|
||||
const target = d.targetLabel || d.targetId || '';
|
||||
const taken = event.type === 'workflow_branch_taken';
|
||||
addTimelineItem(timeline, event.type, {
|
||||
title: (taken ? '➡️' : '⏭️') + (taken ? ' 执行分支' : ' 跳过分支') + (branch ? (' · ' + branch) : '') + (target ? (' → ' + target) : ''),
|
||||
message: event.message || '',
|
||||
data: d
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_tool_start': {
|
||||
const d = event.data || {};
|
||||
const tool = d.tool || d.toolName || '';
|
||||
addTimelineItem(timeline, 'workflow_tool_start', {
|
||||
title: '🔧 工具节点' + (tool ? (' · ' + tool) : ''),
|
||||
message: event.message || '',
|
||||
data: d
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_agent_output': {
|
||||
const d = event.data || {};
|
||||
const label = d.label || d.nodeId || '';
|
||||
addTimelineItem(timeline, 'workflow_agent_output', {
|
||||
title: '🤖 Agent 输出' + (label ? (' · ' + label) : ''),
|
||||
message: event.message || '',
|
||||
data: d
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_hitl_checkpoint': {
|
||||
addTimelineItem(timeline, 'workflow_hitl_checkpoint', {
|
||||
title: '🧑⚖️ 人工确认检查点',
|
||||
message: event.message || '',
|
||||
data: event.data || {}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_hitl_waiting': {
|
||||
const d = event.data || {};
|
||||
const hitlItemId = addTimelineItem(timeline, 'workflow_hitl_waiting', {
|
||||
title: '🧑⚖️ 工作流等待审批',
|
||||
message: event.message || '',
|
||||
data: d
|
||||
});
|
||||
renderInlineWorkflowHitlApproval(hitlItemId, d);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_hitl_resumed': {
|
||||
addTimelineItem(timeline, 'workflow_hitl_resumed', {
|
||||
title: '✅ 审批已通过',
|
||||
message: event.message || '人工审批已通过,继续执行',
|
||||
data: event.data || {}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_hitl_rejected': {
|
||||
addTimelineItem(timeline, 'workflow_hitl_rejected', {
|
||||
title: '❌ 审批已拒绝',
|
||||
message: event.message || '',
|
||||
data: event.data || {}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'workflow_paused': {
|
||||
addTimelineItem(timeline, 'workflow_paused', {
|
||||
title: '⏸️ 工作流已暂停',
|
||||
message: event.message || '',
|
||||
data: event.data || {}
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'eino_trace_run':
|
||||
case 'eino_trace_start':
|
||||
case 'eino_trace_end':
|
||||
@@ -2542,6 +2705,16 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
if (event.data && event.data.workflowStatus === 'awaiting_hitl') {
|
||||
const waitingTitle = document.querySelector(`#${progressId} .progress-title`);
|
||||
if (waitingTitle) {
|
||||
waitingTitle.textContent = '⏸️ ' + (typeof window.t === 'function' ? window.t('chat.workflowAwaitingApproval') : '工作流等待审批');
|
||||
}
|
||||
if (progressTaskState.has(progressId)) {
|
||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('chat.workflowAwaitingApproval') : '等待审批');
|
||||
}
|
||||
break;
|
||||
}
|
||||
// 清理流式输出状态
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
mainIterationStateByProgressId.delete(String(progressId));
|
||||
@@ -2709,6 +2882,210 @@ function renderInlineHitlApproval(itemId, data) {
|
||||
rejectBtn.onclick = function () { submit('reject'); };
|
||||
}
|
||||
|
||||
function renderInlineWorkflowHitlApproval(itemId, data) {
|
||||
const item = document.getElementById(itemId);
|
||||
if (!item || !data) return;
|
||||
const runId = data.workflowRunId || data.workflow_run_id;
|
||||
if (!runId) return;
|
||||
let contentEl = item.querySelector('.timeline-item-content');
|
||||
if (!contentEl) {
|
||||
contentEl = document.createElement('div');
|
||||
contentEl.className = 'timeline-item-content';
|
||||
item.appendChild(contentEl);
|
||||
}
|
||||
const existingPanel = contentEl.querySelector('.workflow-hitl-inline-approval');
|
||||
if (existingPanel) existingPanel.remove();
|
||||
|
||||
const label = data.label || data.nodeId || runId;
|
||||
const prompt = data.prompt || '';
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'workflow-hitl-inline-approval hitl-inline-approval';
|
||||
panel.innerHTML = `
|
||||
<div class="hitl-input-help"><strong>${escapeHtml(label)}</strong> 等待人工审批。</div>
|
||||
${prompt ? `<div class="hitl-input-help">${escapeHtml(prompt)}</div>` : ''}
|
||||
<div class="hitl-input-help">备注(可选)</div>
|
||||
<input class="hitl-config-input workflow-hitl-inline-comment" type="text" placeholder="审批意见">
|
||||
<div class="hitl-pending-actions">
|
||||
<button class="btn-secondary workflow-hitl-inline-reject">拒绝</button>
|
||||
<button class="btn-primary workflow-hitl-inline-approve">通过</button>
|
||||
</div>
|
||||
<div class="hitl-input-help workflow-hitl-inline-status"></div>
|
||||
`;
|
||||
contentEl.appendChild(panel);
|
||||
|
||||
const approveBtn = panel.querySelector('.workflow-hitl-inline-approve');
|
||||
const rejectBtn = panel.querySelector('.workflow-hitl-inline-reject');
|
||||
const commentInput = panel.querySelector('.workflow-hitl-inline-comment');
|
||||
const statusEl = panel.querySelector('.workflow-hitl-inline-status');
|
||||
|
||||
const setBusy = function (busy) {
|
||||
approveBtn.disabled = busy;
|
||||
rejectBtn.disabled = busy;
|
||||
};
|
||||
|
||||
const submit = async function (approved) {
|
||||
setBusy(true);
|
||||
const comment = String(commentInput.value || '').trim();
|
||||
try {
|
||||
const fetchFn = typeof apiFetch === 'function' ? apiFetch : fetch;
|
||||
const response = await fetchFn(`/api/workflows/runs/${encodeURIComponent(runId)}/resume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approved: approved, comment: comment })
|
||||
});
|
||||
const body = response && typeof response.json === 'function' ? await response.json() : null;
|
||||
if (!response || !response.ok) {
|
||||
statusEl.textContent = (body && body.error) ? body.error : '提交失败,请重试';
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
if (body && body.streamResuming) {
|
||||
statusEl.textContent = approved ? '已通过,工作流继续执行中…' : '已拒绝';
|
||||
panel.classList.add('hitl-inline-done');
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = approved ? '已通过,工作流继续执行' : '已拒绝';
|
||||
panel.classList.add('hitl-inline-done');
|
||||
} catch (e) {
|
||||
statusEl.textContent = '提交失败:' + (e && e.message ? e.message : 'unknown error');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
approveBtn.onclick = function () { submit(true); };
|
||||
rejectBtn.onclick = function () { submit(false); };
|
||||
}
|
||||
|
||||
function parseWorkflowHitlPendingJSON(raw) {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === 'object') return raw;
|
||||
try {
|
||||
const o = JSON.parse(String(raw));
|
||||
return o && typeof o === 'object' ? o : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function workflowHitlDataFromRun(run) {
|
||||
if (!run) return null;
|
||||
const runId = run.id || run.workflowRunId || run.workflow_run_id;
|
||||
if (!runId) return null;
|
||||
const pending = parseWorkflowHitlPendingJSON(run.pending_hitl_json || run.pendingHitlJson || run.pendingHitlJSON);
|
||||
const pendingHitl = pending.pendingHitl && typeof pending.pendingHitl === 'object' ? pending.pendingHitl : pending;
|
||||
return {
|
||||
workflowRunId: String(runId),
|
||||
nodeId: pendingHitl.nodeId || run.pending_hitl_node_id || run.pendingHitlNodeId || '',
|
||||
label: pendingHitl.label || pendingHitl.nodeId || run.pending_hitl_node_id || run.pendingHitlNodeId || runId,
|
||||
prompt: pendingHitl.prompt || '',
|
||||
conversationId: run.conversation_id || run.conversationId || ''
|
||||
};
|
||||
}
|
||||
|
||||
function findWorkflowHitlTimelineItem(detailsContainer, runId) {
|
||||
if (!detailsContainer || !runId) return null;
|
||||
const rid = String(runId).trim();
|
||||
const byRun = detailsContainer.querySelector('[data-workflow-run-id="' + hitlEscapeAttrSelector(rid) + '"]');
|
||||
if (byRun) return byRun;
|
||||
const items = detailsContainer.querySelectorAll('.timeline-item-workflow_hitl_waiting');
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const el = items[i];
|
||||
if (!el.querySelector('.workflow-hitl-inline-approval.hitl-inline-done')) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
return items.length ? items[items.length - 1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新或切换会话后:根据 workflow_runs(awaiting_hitl) 恢复工作流内联审批入口。
|
||||
*/
|
||||
async function restoreWorkflowHitlInlineForConversation(conversationId) {
|
||||
if (!conversationId || typeof apiFetch !== 'function') return;
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId !== conversationId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await apiFetch('/api/workflows/runs/pending?conversationId=' + encodeURIComponent(conversationId));
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json().catch(function () { return {}; });
|
||||
const runs = Array.isArray(data.runs) ? data.runs : [];
|
||||
if (!runs.length) return;
|
||||
|
||||
let msgEl = document.querySelector('#chat-messages [data-backend-message-id]');
|
||||
const nodes = document.querySelectorAll('#chat-messages .message.assistant');
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
if (nodes[i] && nodes[i].dataset && nodes[i].dataset.backendMessageId) {
|
||||
msgEl = nodes[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!msgEl || !msgEl.id) return;
|
||||
const clientMsgId = msgEl.id;
|
||||
const backendMsgId = msgEl.dataset.backendMessageId;
|
||||
const detailsContainer = document.getElementById('process-details-' + clientMsgId);
|
||||
if (!detailsContainer) return;
|
||||
|
||||
if (detailsContainer.dataset.lazyNotLoaded === '1' && detailsContainer.dataset.loaded !== '1') {
|
||||
try {
|
||||
detailsContainer.dataset.loading = '1';
|
||||
if (typeof loadProcessDetailsPaginated === 'function') {
|
||||
await loadProcessDetailsPaginated(clientMsgId, backendMsgId);
|
||||
} else if (typeof apiFetch === 'function' && backendMsgId) {
|
||||
const res = await apiFetch('/api/messages/' + encodeURIComponent(backendMsgId) + '/process-details');
|
||||
const j = await res.json().catch(function () { return {}; });
|
||||
if (res.ok && typeof renderProcessDetails === 'function') {
|
||||
renderProcessDetails(clientMsgId, (j && Array.isArray(j.processDetails)) ? j.processDetails : []);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载过程详情失败(工作流 HITL 恢复):', e);
|
||||
} finally {
|
||||
detailsContainer.dataset.loading = '0';
|
||||
}
|
||||
}
|
||||
|
||||
expandProcessDetailsTimeline(clientMsgId);
|
||||
|
||||
for (let i = 0; i < runs.length; i++) {
|
||||
const hitlData = workflowHitlDataFromRun(runs[i]);
|
||||
if (!hitlData) continue;
|
||||
let hitlItemEl = findWorkflowHitlTimelineItem(detailsContainer, hitlData.workflowRunId);
|
||||
if (!hitlItemEl) {
|
||||
const timeline = detailsContainer.querySelector('.progress-timeline');
|
||||
if (timeline && typeof addTimelineItem === 'function') {
|
||||
const itemId = addTimelineItem(timeline, 'workflow_hitl_waiting', {
|
||||
title: '🧑⚖️ 工作流等待审批',
|
||||
message: hitlData.label || '',
|
||||
data: hitlData
|
||||
});
|
||||
hitlItemEl = document.getElementById(itemId);
|
||||
}
|
||||
}
|
||||
if (hitlItemEl && hitlItemEl.id) {
|
||||
renderInlineWorkflowHitlApproval(hitlItemEl.id, hitlData);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('restoreWorkflowHitlInlineForConversation failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
window.restoreWorkflowHitlInlineForConversation = restoreWorkflowHitlInlineForConversation;
|
||||
window.submitWorkflowHitlDecision = async function submitWorkflowHitlDecision(runId, approved, comment) {
|
||||
const fetchFn = typeof apiFetch === 'function' ? apiFetch : fetch;
|
||||
const response = await fetchFn('/api/workflows/runs/' + encodeURIComponent(String(runId)) + '/resume', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ approved: !!approved, comment: comment || '' })
|
||||
});
|
||||
const body = response && typeof response.json === 'function' ? await response.json() : null;
|
||||
if (!response || !response.ok) {
|
||||
throw new Error((body && body.error) ? body.error : '提交失败');
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
function hitlEscapeAttrSelector(val) {
|
||||
const s = String(val);
|
||||
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
||||
@@ -2832,6 +3209,9 @@ async function restoreHitlInlineForConversation(conversationId) {
|
||||
if (!hitlItemEl) continue;
|
||||
renderInlineHitlApproval(hitlItemEl.id, hitlData);
|
||||
}
|
||||
if (typeof restoreWorkflowHitlInlineForConversation === 'function') {
|
||||
await restoreWorkflowHitlInlineForConversation(conversationId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('restoreHitlInlineForConversation failed', e);
|
||||
}
|
||||
@@ -2951,7 +3331,11 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let replaySawDone = false;
|
||||
const dispatchTaskEvent = function (eventData) {
|
||||
if (eventData && eventData.type === 'done') {
|
||||
replaySawDone = true;
|
||||
}
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
||||
};
|
||||
while (true) {
|
||||
@@ -2971,14 +3355,14 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
|
||||
clearCsTaskReplay();
|
||||
}
|
||||
if (progressTaskState.has(progressId)) {
|
||||
if (replaySawDone && progressTaskState.has(progressId)) {
|
||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
|
||||
}
|
||||
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
|
||||
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
|
||||
}
|
||||
if (typeof loadActiveTasks === 'function') loadActiveTasks();
|
||||
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||
if (replaySawDone && typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||
await window.loadConversation(conversationId);
|
||||
}
|
||||
return true;
|
||||
@@ -3262,6 +3646,34 @@ function updateToolCallStatus(progressId, toolCallId, status) {
|
||||
}
|
||||
|
||||
// 添加时间线项目
|
||||
function buildWorkflowConditionResultHtml(data) {
|
||||
const output = (data && data.output) || {};
|
||||
const expr = (data && data.expression) || output.condition || '';
|
||||
const matched = (data && (data.matched === true || data.matched === 'true'))
|
||||
|| output.matched === true || output.matched === 'true';
|
||||
const branchText = matched ? '是(true)' : '否(false)';
|
||||
const branchClass = matched ? 'is-true' : 'is-false';
|
||||
return `<div class="timeline-item-content workflow-condition-result">
|
||||
<div class="workflow-condition-row">
|
||||
<span class="workflow-agent-io-label">表达式</span>
|
||||
<code>${escapeHtml(String(expr || '(空)'))}</code>
|
||||
</div>
|
||||
<div class="workflow-condition-row">
|
||||
<span class="workflow-agent-io-label">结果</span>
|
||||
<span class="workflow-condition-branch ${branchClass}">${escapeHtml(branchText)}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildWorkflowBranchDetailHtml(data) {
|
||||
const cond = (data && data.edgeCondition) || '';
|
||||
if (!cond) return '';
|
||||
return `<div class="timeline-item-content workflow-branch-detail">
|
||||
<span class="workflow-agent-io-label">连线条件</span>
|
||||
<code>${escapeHtml(cond)}</code>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function addTimelineItem(timeline, type, options) {
|
||||
const item = document.createElement('div');
|
||||
// 生成唯一ID
|
||||
@@ -3300,6 +3712,12 @@ function addTimelineItem(timeline, type, options) {
|
||||
if (type === 'hitl_interrupt' && options.data && options.data.interruptId != null && String(options.data.interruptId).trim() !== '') {
|
||||
item.dataset.hitlInterruptId = String(options.data.interruptId).trim();
|
||||
}
|
||||
if (type === 'workflow_hitl_waiting' && options.data) {
|
||||
const runId = options.data.workflowRunId || options.data.workflow_run_id;
|
||||
if (runId != null && String(runId).trim() !== '') {
|
||||
item.dataset.workflowRunId = String(runId).trim();
|
||||
}
|
||||
}
|
||||
if (type === 'tool_result' && options.data) {
|
||||
const d = options.data;
|
||||
item.dataset.toolName = (d.toolName != null && d.toolName !== '') ? String(d.toolName) : '';
|
||||
@@ -3382,8 +3800,35 @@ function addTimelineItem(timeline, type, options) {
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (type === 'eino_agent_reply' && options.message) {
|
||||
content += `<div class="timeline-item-content">${formatMarkdown(options.message, timelineMarkdownOpts)}</div>`;
|
||||
} else if ((type === 'eino_agent_reply' || type === 'workflow_agent_output') && options.message) {
|
||||
let prefix = '';
|
||||
if (type === 'workflow_agent_output' && options.data) {
|
||||
const source = options.data.inputSource || '';
|
||||
const preview = options.data.inputPreview || '';
|
||||
if (source || preview) {
|
||||
const previewText = String(preview || '').trim();
|
||||
const summaryPreview = previewText.length > 80 ? (previewText.slice(0, 80) + '...') : previewText;
|
||||
prefix = `<details class="workflow-agent-input">
|
||||
<summary>
|
||||
<span class="workflow-agent-io-label">输入</span>
|
||||
${source ? `<code>${escapeHtml(source)}</code>` : ''}
|
||||
${summaryPreview ? `<span class="workflow-agent-input-summary">${escapeHtml(summaryPreview)}</span>` : ''}
|
||||
</summary>
|
||||
${previewText ? `<pre>${escapeHtml(previewText)}</pre>` : '<div class="workflow-agent-empty">暂无输入预览</div>'}
|
||||
</details>`;
|
||||
}
|
||||
}
|
||||
const body = type === 'workflow_agent_output'
|
||||
? `<div class="workflow-agent-output">
|
||||
<div class="workflow-agent-io-label">输出</div>
|
||||
<div class="workflow-agent-output-body">${formatMarkdown(options.message, timelineMarkdownOpts)}</div>
|
||||
</div>`
|
||||
: formatMarkdown(options.message, timelineMarkdownOpts);
|
||||
content += `<div class="timeline-item-content workflow-agent-io">${prefix}${body}</div>`;
|
||||
} else if (type === 'workflow_node_result' && options.data && String(options.data.nodeType || '').toLowerCase() === 'condition') {
|
||||
content += buildWorkflowConditionResultHtml(options.data);
|
||||
} else if ((type === 'workflow_branch_taken' || type === 'workflow_branch_skipped') && options.data) {
|
||||
content += buildWorkflowBranchDetailHtml(options.data);
|
||||
} else if (type === 'tool_result' && options.data) {
|
||||
const data = options.data;
|
||||
const isError = data.isError || !data.success;
|
||||
@@ -3932,6 +4377,8 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
const maxVal = Math.max(1, ...points.map((p) => p.total || 0));
|
||||
const hasFailed = points.some((p) => (p.failed || 0) > 0);
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const barGap = points.length > 48 ? 1 : 2;
|
||||
const barW = Math.max(1.6, Math.min(8, (plotW / Math.max(1, points.length)) - barGap));
|
||||
|
||||
const coords = points.map((p, i) => {
|
||||
const x = padL + (points.length <= 1 ? plotW / 2 : (i / (points.length - 1)) * plotW);
|
||||
@@ -3988,6 +4435,21 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
data-failed="${c.p.failed || 0}" />`;
|
||||
}).join('');
|
||||
|
||||
const bars = coords.map((c) => {
|
||||
const total = c.p.total || 0;
|
||||
const failed = c.p.failed || 0;
|
||||
const h = total > 0 ? Math.max(3, (total / maxVal) * plotH) : 1;
|
||||
const y = baseY - h;
|
||||
const failedH = failed > 0 ? Math.max(2, (failed / maxVal) * plotH) : 0;
|
||||
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
|
||||
return `<g class="mcp-stats-timeline-bar-group">
|
||||
<rect class="mcp-stats-timeline-bar${total > 0 ? ' is-active' : ''}" x="${(c.x - barW / 2).toFixed(2)}" y="${y.toFixed(2)}" width="${barW.toFixed(2)}" height="${h.toFixed(2)}" rx="1.6"
|
||||
data-time="${escapeHtml(tipTime)}" data-total="${total}" data-failed="${failed}" />
|
||||
${failedH > 0 ? `<rect class="mcp-stats-timeline-bar-fail" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${failedH.toFixed(2)}" rx="1.6"
|
||||
data-time="${escapeHtml(tipTime)}" data-total="${total}" data-failed="${failed}" />` : ''}
|
||||
</g>`;
|
||||
}).join('');
|
||||
|
||||
const peakC = coords[peakIdx];
|
||||
const peakMarker = (peakC.p.total || 0) > 0
|
||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="5" />`
|
||||
@@ -4008,6 +4470,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
</defs>
|
||||
${yLines}
|
||||
<path class="mcp-stats-timeline-area" d="${areaPath}" fill="url(#mcpTimelineAreaFill)" />
|
||||
${bars}
|
||||
${peakMarker}
|
||||
<path class="mcp-stats-timeline-line" d="${linePath}" stroke="url(#mcpTimelineLineStroke)" />
|
||||
${hasFailed ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--fail" d="${failPath}" />` : ''}
|
||||
@@ -4039,14 +4502,17 @@ function bindMcpStatsTimelineEvents() {
|
||||
}
|
||||
|
||||
root.addEventListener('mousemove', function (e) {
|
||||
const dot = e.target.closest('.mcp-stats-timeline-dot');
|
||||
const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail');
|
||||
if (!dot || !mcpTimelineTooltipEl) {
|
||||
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
|
||||
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
|
||||
mcpTimelineTooltipEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
|
||||
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
|
||||
dot.classList.add('is-active');
|
||||
dot.classList.add('is-hover');
|
||||
const time = dot.getAttribute('data-time') || '';
|
||||
const total = dot.getAttribute('data-total') || '0';
|
||||
const failed = dot.getAttribute('data-failed') || '0';
|
||||
@@ -4062,6 +4528,7 @@ function bindMcpStatsTimelineEvents() {
|
||||
if (!e.target.closest || !e.target.closest('.mcp-stats-combined__timeline, .mcp-stats-timeline')) return;
|
||||
if (e.relatedTarget && root.contains(e.relatedTarget)) return;
|
||||
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
|
||||
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
|
||||
if (mcpTimelineTooltipEl) mcpTimelineTooltipEl.style.display = 'none';
|
||||
});
|
||||
|
||||
@@ -4123,6 +4590,37 @@ function buildTimelineSparseHint(points, timeline) {
|
||||
|| `该时段多数时间为 0,峰值 ${peak} 次出现在 ${peakTime}`;
|
||||
}
|
||||
|
||||
function renderMcpTimelineActiveMoments(points, rangeKey) {
|
||||
if (!Array.isArray(points) || points.length === 0) return '';
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const active = points
|
||||
.map((p, i) => ({ ...p, i }))
|
||||
.filter((p) => (p.total || 0) > 0)
|
||||
.sort((a, b) => (b.total || 0) - (a.total || 0) || b.i - a.i)
|
||||
const shown = active.slice(0, 4);
|
||||
const hiddenCount = Math.max(0, active.length - shown.length);
|
||||
if (!active.length) return '';
|
||||
const label = mcpMonitorT('timelineActiveMoments') || monitorFallback('活跃时段', 'Active moments');
|
||||
const moreLabel = mcpMonitorT('timelineMoreMoments', { n: hiddenCount }) || `+${hiddenCount}`;
|
||||
const chips = shown.map((p) => {
|
||||
const time = formatMcpTimelineLabel(p.t, rangeKey, locale);
|
||||
const failed = p.failed || 0;
|
||||
const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`;
|
||||
return `<span class="mcp-stats-timeline-moment" title="${escapeHtml(time)}">
|
||||
<span class="mcp-stats-timeline-moment__time">${escapeHtml(time)}</span>
|
||||
<span class="mcp-stats-timeline-moment__count">${p.total || 0}</span>
|
||||
${failed > 0 ? `<span class="mcp-stats-timeline-moment__fail">${escapeHtml(failedLabel)}</span>` : ''}
|
||||
</span>`;
|
||||
}).join('');
|
||||
const moreChip = hiddenCount > 0
|
||||
? `<span class="mcp-stats-timeline-moment mcp-stats-timeline-moment--more" title="${escapeHtml(mcpMonitorT('timelineMoreMomentsTitle', { n: hiddenCount }) || `还有 ${hiddenCount} 个活跃时段`)}">${escapeHtml(moreLabel)}</span>`
|
||||
: '';
|
||||
return `<div class="mcp-stats-timeline-moments">
|
||||
<span class="mcp-stats-timeline-moments__label">${escapeHtml(label)}</span>
|
||||
<div class="mcp-stats-timeline-moments__list">${chips}${moreChip}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function setMcpMonitorTimelineRange(range) {
|
||||
if (!MCP_TIMELINE_RANGES.includes(range)) return;
|
||||
localStorage.setItem('mcpMonitorTimelineRange', range);
|
||||
@@ -4198,12 +4696,14 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
|
||||
const failLegend = mcpMonitorT('timelineFailedLegend') || '失败';
|
||||
const hasFailed = points.some((p) => (p.failed || 0) > 0);
|
||||
const sparseHint = buildTimelineSparseHint(points, timeline);
|
||||
const momentsHtml = renderMcpTimelineActiveMoments(points, rangeKey);
|
||||
const sparseHtml = sparseHint
|
||||
? `<p class="mcp-stats-timeline__sparse-hint">${escapeHtml(sparseHint)}</p>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<p class="mcp-stats-timeline__inline-meta">${escapeHtml(hint)} · ${escapeHtml(summaryText)}</p>
|
||||
${momentsHtml}
|
||||
<div class="mcp-stats-timeline__chart-wrap">${chartSvg}</div>
|
||||
${sparseHtml}
|
||||
<div class="mcp-stats-timeline__legend">
|
||||
@@ -4849,10 +5349,6 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
const caption = mcpMonitorT('rankingSummary', { n: MCP_STATS_TOP_N, pct: topNSharePct, total: totals.total })
|
||||
|| `Top ${MCP_STATS_TOP_N} 占 ${topNSharePct}% · 共 ${totals.total} 次`;
|
||||
const unknownToolLabel = mcpMonitorT('unknownTool') || '未知工具';
|
||||
const colTool = mcpMonitorT('columnTool') || '工具';
|
||||
const colCalls = mcpMonitorT('columnCalls') || '调用';
|
||||
const colShare = mcpMonitorT('columnShare') || '占比';
|
||||
const colRate = mcpMonitorT('columnSuccessRate') || '成功率';
|
||||
const distAria = mcpMonitorT('distTitle') || '调用分布';
|
||||
|
||||
const stackedHtml = segments.map((s) => {
|
||||
@@ -4891,20 +5387,27 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
const failNote = failed > 0
|
||||
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
|
||||
: '';
|
||||
const successLabel = mcpMonitorT('successCount', { n: success }) || `成功 ${success}`;
|
||||
const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`;
|
||||
return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}"
|
||||
data-tool-name="${escapeHtml(rawName)}" tabindex="0" role="button"
|
||||
aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
|
||||
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
|
||||
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
|
||||
<div class="mcp-stats-tool-item__body">
|
||||
<div class="mcp-stats-tool-item__top">
|
||||
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
|
||||
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
|
||||
<span class="mcp-stats-tool-item__name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
<span class="mcp-stats-tool-item__share">${sharePct}%</span>
|
||||
</div>
|
||||
<div class="mcp-stats-tool-item__middle">
|
||||
<strong class="mcp-stats-tool-item__calls">${total}</strong>
|
||||
<span class="mcp-stats-tool-item__calls-label">${escapeHtml(mcpMonitorT('columnCalls') || '调用')}</span>
|
||||
<span class="mcp-stats-tool-item__track" aria-hidden="true">
|
||||
<span class="mcp-stats-tool-item__fill" style="width:${barPct}%;background:${color}"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mcp-stats-tool-item__metrics">
|
||||
<span class="mcp-stats-tool-item__share">${sharePct}%</span>
|
||||
<span class="mcp-stats-tool-item__calls">${total}</span>
|
||||
<div class="mcp-stats-tool-item__bottom">
|
||||
<span class="mcp-stats-tool-item__pill is-success">${escapeHtml(successLabel)}</span>
|
||||
<span class="mcp-stats-tool-item__pill${failed > 0 ? ' is-danger' : ''}">${escapeHtml(failedLabel)}</span>
|
||||
<span class="mcp-stats-tool-item__rate ${rateClass}">${toolRate}%${failNote}</span>
|
||||
</div>
|
||||
</li>`;
|
||||
@@ -4919,16 +5422,6 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
${escapeHtml(caption)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mcp-stats-tools-panel__list-head" aria-hidden="true">
|
||||
<span>#</span>
|
||||
<span></span>
|
||||
<span>${escapeHtml(colTool)}</span>
|
||||
<span class="mcp-stats-tool-item__metrics-head">
|
||||
<span>${escapeHtml(colShare)}</span>
|
||||
<span>${escapeHtml(colCalls)}</span>
|
||||
<span>${escapeHtml(colRate)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ol class="mcp-stats-tools-panel__list">${listHtml}</ol>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
+269
-70
@@ -173,6 +173,65 @@ function rebuildProjectNameMap(list) {
|
||||
});
|
||||
}
|
||||
|
||||
function rememberProjectsInNameMap(list) {
|
||||
(list || []).forEach((p) => {
|
||||
if (p && p.id) projectNameById[p.id] = p.name || p.id;
|
||||
});
|
||||
}
|
||||
|
||||
/** 与后端 projectListSearchPattern 对齐:name / description / id 子串匹配(忽略大小写) */
|
||||
function matchProjectSearchQuery(project, query) {
|
||||
const q = String(query || '').trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
const name = String(project.name || '').toLowerCase();
|
||||
const desc = String(project.description || '').toLowerCase();
|
||||
const id = String(project.id || '').toLowerCase();
|
||||
return name.includes(q) || desc.includes(q) || id.includes(q);
|
||||
}
|
||||
|
||||
function sortProjectsForPicker(projects) {
|
||||
return [...projects].sort((a, b) => {
|
||||
const ap = a.pinned ? 1 : 0;
|
||||
const bp = b.pinned ? 1 : 0;
|
||||
if (bp !== ap) return bp - ap;
|
||||
const au = a.updated_at || a.updatedAt || '';
|
||||
const bu = b.updated_at || b.updatedAt || '';
|
||||
return String(bu).localeCompare(String(au));
|
||||
});
|
||||
}
|
||||
|
||||
/** 从已加载列表中筛选活跃项目(对话选择器 / 项目筛选下拉) */
|
||||
function filterActiveProjectsLocal(projects, query) {
|
||||
const list = (projects || []).filter((p) => p && p.id && p.status !== 'archived');
|
||||
const q = String(query || '').trim();
|
||||
const filtered = q ? list.filter((p) => matchProjectSearchQuery(p, q)) : list;
|
||||
return sortProjectsForPicker(filtered);
|
||||
}
|
||||
|
||||
async function searchActiveProjects(query, opts = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('status', opts.status || 'active');
|
||||
params.set('limit', String(opts.limit ?? (String(query || '').trim() ? PROJECT_PICKER_SEARCH_LIMIT : PROJECT_PICKER_INITIAL_LIMIT)));
|
||||
params.set('offset', String(opts.offset ?? 0));
|
||||
const q = String(query || '').trim();
|
||||
if (q) params.set('search', q);
|
||||
const res = await apiFetch(`/api/projects?${params}`);
|
||||
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
|
||||
const parsed = parseProjectsListResponse(await res.json());
|
||||
rememberProjectsInNameMap(parsed.items);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function fetchProjectSummary(projectId) {
|
||||
const id = String(projectId || '').trim();
|
||||
if (!id) return null;
|
||||
const res = await apiFetch(`/api/projects/${encodeURIComponent(id)}`);
|
||||
if (!res.ok) return null;
|
||||
const project = await res.json();
|
||||
if (project && project.id) rememberProjectsInNameMap([project]);
|
||||
return project;
|
||||
}
|
||||
|
||||
function getProjectsListPageSize() {
|
||||
try {
|
||||
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
|
||||
@@ -308,7 +367,15 @@ async function ensureProjectsLoaded(force) {
|
||||
return _projectsFetchPromise;
|
||||
}
|
||||
|
||||
function isProjectsCacheReady() {
|
||||
return _projectsListReady;
|
||||
}
|
||||
|
||||
function prefetchProjectsForChat() {
|
||||
const id = (resolveChatProjectSelection() || '').trim();
|
||||
if (id && !projectNameById[id]) {
|
||||
fetchProjectSummary(id).catch(() => {});
|
||||
}
|
||||
ensureProjectsLoaded().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -655,9 +722,12 @@ function updateProjectStatusPill(status) {
|
||||
|
||||
function renderProjectDetailMeta(updatedAt) {
|
||||
const metaEl = document.getElementById('projects-detail-meta');
|
||||
if (!metaEl) return;
|
||||
const timeEl = document.getElementById('projects-detail-meta-time');
|
||||
if (!metaEl || !timeEl) return;
|
||||
const time = formatProjectTime(updatedAt);
|
||||
metaEl.textContent = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
||||
const full = tpFmt('projects.updatedPrefix', `Updated ${time}`, { time });
|
||||
timeEl.textContent = time;
|
||||
metaEl.title = full;
|
||||
}
|
||||
|
||||
function refreshProjectDetailMetaI18n() {
|
||||
@@ -2032,27 +2102,20 @@ function getChatProjectSelection() {
|
||||
return getActiveProjectId();
|
||||
}
|
||||
|
||||
function isActiveChatProjectId(id) {
|
||||
if (!id) return false;
|
||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||
return source.some((p) => p.id === id && p.status !== 'archived');
|
||||
}
|
||||
|
||||
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
|
||||
/** 用于 UI:返回当前选中的项目 ID(有效性由 normalizeStaleChatProjectSelection 异步校验) */
|
||||
function resolveChatProjectSelection() {
|
||||
const raw = getChatProjectSelection();
|
||||
if (!raw) return '';
|
||||
if (!_projectsListReady) return raw;
|
||||
return isActiveChatProjectId(raw) ? raw : '';
|
||||
return getChatProjectSelection() || '';
|
||||
}
|
||||
|
||||
let _normalizingStaleProject = false;
|
||||
|
||||
/** 项目列表加载后,清除 localStorage 或对话上残留的失效项目 ID */
|
||||
/** 清除 localStorage 或对话上残留的失效项目 ID */
|
||||
async function normalizeStaleChatProjectSelection() {
|
||||
if (!_projectsListReady || _normalizingStaleProject) return;
|
||||
const raw = getChatProjectSelection();
|
||||
if (!raw || isActiveChatProjectId(raw)) return;
|
||||
if (_normalizingStaleProject) return;
|
||||
const raw = (getChatProjectSelection() || '').trim();
|
||||
if (!raw) return;
|
||||
const project = await fetchProjectSummary(raw);
|
||||
if (project && project.id && project.status !== 'archived') return;
|
||||
|
||||
_normalizingStaleProject = true;
|
||||
try {
|
||||
@@ -2079,6 +2142,175 @@ async function normalizeStaleChatProjectSelection() {
|
||||
}
|
||||
}
|
||||
|
||||
const PROJECT_PICKER_DEBOUNCE_MS = 100;
|
||||
const projectPickerPanelState = {
|
||||
chat: { seq: 0, timer: null },
|
||||
webshell: { seq: 0, timer: null },
|
||||
};
|
||||
|
||||
function appendChatProjectPanelItem(list, project, selectedId, onSelect, tFn) {
|
||||
const t = tFn || tp;
|
||||
const isNone = !project.id;
|
||||
const isSelected = isNone ? !selectedId : selectedId === project.id;
|
||||
const fullDesc = isNone
|
||||
? (project.description || '')
|
||||
: (project.description || '').trim() || t('projects.sharedFactBoard');
|
||||
const desc = isNone
|
||||
? (project.description || '')
|
||||
: fullDesc.slice(0, 80);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||
btn.setAttribute('role', 'option');
|
||||
btn.setAttribute('data-selection-detail', fullDesc);
|
||||
btn.onclick = () => onSelect(project.id || '');
|
||||
btn.innerHTML = `
|
||||
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
|
||||
<div class="role-selection-item-content-main">
|
||||
<div class="role-selection-item-name-main">${escapeHtml(project.name || t('common.untitled'))}</div>
|
||||
<div class="role-selection-item-description-main">${escapeHtml(desc)}</div>
|
||||
</div>
|
||||
${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''}
|
||||
`;
|
||||
list.appendChild(btn);
|
||||
}
|
||||
|
||||
function appendChatProjectPanelMessage(list, className, text) {
|
||||
const el = document.createElement('div');
|
||||
el.className = className;
|
||||
el.textContent = text;
|
||||
list.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function pickerMessage(t, key, fallback) {
|
||||
const value = t(key);
|
||||
if (!value || value === key) return fallback;
|
||||
return value;
|
||||
}
|
||||
|
||||
async function renderProjectPickerPanel(panelKey, config) {
|
||||
const state = projectPickerPanelState[panelKey];
|
||||
const list = document.getElementById(config.listId);
|
||||
if (!list || !state) return;
|
||||
const query = (document.getElementById(config.searchInputId)?.value || '').trim();
|
||||
const seq = ++state.seq;
|
||||
const selectedId = config.getSelectedId();
|
||||
const t = config.t || tp;
|
||||
|
||||
const renderPinned = () => {
|
||||
appendChatProjectPanelItem(
|
||||
list,
|
||||
{
|
||||
id: '',
|
||||
name: t('projects.noProject'),
|
||||
description: t('projects.noProjectDescription'),
|
||||
},
|
||||
selectedId,
|
||||
config.onSelect,
|
||||
t
|
||||
);
|
||||
};
|
||||
|
||||
const needsFetch = !isProjectsCacheReady();
|
||||
let loadingEl = null;
|
||||
if (needsFetch) {
|
||||
list.innerHTML = '';
|
||||
renderPinned();
|
||||
loadingEl = appendChatProjectPanelMessage(
|
||||
list,
|
||||
'chat-project-panel-loading',
|
||||
pickerMessage(t, 'common.loading', '加载中…')
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const all = await ensureProjectsLoaded();
|
||||
if (seq !== state.seq) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
renderPinned();
|
||||
const projects = filterActiveProjectsLocal(all, query);
|
||||
projects.forEach((p) => {
|
||||
appendChatProjectPanelItem(list, p, selectedId, config.onSelect, t);
|
||||
});
|
||||
|
||||
if (query && projects.length === 0) {
|
||||
appendChatProjectPanelMessage(
|
||||
list,
|
||||
'chat-project-panel-empty',
|
||||
pickerMessage(t, 'chat.filterProjectSearchEmpty', '没有匹配的项目')
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== state.seq) return;
|
||||
list.innerHTML = '';
|
||||
renderPinned();
|
||||
appendChatProjectPanelMessage(
|
||||
list,
|
||||
'chat-project-panel-empty',
|
||||
pickerMessage(t, 'chat.filterProjectSearchFailed', '加载项目失败,请重试')
|
||||
);
|
||||
} finally {
|
||||
if (loadingEl && loadingEl.parentNode) loadingEl.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function initProjectPickerPanelSearch(panelKey, searchInputId, onSearch) {
|
||||
const input = document.getElementById(searchInputId);
|
||||
if (!input || input.dataset.pickerBound === panelKey) return;
|
||||
input.dataset.pickerBound = panelKey;
|
||||
input.addEventListener('input', onSearch);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (panelKey === 'chat' && typeof closeChatProjectPanel === 'function') {
|
||||
closeChatProjectPanel();
|
||||
} else if (panelKey === 'webshell' && typeof wsCloseProjectPanel === 'function') {
|
||||
wsCloseProjectPanel();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearProjectPickerPanelSearch(panelKey, searchInputId) {
|
||||
const state = projectPickerPanelState[panelKey];
|
||||
if (!state) return;
|
||||
state.seq += 1;
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
const input = document.getElementById(searchInputId);
|
||||
if (input) input.value = '';
|
||||
}
|
||||
|
||||
function scheduleProjectPickerPanelSearch(panelKey, loadFn) {
|
||||
const state = projectPickerPanelState[panelKey];
|
||||
if (!state) return;
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = null;
|
||||
loadFn();
|
||||
}, PROJECT_PICKER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function loadChatProjectPanelList() {
|
||||
await renderProjectPickerPanel('chat', {
|
||||
listId: 'chat-project-list',
|
||||
searchInputId: 'chat-project-search',
|
||||
getSelectedId: resolveChatProjectSelection,
|
||||
onSelect: (projectId) => selectChatProject(projectId),
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureChatProjectButtonLabel() {
|
||||
const id = (resolveChatProjectSelection() || '').trim();
|
||||
if (id && !projectNameById[id]) {
|
||||
await fetchProjectSummary(id);
|
||||
}
|
||||
updateChatProjectButtonLabel();
|
||||
}
|
||||
|
||||
function updateChatProjectButtonLabel() {
|
||||
const textEl = document.getElementById('chat-project-text');
|
||||
if (!textEl) return;
|
||||
@@ -2086,56 +2318,13 @@ function updateChatProjectButtonLabel() {
|
||||
textEl.textContent = id && projectNameById[id] ? projectNameById[id] : tp('projects.noProject');
|
||||
}
|
||||
|
||||
function renderChatProjectPanelList() {
|
||||
const list = document.getElementById('chat-project-list');
|
||||
if (!list) return;
|
||||
const selected = resolveChatProjectSelection();
|
||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||
const activeProjects = source.filter((p) => p.status !== 'archived');
|
||||
const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects];
|
||||
if (!items.length) {
|
||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = '';
|
||||
items.forEach((p) => {
|
||||
const isNone = !p.id;
|
||||
const isSelected = isNone ? !selected : selected === p.id;
|
||||
const desc = isNone
|
||||
? (p.description || '')
|
||||
: (p.description || '').trim().slice(0, 80) || tp('projects.sharedFactBoard');
|
||||
const projectId = p.id || '';
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||
btn.setAttribute('role', 'option');
|
||||
btn.onclick = () => {
|
||||
selectChatProject(projectId);
|
||||
};
|
||||
btn.innerHTML = `
|
||||
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
|
||||
<div class="role-selection-item-content-main">
|
||||
<div class="role-selection-item-name-main">${escapeHtml(p.name || tp('common.untitled'))}</div>
|
||||
<div class="role-selection-item-description-main">${escapeHtml(desc)}</div>
|
||||
</div>
|
||||
${isSelected ? '<div class="role-selection-checkmark-main">✓</div>' : ''}
|
||||
`;
|
||||
list.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderChatProjectPanel() {
|
||||
const list = document.getElementById('chat-project-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = `<div class="chat-project-panel-loading">${escapeHtml(tp('common.loading'))}</div>`;
|
||||
try {
|
||||
await ensureProjectsLoaded();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.loadFailedRetry'))}</div>`;
|
||||
return;
|
||||
}
|
||||
renderChatProjectPanelList();
|
||||
initProjectPickerPanelSearch('chat', 'chat-project-search', () => {
|
||||
scheduleProjectPickerPanelSearch('chat', () => loadChatProjectPanelList());
|
||||
});
|
||||
clearProjectPickerPanelSearch('chat', 'chat-project-search');
|
||||
await loadChatProjectPanelList();
|
||||
requestAnimationFrame(() => document.getElementById('chat-project-search')?.focus());
|
||||
}
|
||||
|
||||
function closeChatProjectPanel() {
|
||||
@@ -2146,6 +2335,7 @@ function closeChatProjectPanel() {
|
||||
btn.classList.remove('active');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
clearProjectPickerPanelSearch('chat', 'chat-project-search');
|
||||
}
|
||||
|
||||
async function toggleChatProjectPanel() {
|
||||
@@ -2213,15 +2403,14 @@ async function applyChatProjectSelection(projectId) {
|
||||
async function refreshChatProjectSelector() {
|
||||
if (!document.getElementById('chat-project-btn')) return;
|
||||
try {
|
||||
await ensureProjectsLoaded();
|
||||
await normalizeStaleChatProjectSelection();
|
||||
await ensureChatProjectButtonLabel();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
updateChatProjectButtonLabel();
|
||||
const panel = document.getElementById('chat-project-panel');
|
||||
if (panel && panel.style.display === 'flex') {
|
||||
renderChatProjectPanelList();
|
||||
await loadChatProjectPanelList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2240,7 +2429,7 @@ function initChatProjectSelector() {
|
||||
renderProjectsPagination();
|
||||
updateChatProjectButtonLabel();
|
||||
const panel = document.getElementById('chat-project-panel');
|
||||
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
||||
if (panel && panel.style.display === 'flex') loadChatProjectPanelList();
|
||||
if (currentProjectId) {
|
||||
refreshProjectDetailMetaI18n();
|
||||
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||
@@ -2298,6 +2487,11 @@ window.onChatProjectChange = onChatProjectChange;
|
||||
window.toggleChatProjectPanel = toggleChatProjectPanel;
|
||||
window.closeChatProjectPanel = closeChatProjectPanel;
|
||||
window.selectChatProject = selectChatProject;
|
||||
window.renderProjectPickerPanel = renderProjectPickerPanel;
|
||||
window.initProjectPickerPanelSearch = initProjectPickerPanelSearch;
|
||||
window.clearProjectPickerPanelSearch = clearProjectPickerPanelSearch;
|
||||
window.scheduleProjectPickerPanelSearch = scheduleProjectPickerPanelSearch;
|
||||
window.loadChatProjectPanelList = loadChatProjectPanelList;
|
||||
window.prefetchProjectsForChat = prefetchProjectsForChat;
|
||||
window.ensureDefaultActiveProjectForNewChat = ensureDefaultActiveProjectForNewChat;
|
||||
window.getActiveProjectId = getActiveProjectId;
|
||||
@@ -2334,5 +2528,10 @@ window.deleteProjectFactEdge = deleteProjectFactEdge;
|
||||
window.focusProjectFactGraphEdge = focusProjectFactGraphEdge;
|
||||
window.toggleProjectFactGraphConnectMode = toggleProjectFactGraphConnectMode;
|
||||
window.rebuildProjectNameMap = rebuildProjectNameMap;
|
||||
window.rememberProjectsInNameMap = rememberProjectsInNameMap;
|
||||
window.searchActiveProjects = searchActiveProjects;
|
||||
window.filterActiveProjectsLocal = filterActiveProjectsLocal;
|
||||
window.fetchProjectSummary = fetchProjectSummary;
|
||||
window.projectNameById = projectNameById;
|
||||
window.ensureProjectsLoaded = ensureProjectsLoaded;
|
||||
window.isProjectsCacheReady = isProjectsCacheReady;
|
||||
|
||||
+104
-1
@@ -24,6 +24,79 @@ function rolePlainDescription(role) {
|
||||
if (raw === 'roles.noDescription' || raw === 'roles.noDescriptionShort') return '';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function initSelectionDetailTooltip() {
|
||||
if (window.__selectionDetailTooltipReady) return;
|
||||
window.__selectionDetailTooltipReady = true;
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'selection-detail-tooltip';
|
||||
tooltip.setAttribute('role', 'tooltip');
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
let activeEl = null;
|
||||
|
||||
function hideTooltip() {
|
||||
activeEl = null;
|
||||
tooltip.classList.remove('visible', 'placement-left');
|
||||
}
|
||||
|
||||
function positionTooltip(el) {
|
||||
const text = el && el.getAttribute('data-selection-detail');
|
||||
if (!text) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
activeEl = el;
|
||||
tooltip.textContent = text;
|
||||
tooltip.classList.add('visible');
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const tipRect = tooltip.getBoundingClientRect();
|
||||
const gap = 12;
|
||||
const margin = 12;
|
||||
const rightSpace = window.innerWidth - rect.right - gap - margin;
|
||||
const placeLeft = rightSpace < tipRect.width && rect.left > tipRect.width + gap + margin;
|
||||
const left = placeLeft ? rect.left - tipRect.width - gap : rect.right + gap;
|
||||
const top = Math.max(margin, Math.min(rect.top + rect.height / 2 - tipRect.height / 2, window.innerHeight - tipRect.height - margin));
|
||||
|
||||
tooltip.classList.toggle('placement-left', placeLeft);
|
||||
tooltip.style.left = left + 'px';
|
||||
tooltip.style.top = top + 'px';
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', function (event) {
|
||||
const el = event.target && event.target.closest && event.target.closest('[data-selection-detail]');
|
||||
if (!el || (event.relatedTarget && el.contains(event.relatedTarget))) return;
|
||||
positionTooltip(el);
|
||||
});
|
||||
document.addEventListener('mouseout', function (event) {
|
||||
const el = event.target && event.target.closest && event.target.closest('[data-selection-detail]');
|
||||
if (!el || (event.relatedTarget && el.contains(event.relatedTarget))) return;
|
||||
hideTooltip();
|
||||
});
|
||||
document.addEventListener('focusin', function (event) {
|
||||
const el = event.target && event.target.closest && event.target.closest('[data-selection-detail]');
|
||||
if (el) positionTooltip(el);
|
||||
});
|
||||
document.addEventListener('focusout', function (event) {
|
||||
const el = event.target && event.target.closest && event.target.closest('[data-selection-detail]');
|
||||
if (el) hideTooltip();
|
||||
});
|
||||
window.addEventListener('scroll', hideTooltip, true);
|
||||
window.addEventListener('resize', function () {
|
||||
if (activeEl) positionTooltip(activeEl);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initSelectionDetailTooltip);
|
||||
} else {
|
||||
initSelectionDetailTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
let currentRole = localStorage.getItem('currentRole') || '';
|
||||
let roles = [];
|
||||
let rolesSearchKeyword = ''; // 角色搜索关键词
|
||||
@@ -209,10 +282,18 @@ function renderRoleSelectionSidebar() {
|
||||
const isSelected = isDefaultRole ? (currentRole === '' || currentRole === '默认') : (currentRole === role.name);
|
||||
const roleItem = document.createElement('div');
|
||||
roleItem.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||
roleItem.setAttribute('role', 'option');
|
||||
roleItem.tabIndex = 0;
|
||||
roleItem.onclick = () => {
|
||||
selectRole(role.name);
|
||||
closeRoleSelectionPanel(); // 选择后自动关闭面板
|
||||
};
|
||||
roleItem.onkeydown = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
roleItem.click();
|
||||
}
|
||||
};
|
||||
const icon = getRoleIcon(role);
|
||||
|
||||
// 处理默认角色的描述
|
||||
@@ -221,6 +302,7 @@ function renderRoleSelectionSidebar() {
|
||||
if (isDefaultRole && !plainDesc) {
|
||||
description = _t('roles.defaultRoleDescription');
|
||||
}
|
||||
roleItem.setAttribute('data-selection-detail', description);
|
||||
|
||||
roleItem.innerHTML = `
|
||||
<div class="role-selection-item-icon-main">${icon}</div>
|
||||
@@ -1058,6 +1140,13 @@ async function showAddRoleModal() {
|
||||
document.getElementById('role-icon').value = '';
|
||||
document.getElementById('role-user-prompt').value = '';
|
||||
document.getElementById('role-enabled').checked = true;
|
||||
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
|
||||
await loadWorkflowOptionsForRoleModal('');
|
||||
}
|
||||
const workflowPolicy = document.getElementById('role-workflow-policy');
|
||||
if (workflowPolicy) {
|
||||
workflowPolicy.value = 'auto';
|
||||
}
|
||||
|
||||
// 添加角色时:显示工具选择界面,隐藏默认角色提示
|
||||
const toolsSection = document.getElementById('role-tools-section');
|
||||
@@ -1144,6 +1233,13 @@ async function editRole(roleName) {
|
||||
document.getElementById('role-icon').value = iconValue;
|
||||
document.getElementById('role-user-prompt').value = role.user_prompt || '';
|
||||
document.getElementById('role-enabled').checked = role.enabled !== false;
|
||||
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
|
||||
await loadWorkflowOptionsForRoleModal(role.workflow_id || '');
|
||||
}
|
||||
const workflowPolicy = document.getElementById('role-workflow-policy');
|
||||
if (workflowPolicy) {
|
||||
workflowPolicy.value = role.workflow_policy || 'auto';
|
||||
}
|
||||
|
||||
// 检查是否为默认角色
|
||||
const isDefaultRole = roleName === '默认';
|
||||
@@ -1398,6 +1494,10 @@ async function saveRole() {
|
||||
}
|
||||
const userPrompt = document.getElementById('role-user-prompt').value.trim();
|
||||
const enabled = document.getElementById('role-enabled').checked;
|
||||
const workflowIdEl = document.getElementById('role-workflow-id');
|
||||
const workflowPolicyEl = document.getElementById('role-workflow-policy');
|
||||
const workflowId = workflowIdEl ? workflowIdEl.value.trim() : '';
|
||||
const workflowPolicy = workflowPolicyEl ? workflowPolicyEl.value.trim() : 'auto';
|
||||
|
||||
const isEdit = document.getElementById('role-name').disabled;
|
||||
|
||||
@@ -1504,7 +1604,10 @@ async function saveRole() {
|
||||
icon: icon || undefined, // 如果为空字符串,则不发送该字段
|
||||
user_prompt: userPrompt,
|
||||
tools: tools, // 默认角色为空数组,表示使用所有工具
|
||||
enabled: enabled
|
||||
enabled: enabled,
|
||||
workflow_id: workflowId || undefined,
|
||||
workflow_version: workflowId ? 'latest' : undefined,
|
||||
workflow_policy: workflowId ? (workflowPolicy || 'auto') : undefined
|
||||
};
|
||||
const url = isEdit ? `/api/roles/${encodeURIComponent(name)}` : '/api/roles';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
@@ -58,7 +58,7 @@ function initRouter() {
|
||||
const hashParts = hash.split('?');
|
||||
let pageId = hashParts[0];
|
||||
if (pageId === 'c2') pageId = 'c2-listeners';
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'workflows', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(500);
|
||||
@@ -449,6 +449,11 @@ async function initPage(pageId) {
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'workflows':
|
||||
if (typeof refreshWorkflows === 'function') {
|
||||
refreshWorkflows();
|
||||
}
|
||||
break;
|
||||
case 'skills-monitor':
|
||||
// 初始化Skills状态监控页面
|
||||
if (typeof loadSkillsMonitor === 'function') {
|
||||
@@ -510,7 +515,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let pageId = hashParts[0];
|
||||
|
||||
if (pageId === 'c2') pageId = 'c2-listeners';
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'tasks', 'workflows', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
if (pageId === 'chat') {
|
||||
scheduleChatConversationFromHash(200);
|
||||
@@ -569,4 +574,3 @@ function initConversationSidebarState() {
|
||||
|
||||
// 导出函数供其他脚本使用(与上方尽早绑定保持一致,便于外部脚本探测)
|
||||
window.currentPage = function() { return currentPage; };
|
||||
|
||||
|
||||
+253
-2
@@ -6,6 +6,156 @@ let alwaysVisibleBuiltinToolNames = new Set();
|
||||
// 全局工具状态映射,用于保存用户在所有页面的修改
|
||||
// key: 唯一工具标识符(toolKey),value: { enabled: boolean, is_external: boolean, external_mcp: string }
|
||||
let toolStateMap = new Map();
|
||||
let activeRobotEditor = '';
|
||||
|
||||
function settingsT(key, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
const translated = window.t(key);
|
||||
if (translated && translated !== key) return translated;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getRobotStatus(type) {
|
||||
const value = (id) => document.getElementById(id)?.value?.trim() || '';
|
||||
const checked = (id) => document.getElementById(id)?.checked === true;
|
||||
let configured = false;
|
||||
let enabled = false;
|
||||
|
||||
if (type === 'wechat') {
|
||||
configured = !!value('robot-wechat-ilink-bot-id');
|
||||
enabled = checked('robot-wechat-enabled');
|
||||
} else if (type === 'wecom') {
|
||||
const agentId = parseInt(value('robot-wecom-agent-id'), 10);
|
||||
configured = !!(value('robot-wecom-token') && value('robot-wecom-corp-id') && value('robot-wecom-secret') && agentId > 0);
|
||||
enabled = checked('robot-wecom-enabled');
|
||||
} else if (type === 'dingtalk') {
|
||||
configured = !!(value('robot-dingtalk-client-id') && value('robot-dingtalk-client-secret'));
|
||||
enabled = checked('robot-dingtalk-enabled');
|
||||
} else if (type === 'lark') {
|
||||
configured = !!(value('robot-lark-app-id') && value('robot-lark-app-secret'));
|
||||
enabled = checked('robot-lark-enabled');
|
||||
} else if (type === 'telegram') {
|
||||
configured = !!value('robot-telegram-bot-token');
|
||||
enabled = checked('robot-telegram-enabled');
|
||||
} else if (type === 'slack') {
|
||||
configured = !!(value('robot-slack-bot-token') && value('robot-slack-app-token'));
|
||||
enabled = checked('robot-slack-enabled');
|
||||
} else if (type === 'discord') {
|
||||
configured = !!value('robot-discord-bot-token');
|
||||
enabled = checked('robot-discord-enabled');
|
||||
} else if (type === 'qq') {
|
||||
configured = !!(value('robot-qq-app-id') && value('robot-qq-client-secret'));
|
||||
enabled = checked('robot-qq-enabled');
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
return { state: 'enabled', text: settingsT('settings.robots.statusEnabled', '已启用') };
|
||||
}
|
||||
if (configured) {
|
||||
return { state: 'ready', text: settingsT('settings.robots.statusConfigured', '已配置') };
|
||||
}
|
||||
return { state: 'idle', text: settingsT('settings.robots.statusNotConfigured', '未配置') };
|
||||
}
|
||||
|
||||
function refreshRobotManager() {
|
||||
['wechat', 'wecom', 'dingtalk', 'lark', 'telegram', 'slack', 'discord', 'qq'].forEach((type) => {
|
||||
const status = getRobotStatus(type);
|
||||
const pill = document.getElementById(`robot-card-${type}-status`);
|
||||
if (pill) {
|
||||
pill.className = `robot-status-pill robot-status-pill--${status.state}`;
|
||||
pill.textContent = status.text;
|
||||
}
|
||||
const card = document.querySelector(`[data-robot-card="${type}"]`);
|
||||
if (card) {
|
||||
card.classList.toggle('is-active', activeRobotEditor === type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openRobotEditor(type) {
|
||||
activeRobotEditor = type;
|
||||
const empty = document.getElementById('robot-editor-empty');
|
||||
if (empty) empty.hidden = true;
|
||||
document.querySelectorAll('[data-robot-editor]').forEach((panel) => {
|
||||
panel.hidden = panel.dataset.robotEditor !== type;
|
||||
});
|
||||
refreshRobotManager();
|
||||
const panel = document.querySelector(`[data-robot-editor="${type}"]`);
|
||||
if (panel) {
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
|
||||
function openRobotCreateModal() {
|
||||
const modal = document.getElementById('robot-create-modal');
|
||||
if (modal) modal.style.display = 'block';
|
||||
}
|
||||
|
||||
function closeRobotCreateModal() {
|
||||
const modal = document.getElementById('robot-create-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function openRobotCommandsModal() {
|
||||
if (typeof openAppModal === 'function') {
|
||||
openAppModal('robot-commands-modal', { focus: false });
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('robot-commands-modal');
|
||||
if (modal) modal.style.display = 'block';
|
||||
}
|
||||
|
||||
function closeRobotCommandsModal() {
|
||||
if (typeof closeAppModal === 'function') {
|
||||
closeAppModal('robot-commands-modal');
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('robot-commands-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function selectRobotType(type) {
|
||||
closeRobotCreateModal();
|
||||
openRobotEditor(type);
|
||||
}
|
||||
|
||||
function bindRobotManagerEvents() {
|
||||
const robotInputIds = [
|
||||
'robot-wechat-enabled', 'robot-wechat-ilink-bot-id',
|
||||
'robot-wecom-enabled', 'robot-wecom-token', 'robot-wecom-corp-id', 'robot-wecom-secret', 'robot-wecom-agent-id',
|
||||
'robot-dingtalk-enabled', 'robot-dingtalk-client-id', 'robot-dingtalk-client-secret',
|
||||
'robot-lark-enabled', 'robot-lark-app-id', 'robot-lark-app-secret',
|
||||
'robot-telegram-enabled', 'robot-telegram-bot-token', 'robot-telegram-bot-username', 'robot-telegram-allow-group',
|
||||
'robot-slack-enabled', 'robot-slack-bot-token', 'robot-slack-app-token',
|
||||
'robot-discord-enabled', 'robot-discord-bot-token', 'robot-discord-allow-guild',
|
||||
'robot-qq-enabled', 'robot-qq-app-id', 'robot-qq-client-secret', 'robot-qq-sandbox'
|
||||
];
|
||||
robotInputIds.forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && !el.dataset.robotManagerBound) {
|
||||
el.addEventListener('input', refreshRobotManager);
|
||||
el.addEventListener('change', refreshRobotManager);
|
||||
el.dataset.robotManagerBound = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
const modal = document.getElementById('robot-create-modal');
|
||||
if (modal && !modal.dataset.robotManagerBound) {
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) closeRobotCreateModal();
|
||||
});
|
||||
modal.dataset.robotManagerBound = 'true';
|
||||
}
|
||||
|
||||
const commandsModal = document.getElementById('robot-commands-modal');
|
||||
if (commandsModal && !commandsModal.dataset.robotManagerBound) {
|
||||
commandsModal.addEventListener('click', (event) => {
|
||||
if (event.target === commandsModal) closeRobotCommandsModal();
|
||||
});
|
||||
commandsModal.dataset.robotManagerBound = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// 生成工具的唯一标识符,用于区分同名但来源不同的工具
|
||||
function getToolKey(tool) {
|
||||
@@ -389,10 +539,35 @@ async function loadConfig(loadTools = true) {
|
||||
subIdxFilterInput.value = knowledge.retrieval?.sub_index_filter || '';
|
||||
}
|
||||
|
||||
const mq = knowledge.retrieval?.multi_query || {};
|
||||
const mqMaxInput = document.getElementById('knowledge-multi-query-max-queries');
|
||||
if (mqMaxInput) {
|
||||
const mqVal = parseInt(mq.max_queries, 10);
|
||||
mqMaxInput.value = (!isNaN(mqVal) && mqVal > 0) ? mqVal : 4;
|
||||
}
|
||||
const rr = knowledge.retrieval?.rerank || {};
|
||||
const rerankProviderSelect = document.getElementById('knowledge-rerank-provider');
|
||||
if (rerankProviderSelect) {
|
||||
const p = (rr.provider || '').toLowerCase();
|
||||
rerankProviderSelect.value = (p === 'dashscope' || p === 'cohere') ? p : '';
|
||||
}
|
||||
const rerankModelInput = document.getElementById('knowledge-rerank-model');
|
||||
if (rerankModelInput) {
|
||||
rerankModelInput.value = rr.model || '';
|
||||
}
|
||||
const rerankBaseUrlInput = document.getElementById('knowledge-rerank-base-url');
|
||||
if (rerankBaseUrlInput) {
|
||||
rerankBaseUrlInput.value = rr.base_url || '';
|
||||
}
|
||||
const rerankApiKeyInput = document.getElementById('knowledge-rerank-api-key');
|
||||
if (rerankApiKeyInput) {
|
||||
rerankApiKeyInput.value = rr.api_key || '';
|
||||
}
|
||||
|
||||
const post = knowledge.retrieval?.post_retrieve || {};
|
||||
const prefetchInput = document.getElementById('knowledge-post-retrieve-prefetch-top-k');
|
||||
if (prefetchInput) {
|
||||
prefetchInput.value = post.prefetch_top_k ?? 0;
|
||||
prefetchInput.value = post.prefetch_top_k ?? 20;
|
||||
}
|
||||
const maxCharsInput = document.getElementById('knowledge-post-retrieve-max-chars');
|
||||
if (maxCharsInput) {
|
||||
@@ -475,6 +650,10 @@ async function loadConfig(loadTools = true) {
|
||||
const wecom = robots.wecom || {};
|
||||
const dingtalk = robots.dingtalk || {};
|
||||
const lark = robots.lark || {};
|
||||
const telegram = robots.telegram || {};
|
||||
const slack = robots.slack || {};
|
||||
const discord = robots.discord || {};
|
||||
const qq = robots.qq || {};
|
||||
const wechatEnabled = document.getElementById('robot-wechat-enabled');
|
||||
if (wechatEnabled) wechatEnabled.checked = wechat.enabled === true;
|
||||
const wechatBase = document.getElementById('robot-wechat-base-url');
|
||||
@@ -514,6 +693,36 @@ async function loadConfig(loadTools = true) {
|
||||
if (larkAppSecret) larkAppSecret.value = lark.app_secret || '';
|
||||
const larkVerify = document.getElementById('robot-lark-verify-token');
|
||||
if (larkVerify) larkVerify.value = lark.verify_token || '';
|
||||
const telegramEnabled = document.getElementById('robot-telegram-enabled');
|
||||
if (telegramEnabled) telegramEnabled.checked = telegram.enabled === true;
|
||||
const telegramToken = document.getElementById('robot-telegram-bot-token');
|
||||
if (telegramToken) telegramToken.value = telegram.bot_token || '';
|
||||
const telegramUsername = document.getElementById('robot-telegram-bot-username');
|
||||
if (telegramUsername) telegramUsername.value = telegram.bot_username || '';
|
||||
const telegramAllowGroup = document.getElementById('robot-telegram-allow-group');
|
||||
if (telegramAllowGroup) telegramAllowGroup.checked = telegram.allow_group_messages === true;
|
||||
const slackEnabled = document.getElementById('robot-slack-enabled');
|
||||
if (slackEnabled) slackEnabled.checked = slack.enabled === true;
|
||||
const slackBotToken = document.getElementById('robot-slack-bot-token');
|
||||
if (slackBotToken) slackBotToken.value = slack.bot_token || '';
|
||||
const slackAppToken = document.getElementById('robot-slack-app-token');
|
||||
if (slackAppToken) slackAppToken.value = slack.app_token || '';
|
||||
const discordEnabled = document.getElementById('robot-discord-enabled');
|
||||
if (discordEnabled) discordEnabled.checked = discord.enabled === true;
|
||||
const discordToken = document.getElementById('robot-discord-bot-token');
|
||||
if (discordToken) discordToken.value = discord.bot_token || '';
|
||||
const discordAllowGuild = document.getElementById('robot-discord-allow-guild');
|
||||
if (discordAllowGuild) discordAllowGuild.checked = discord.allow_guild_messages === true;
|
||||
const qqEnabled = document.getElementById('robot-qq-enabled');
|
||||
if (qqEnabled) qqEnabled.checked = qq.enabled === true;
|
||||
const qqAppId = document.getElementById('robot-qq-app-id');
|
||||
if (qqAppId) qqAppId.value = qq.app_id || '';
|
||||
const qqSecret = document.getElementById('robot-qq-client-secret');
|
||||
if (qqSecret) qqSecret.value = qq.client_secret || '';
|
||||
const qqSandbox = document.getElementById('robot-qq-sandbox');
|
||||
if (qqSandbox) qqSandbox.checked = qq.sandbox === true;
|
||||
bindRobotManagerEvents();
|
||||
refreshRobotManager();
|
||||
|
||||
// 只有在需要时才加载工具列表(MCP管理页面需要,系统设置页面不需要)
|
||||
if (loadTools) {
|
||||
@@ -1273,8 +1482,25 @@ async function applySettings() {
|
||||
return isNaN(val) ? 0.7 : val;
|
||||
})(),
|
||||
sub_index_filter: document.getElementById('knowledge-retrieval-sub-index-filter')?.value?.trim() || '',
|
||||
multi_query: {
|
||||
max_queries: (() => {
|
||||
const v = parseInt(document.getElementById('knowledge-multi-query-max-queries')?.value, 10);
|
||||
if (isNaN(v) || v <= 0) return 4;
|
||||
return Math.min(8, v);
|
||||
})()
|
||||
},
|
||||
rerank: {
|
||||
provider: document.getElementById('knowledge-rerank-provider')?.value?.trim() || '',
|
||||
model: document.getElementById('knowledge-rerank-model')?.value?.trim() || '',
|
||||
base_url: document.getElementById('knowledge-rerank-base-url')?.value?.trim() || '',
|
||||
api_key: document.getElementById('knowledge-rerank-api-key')?.value?.trim() || ''
|
||||
},
|
||||
post_retrieve: {
|
||||
prefetch_top_k: parseInt(document.getElementById('knowledge-post-retrieve-prefetch-top-k')?.value, 10) || 0,
|
||||
prefetch_top_k: (() => {
|
||||
const raw = document.getElementById('knowledge-post-retrieve-prefetch-top-k')?.value;
|
||||
const v = parseInt(raw, 10);
|
||||
return isNaN(v) ? 20 : Math.max(0, v);
|
||||
})(),
|
||||
max_context_chars: parseInt(document.getElementById('knowledge-post-retrieve-max-chars')?.value, 10) || 0,
|
||||
max_context_tokens: parseInt(document.getElementById('knowledge-post-retrieve-max-tokens')?.value, 10) || 0
|
||||
}
|
||||
@@ -1383,6 +1609,31 @@ async function applySettings() {
|
||||
app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '',
|
||||
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '',
|
||||
allow_chat_id_fallback: !!(prevRobots.lark && prevRobots.lark.allow_chat_id_fallback)
|
||||
},
|
||||
telegram: {
|
||||
enabled: document.getElementById('robot-telegram-enabled')?.checked === true,
|
||||
bot_token: document.getElementById('robot-telegram-bot-token')?.value.trim() || '',
|
||||
bot_username: document.getElementById('robot-telegram-bot-username')?.value.trim() || '',
|
||||
allow_group_messages: document.getElementById('robot-telegram-allow-group')?.checked === true,
|
||||
...(prevRobots.telegram && typeof prevRobots.telegram === 'object' ? {
|
||||
update_offset: prevRobots.telegram.update_offset || 0
|
||||
} : {})
|
||||
},
|
||||
slack: {
|
||||
enabled: document.getElementById('robot-slack-enabled')?.checked === true,
|
||||
bot_token: document.getElementById('robot-slack-bot-token')?.value.trim() || '',
|
||||
app_token: document.getElementById('robot-slack-app-token')?.value.trim() || ''
|
||||
},
|
||||
discord: {
|
||||
enabled: document.getElementById('robot-discord-enabled')?.checked === true,
|
||||
bot_token: document.getElementById('robot-discord-bot-token')?.value.trim() || '',
|
||||
allow_guild_messages: document.getElementById('robot-discord-allow-guild')?.checked === true
|
||||
},
|
||||
qq: {
|
||||
enabled: document.getElementById('robot-qq-enabled')?.checked === true,
|
||||
app_id: document.getElementById('robot-qq-app-id')?.value.trim() || '',
|
||||
client_secret: document.getElementById('robot-qq-client-secret')?.value.trim() || '',
|
||||
sandbox: document.getElementById('robot-qq-sandbox')?.checked === true
|
||||
}
|
||||
},
|
||||
tools: []
|
||||
|
||||
@@ -650,7 +650,7 @@ async function viewSkill(skillId) {
|
||||
<div style="margin-bottom: 8px;"><strong>${escapeHtml(pathLabel)}</strong> ${escapeHtml(sumSkill.path || '')}</div>
|
||||
<div style="margin-bottom: 16px;"><strong>${escapeHtml(modTimeLabel)}</strong> ${escapeHtml(sumSkill.mod_time || '')}</div>
|
||||
<div style="margin-bottom: 8px;"><strong>${escapeHtml(contentLabel)}</strong> <span style="opacity:0.8;font-size:12px;">${escapeHtml(_t('skills.summaryHint'))}</span></div>
|
||||
<pre id="skill-view-body" style="background: #f5f5f5; padding: 16px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word;">${escapeHtml(sumSkill.content || '')}</pre>
|
||||
<pre id="skill-view-body" style="padding: 16px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; border: 1px solid var(--border-color);">${escapeHtml(sumSkill.content || '')}</pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" data-skill-load-full>${escapeHtml(loadFullLabel)}</button>
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const STORAGE_KEY = 'cyberstrike-theme';
|
||||
const THEMES = ['system', 'light', 'dark'];
|
||||
const FALLBACK_LABELS = {
|
||||
system: '跟随系统',
|
||||
light: '浅色',
|
||||
dark: '暗色'
|
||||
};
|
||||
const FALLBACK_TITLES = {
|
||||
system: '当前:跟随系统主题。点击切换为浅色。',
|
||||
light: '当前:浅色主题。点击切换为暗色。',
|
||||
dark: '当前:暗色主题。点击切换为跟随系统。'
|
||||
};
|
||||
const TITLE_KEYS = {
|
||||
system: 'titleSystem',
|
||||
light: 'titleLight',
|
||||
dark: 'titleDark'
|
||||
};
|
||||
|
||||
const media = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
|
||||
|
||||
function themeText(key, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
const value = window.t('theme.' + key);
|
||||
if (value && value !== 'theme.' + key) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getLabel(preference) {
|
||||
return themeText(preference, FALLBACK_LABELS[preference] || FALLBACK_LABELS.system);
|
||||
}
|
||||
|
||||
function getTitle(preference) {
|
||||
const titleKey = TITLE_KEYS[preference] || TITLE_KEYS.system;
|
||||
return themeText(titleKey, FALLBACK_TITLES[preference] || FALLBACK_TITLES.system);
|
||||
}
|
||||
|
||||
function normalizePreference(value) {
|
||||
return THEMES.includes(value) ? value : 'system';
|
||||
}
|
||||
|
||||
function readPreference() {
|
||||
try {
|
||||
return normalizePreference(localStorage.getItem(STORAGE_KEY));
|
||||
} catch (err) {
|
||||
return 'system';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTheme(preference) {
|
||||
if (preference === 'dark' || preference === 'light') {
|
||||
return preference;
|
||||
}
|
||||
return media && media.matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function updateButton(preference, resolved) {
|
||||
const btn = document.getElementById('theme-toggle-btn');
|
||||
const label = document.getElementById('theme-toggle-label');
|
||||
if (!btn) {
|
||||
return;
|
||||
}
|
||||
btn.dataset.themePreference = preference;
|
||||
btn.dataset.theme = resolved;
|
||||
const title = getTitle(preference);
|
||||
btn.title = title;
|
||||
btn.setAttribute('aria-label', title);
|
||||
if (label) {
|
||||
label.textContent = getLabel(preference);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(preference) {
|
||||
const normalized = normalizePreference(preference);
|
||||
const resolved = resolveTheme(normalized);
|
||||
const root = document.documentElement;
|
||||
root.setAttribute('data-theme-preference', normalized);
|
||||
root.setAttribute('data-theme', resolved);
|
||||
root.style.colorScheme = resolved;
|
||||
updateButton(normalized, resolved);
|
||||
}
|
||||
|
||||
function savePreference(preference) {
|
||||
const normalized = normalizePreference(preference);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, normalized);
|
||||
} catch (err) {
|
||||
// Ignore storage failures; the current page can still apply the theme.
|
||||
}
|
||||
applyTheme(normalized);
|
||||
}
|
||||
|
||||
window.setThemePreference = savePreference;
|
||||
window.getThemePreference = readPreference;
|
||||
window.cycleThemePreference = function () {
|
||||
const current = readPreference();
|
||||
const next = THEMES[(THEMES.indexOf(current) + 1) % THEMES.length];
|
||||
savePreference(next);
|
||||
};
|
||||
window.refreshThemeToggleLabel = function () {
|
||||
applyTheme(readPreference());
|
||||
};
|
||||
|
||||
if (media) {
|
||||
const onSystemThemeChange = function () {
|
||||
if (readPreference() === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
};
|
||||
if (typeof media.addEventListener === 'function') {
|
||||
media.addEventListener('change', onSystemThemeChange);
|
||||
} else if (typeof media.addListener === 'function') {
|
||||
media.addListener(onSystemThemeChange);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('languagechange', function () {
|
||||
applyTheme(readPreference());
|
||||
});
|
||||
|
||||
function initTheme() {
|
||||
applyTheme(readPreference());
|
||||
if (window.i18nReady && typeof window.i18nReady.then === 'function') {
|
||||
window.i18nReady.then(function () {
|
||||
applyTheme(readPreference());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTheme);
|
||||
} else {
|
||||
initTheme();
|
||||
}
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user