Compare commits

..
29 Commits
Author SHA1 Message Date
公明andGitHub 412daf8598 Update config.yaml 2026-07-06 17:45:27 +08:00
公明andGitHub c40a1f82a8 Add files via upload 2026-07-06 17:27:29 +08:00
公明andGitHub 404feb372e Add files via upload 2026-07-06 17:00:03 +08:00
公明andGitHub 00a936e5dc Add files via upload 2026-07-06 16:50:05 +08:00
公明andGitHub c107c77619 Add files via upload 2026-07-06 16:27:34 +08:00
公明andGitHub 4d81d2be97 Add files via upload 2026-07-06 16:26:38 +08:00
公明andGitHub 1dc43083b0 Add files via upload 2026-07-06 16:20:29 +08:00
公明andGitHub 6021d1a097 Add files via upload 2026-07-06 15:44:19 +08:00
公明andGitHub 7a06db3f44 Add files via upload 2026-07-06 15:20:35 +08:00
公明andGitHub 33d6f8bb0c Add files via upload 2026-07-06 14:56:46 +08:00
公明andGitHub cf2e65ca1b Merge pull request #183 from flydreamsec/fix/mcp-execution-binder-race
fix(multiagent): guard MCPExecutionBinder map with RWMutex (concurrent tool callback race)
2026-07-06 14:39:58 +08:00
flydream 49a90d03fa fix(multiagent): guard MCPExecutionBinder map with RWMutex
Concurrent tool callbacks in eino_adk_run_loop and the MCP monitor
goroutine call Bind/ExecutionID on the same binder. The previous
implementation used a plain map[string]string, causing intermittent
'concurrent map read and map write' panics — the same race that
TestMCPExecutionBinder_ConcurrentBind was added to guard against.

Wrap the map with sync.RWMutex (write lock in Bind, read lock in
ExecutionID). The TrimSpace of toolCallID/executionID stays outside
the critical section to keep it minimal.

go vet ./internal/multiagent/...   ✓
go test -race ./internal/multiagent/... -run MCPExecutionBinder  ✓
2026-07-06 14:37:56 +08:00
公明andGitHub 754b1ad88b Add files via upload 2026-07-06 14:17:49 +08:00
公明andGitHub b41d334d2b Update config.yaml 2026-07-06 14:00:08 +08:00
公明andGitHub 38ebdebd14 Add files via upload 2026-07-06 13:59:13 +08:00
公明andGitHub a63b535a68 Add files via upload 2026-07-06 13:58:12 +08:00
公明andGitHub ecc75ee326 Add files via upload 2026-07-06 13:55:51 +08:00
公明andGitHub a425df2607 Add files via upload 2026-07-06 12:59:54 +08:00
公明andGitHub 0aa402f89c Add files via upload 2026-07-06 11:47:09 +08:00
公明andGitHub c32cb915bc Add files via upload 2026-07-06 11:46:42 +08:00
公明andGitHub 4fd3fe3cc8 Add files via upload 2026-07-06 11:45:22 +08:00
公明andGitHub 0643aaadc6 Add files via upload 2026-07-06 11:40:06 +08:00
公明andGitHub b5baf7f9b3 Add files via upload 2026-07-06 11:38:05 +08:00
公明andGitHub 8b1efd5dd7 Add files via upload 2026-07-06 11:35:42 +08:00
公明andGitHub f6412150cf Add files via upload 2026-07-06 11:34:50 +08:00
公明andGitHub 2764732cdf Add files via upload 2026-07-06 11:33:16 +08:00
公明andGitHub 7b8b57907b Add files via upload 2026-07-06 11:31:13 +08:00
公明andGitHub be2800c248 Add files via upload 2026-07-06 10:39:55 +08:00
公明andGitHub 0ad66d8b7e Add files via upload 2026-07-03 22:39:55 +08:00
60 changed files with 6332 additions and 399 deletions
+4 -6
View File
@@ -131,7 +131,7 @@ CyberStrikeAI is an **AI-native security testing platform** built in Go. It inte
- 🧩 **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)) - 🧩 **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) - 🖼️ **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/` - 🎯 **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 - 🧑‍⚖️ **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. - 🐚 **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.** - 📡 **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.**
@@ -299,11 +299,11 @@ Requirements / tips:
2. Restart the server or reload configuration; the role appears in the role selector dropdown. 2. Restart the server or reload configuration; the role appears in the role selector dropdown.
### Multi-Agent Mode (Eino: Deep, Plan-Execute, Supervisor) ### 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/`): - **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. - **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). - **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. - **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`. - **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). - **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).
@@ -633,7 +633,7 @@ enabled: true
- [Multi-agent mode (Eino)](docs/MULTI_AGENT_EINO.md): **Deep**, **Plan-Execute**, **Supervisor**, `agents/*.md`, `eino_skills` / `eino_middleware`, APIs, and chat/stream behavior. - [Multi-agent mode (Eino)](docs/MULTI_AGENT_EINO.md): **Deep**, **Plan-Execute**, **Supervisor**, `agents/*.md`, `eino_skills` / `eino_middleware`, APIs, and chat/stream behavior.
- [Graph orchestration guide](docs/workflow-graph_en.md): visual workflow design, node configuration, `previous` / `outputs` variable passing, and role binding. - [Graph orchestration guide](docs/workflow-graph_en.md): visual workflow design, node configuration, `previous` / `outputs` variable passing, and role binding.
- [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.** - [Robot / Chatbot guide](docs/robot_en.md): Setup, commands, and troubleshooting for WeChat, WeCom, DingTalk, Lark, Telegram, Slack, Discord, and QQ Bot.
## Project Layout ## Project Layout
@@ -713,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! Need help or want to contribute? Open an issue or PR—community tooling additions are welcome!
+4 -4
View File
@@ -130,7 +130,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
- 🧩 **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) - 🧩 **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) - 🖼️ **视觉分析(`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+ 领域示例仍可绑定角色 - 🎯 **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`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用 - 🧑‍⚖️ **人机协同(HITL**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml``hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。 - 🐚 **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 类规则(如命令拒绝正则)。**仅限授权测试。** - 📡 **内置 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 类规则(如命令拒绝正则)。**仅限授权测试。**
@@ -297,11 +297,11 @@ go build -o cyberstrike-ai cmd/server/main.go
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。 2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
### 多代理模式(EinoDeep / Plan-Execute / Supervisor ### 多代理模式(EinoDeep / 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/`): - **Markdown 定义**`agents_dir`,默认 `agents/`):
- **Deep 主代理**`orchestrator.md` 或唯一 `kind: orchestrator` 的 `.md`;正文或 `multi_agent.orchestrator_instruction`,再回退 Eino 默认。 - **Deep 主代理**`orchestrator.md` 或唯一 `kind: orchestrator` 的 `.md`;正文或 `multi_agent.orchestrator_instruction`,再回退 Eino 默认。
- **Plan-Execute 主代理**:固定 **`orchestrator-plan-execute.md`**(另可配 `orchestrator_instruction_plan_execute`)。 - **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` 列表。 - **子代理****deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
- **界面管理****Agents → Agent 管理**API `/api/multi-agent/markdown-agents`。 - **界面管理****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`**。 - **配置项**`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`**。
@@ -631,7 +631,7 @@ enabled: true
- [多代理模式(Eino](docs/MULTI_AGENT_EINO.md)**Deep**、**Plan-Execute**、**Supervisor**、`agents/*.md`、`eino_skills` / `eino_middleware`、接口与流式说明。 - [多代理模式(Eino](docs/MULTI_AGENT_EINO.md)**Deep**、**Plan-Execute**、**Supervisor**、`agents/*.md`、`eino_skills` / `eino_middleware`、接口与流式说明。
- [图编排使用说明](docs/workflow-graph.md):可视化流程搭建、节点配置、`previous` / `outputs` 变量传参与角色绑定。 - [图编排使用说明](docs/workflow-graph.md):可视化流程搭建、节点配置、`previous` / `outputs` 变量传参与角色绑定。
- [机器人使用说明(钉钉 / 飞书)](docs/robot.md)在手机端通过钉钉、飞书与 CyberStrikeAI 对话的完整配置步骤、命令与排查说明,**建议按该文档操作以避免走弯路** - [机器人使用说明](docs/robot.md)个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord、QQ 机器人的配置、命令与排查
## 项目结构 ## 项目结构
+4 -2
View File
@@ -4,7 +4,7 @@ name: Supervisor 监督主代理
description: supervisor 模式下的协调者:通过 transfer 委派专家子代理,必要时亲自使用 MCP;完成目标时用 exit 结束(运行时会追加专家列表与 exit 说明);transfer 前必须提供完整目标与范围。 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` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节): - **`transfer` 交接包(强制,避免专家重复侦察)**:**把专家当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 在触发 `transfer` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节):
- **已知资产/结论摘要**(主域、关键子域、高价值目标、已开放端口或服务类型等)。 - **已知资产/结论摘要**(主域、关键子域、高价值目标、已开放端口或服务类型等)。
- **本轮唯一任务**与 **禁止项**(例如:「不得再做全量子域枚举;仅对下列主机做 MQTT 验证」)。 - **本轮唯一任务**与 **禁止项**(例如:「不得再做全量子域枚举;仅对下列主机做 MQTT 验证」)。
@@ -106,7 +108,7 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
- 成功标准:预期交付的证据与结论粒度 - 成功标准:预期交付的证据与结论粒度
- **缺失信息处理(强制)**:若任一字段缺失,先补充上下文或向用户澄清,禁止把“目标不明确”的任务直接转给专家。 - **缺失信息处理(强制)**:若任一字段缺失,先补充上下文或向用户澄清,禁止把“目标不明确”的任务直接转给专家。
- **亲自执行**:仅在 transfer 不划算或无法覆盖缺口时由你直接调用工具。 - **亲自执行**:仅在 transfer 不划算或无法覆盖缺口时由你直接调用工具。
- **汇总**:专家输出是证据来源;对齐矛盾、补全上下文,给出统一结论与可复现验证步骤,避免机械拼接原文。 - **汇总**:专家输出是证据来源;你必须对齐矛盾、裁剪噪声、补全上下文,给出统一结论与可复现验证步骤,避免机械拼接原文。最终交付必须由你完成,并通过 `exit` 结束。
- **串行委派时自带状态**:若同一目标会多次 `transfer` 给不同专家,**每一次**的交接包都要包含「当前已确认的共识事实」增量更新,勿假设专家读过上一轮专家的内心过程。 - **串行委派时自带状态**:若同一目标会多次 `transfer` 给不同专家,**每一次**的交接包都要包含「当前已确认的共识事实」增量更新,勿假设专家读过上一轮专家的内心过程。
- **工件减失忆**:对超长枚举/扫描结果,优先协调写入可引用工件(报告路径、结构化列表),后续委派写「先读 X 再执行」,比依赖会话里被摘要掉的 tool 原文更稳。 - **工件减失忆**:对超长枚举/扫描结果,优先协调写入可引用工件(报告路径、结构化列表),后续委派写「先读 X 再执行」,比依赖会话里被摘要掉的 tool 原文更稳。
- **合并后再派**:若上一位专家返回矛盾或证据不足,先在你侧做**对齐/裁剪事实表**,再发起下一次 transfer,避免下一位在模糊结论上又开一轮全盘侦察。 - **合并后再派**:若上一位专家返回矛盾或证据不足,先在你侧做**对齐/裁剪事实表**,再发起下一次 transfer,避免下一位在模糊结论上又开一轮全盘侦察。
+19 -1
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.50" version: "v1.6.51"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
@@ -357,6 +357,24 @@ robots:
app_secret: "" app_secret: ""
verify_token: "" verify_token: ""
allow_chat_id_fallback: false 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 相关配置 # Skills 相关配置
# ============================================ # ============================================
+4 -3
View File
@@ -5,7 +5,7 @@
## 总体结论 ## 总体结论
- **改造已可用于生产试验**:流式对话、MCP 工具桥接、配置开关、前端模式切换均已落地。 - **改造已可用于生产试验**:流式对话、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`)。 | | 配置 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 错误事件)。 | | OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
| 机器人 | `ProcessMessageForRobot``robot_default_agent_mode`(默认 `eino_single`)调用 `RunEinoSingleChatModelAgent``RunDeepAgent`。 | | 机器人 | `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` 至少需一个子代理。 | | 预置编排 | 聊天 / 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`**`runner.go``prependEinoMiddlewares(einoMWMain)` 产物作为 `ExecPreMiddlewares` 挂到 **Executor**与 Deep/Supervisor 主代理同序:patch → reduction → toolsearch → plantask → filesystem → skill → summarization tailPlanner/Replanner 仅 summarization tail + prompt 预算截断,不跑 MCP 工具链。 | | 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 主代理同源的 middlewarepatch → reduction → toolsearch → plantask → filesystem → skill → summarization tailPlanner/Replanner 仅 summarization tail + prompt 预算截断,不跑 MCP 工具链。当前 Eino 官方 `planexecute.NewExecutor` 尚未暴露 Handlers 字段,因此该自定义 Executor 是保留 middleware 的对齐实现。 |
## 进行中 / 待办( backlog ## 进行中 / 待办( backlog
@@ -60,5 +60,6 @@
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 | | 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-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
| 2026-04-21 | 移除角色 `skills``/api/roles/skills/list``bind_role` 仅继承 toolsSkills 仅通过 Eino `skill` 工具按需加载。 | | 2026-04-21 | 移除角色 `skills``/api/roles/skills/list``bind_role` 仅继承 toolsSkills 仅通过 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-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 保留。 | | 2026-06-02 | **移除原生 ReAct**:删除 `/api/agent-loop*` 执行入口与 `AgentLoopWithProgress`;统一 Eino ADK(单代理 `/api/eino-agent*`,多代理 `/api/multi-agent*`);任务 cancel/tasks API 保留。 |
+231 -15
View File
@@ -2,7 +2,7 @@
[English](robot_en.md) [English](robot_en.md)
本文档说明如何通过**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。 本文档说明如何通过**个人微信**、**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
--- ---
@@ -11,11 +11,14 @@
1. 登录 CyberStrikeAI Web 端 1. 登录 CyberStrikeAI Web 端
2. 左侧导航进入 **系统设置** 2. 左侧导航进入 **系统设置**
3. 在左侧设置分类中点击 **机器人设置**(位于「基本设置」与「安全设置」之间) 3. 在左侧设置分类中点击 **机器人设置**(位于「基本设置」与「安全设置」之间)
4. 按平台勾选并填写(钉钉填 Client ID / Client Secret,飞书填 App ID / App Secret 4. 按平台配置:
5. 点击 **应用配置** 保存 - **个人微信**:点击「微信 / iLink」→「生成二维码并绑定」,用微信扫码确认(见 [3.4 个人微信](#34-个人微信-wechat--ilink)
6. **重启 CyberStrikeAI 应用**(只保存不重启,机器人不会连上) - **钉钉**:勾选并填写 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 长连接,程序主动连接钉钉接收消息 | | 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
| 飞书 | 使用长连接,程序主动连接飞书接收消息 | | 飞书 | 使用长连接,程序主动连接飞书接收消息 |
| 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API | | 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API |
| Telegram | Bot API 长轮询(getUpdates),**无需公网回调** |
| Slack | Socket Mode(出站 WebSocket),**无需公网回调** |
| Discord | Gateway WebSocket**无需公网回调** |
| QQ 机器人 | QQ 开放平台 WebSocketC2C / 群 @),**无需公网回调** |
下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。 下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 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** → 创建 tokenscope: `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 → 点击 **应用配置** 2. **在 CyberStrikeAI**:系统设置 → 机器人设置 → 勾选对应平台,粘贴 Client ID/App ID、Client Secret/App Secret → 点击 **应用配置**
3. **重启 CyberStrikeAI 进程**(否则长连接不会建立)。 3. **重启 CyberStrikeAI 进程**(否则长连接不会建立)。
4. **在手机钉钉/飞书**:找到该机器人(单聊直接发,群聊需 @机器人),发「帮助」或任意内容测试。 4. **在手机钉钉/飞书**:找到该机器人(单聊直接发,群聊需 @机器人),发「帮助」或任意内容测试。
@@ -199,6 +357,14 @@
```yaml ```yaml
robots: 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: dingtalk:
enabled: true enabled: true
client_id: "your_dingtalk_app_key" client_id: "your_dingtalk_app_key"
@@ -208,9 +374,33 @@ robots:
app_id: "your_lark_app_id" app_id: "your_lark_app_id"
app_secret: "your_lark_app_secret" app_secret: "your_lark_app_secret"
verify_token: "" 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 + 加签)**不能**用来做对话,本程序只支持**开放平台「企业内部应用」**里的机器人。 - **用错了机器人类型**:在钉钉**群里**添加的「自定义」机器人(Webhook + 加签)**不能**用来做对话,本程序只支持**开放平台「企业内部应用」**里的机器人。
- **只保存没重启**在 CyberStrikeAI 里改完机器人配置后必须**重启应用**,否则长连接不会建立。 - **只保存没重启**钉钉/飞书改完配置后必须**重启应用**,否则长连接不会建立(个人微信扫码绑定会自动重启连接)
- **Client ID 抄错**:开放平台是 `504` 就填 `504`,不要填成 `5o4`;尽量用复制粘贴。 - **Client ID 抄错**:开放平台是 `504` 就填 `504`,不要填成 `5o4`;尽量用复制粘贴。
- **钉钉只开了 HTTP 回调没开 Stream**:本程序通过 **Stream 长连接**收消息,开放平台里机器人的消息接收方式必须选 **Stream 模式** - **钉钉只开了 HTTP 回调没开 Stream**:本程序通过 **Stream 长连接**收消息,开放平台里机器人的消息接收方式必须选 **Stream 模式**
- **应用没发布**:开放平台里修改了机器人或权限后,要在「版本管理与发布」里**发布新版本**,否则不生效。 - **应用没发布**:开放平台里修改了机器人或权限后,要在「版本管理与发布」里**发布新版本**,否则不生效。
@@ -270,6 +485,7 @@ curl -X POST "http://localhost:8080/api/robot/test" \
## 十一、注意事项 ## 十一、注意事项
- 钉钉、飞书均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。 - 各平台均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。
- 个人微信仅支持**私聊**,不支持群聊 @ 机器人。
- 会话与 Web 端共用同一套对话数据:在机器人里创建的对话会在 Web 端「对话」列表中看到,反之亦然。 - 会话与 Web 端共用同一套对话数据:在机器人里创建的对话会在 Web 端「对话」列表中看到,反之亦然。
- 机器人执行与 **Eino 单/多代理** 相同逻辑(`ProcessMessageForRobot`,含进度回调与过程详情入库),仅不向客户端推送 SSE,最后一次性回复钉钉/飞书/企业微信。默认 `robot_default_agent_mode: eino_single` - 机器人执行与 **Eino 单/多代理** 相同逻辑(`ProcessMessageForRobot`,含进度回调与过程详情入库),仅不向客户端推送 SSE,最后一次性回复个人微信/钉钉/飞书/企业微信。默认 `robot_default_agent_mode: eino_single`
+205 -14
View File
@@ -2,7 +2,7 @@
[中文](robot.md) [中文](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. 1. Log in to the CyberStrikeAI web UI.
2. Open **System Settings** in the left sidebar. 2. Open **System Settings** in the left sidebar.
3. Click **Robot settings** (between “Basic” and “Security”). 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). 4. Configure per platform:
5. Click **Apply configuration** to save. - **Personal WeChat**: Open **WeChat / iLink****Generate QR code and bind**, then scan with WeChat (see [Section 3.4](#34-personal-wechat-wechat--ilink))
6. **Restart the CyberStrikeAI process** (saving alone does not establish the connection). - **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 | | 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 | | 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 | | Lark (Feishu) | Long-lived connection; the app connects to Lark to receive messages |
| WeCom (Qiye WX)| HTTP callback to receive messages; CyberStrikeAI replies via WeComs message sending API | | WeCom (Qiye WX)| HTTP callback to receive messages; CyberStrikeAI replies via WeComs 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. 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”, youre 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 ~560 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 ## 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 | | 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?) ## 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**. - **Personal WeChat**: Send directly in the **private chat** with the bot; **no @ needed** (group chat not supported).
- **Group chat**: If the bot is in a group, only messages that **@ the bot** are received and answered; other group messages are ignored. - **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 dont skip steps) ## 6. Recommended flow (so you dont 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). 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**. 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). 3. **Restart the CyberStrikeAI process** (otherwise the long-lived connection is not established).
@@ -196,6 +329,14 @@ Example `robots` section in `config.yaml`:
```yaml ```yaml
robots: 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: dingtalk:
enabled: true enabled: true
client_id: "your_dingtalk_app_key" client_id: "your_dingtalk_app_key"
@@ -205,9 +346,33 @@ robots:
app_id: "your_lark_app_id" app_id: "your_lark_app_id"
app_secret: "your_lark_app_secret" app_secret: "your_lark_app_secret"
verify_token: "" 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 ~560 s; restart if still no response.
### 9.2 DingTalk
Check in this order: Check in this order:
@@ -257,8 +445,10 @@ Check in this order:
## 10. Common pitfalls ## 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. - **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. - **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**. - **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 wont apply. - **App not published**: After changing the bot or permissions in the open platform, **publish a new version** under “Version management and release”, or changes wont apply.
@@ -267,6 +457,7 @@ Check in this order:
## 11. Notes ## 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. - 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`.
+163 -13
View File
@@ -27,9 +27,10 @@
| 选中元素 | 单击节点或连线,右侧显示 **节点属性** | | 选中元素 | 单击节点或连线,右侧显示 **节点属性** |
| 删除选中 | 点击 **删除选中** 删除当前节点或连线 | | 删除选中 | 点击 **删除选中** 删除当前节点或连线 |
| 自动布局 | 点击 **自动布局** 整理节点位置 | | 自动布局 | 点击 **自动布局** 整理节点位置 |
| 试运行 | 点击 **试运行** 使用安全 dry-run 验证数据流;工具、Agent、审批不会真实执行 |
| 删除流程 | 点击 **删除** 删除整个流程定义 | | 删除流程 | 点击 **删除** 删除整个流程定义 |
**建议** 每个流程至少包含 **1 个开始节点****1 个输出节点**;开始节点不有入边,输出节点不有出边。 **硬性规则** 每个流程至少包含 **1 个开始节点****1 个输出节点**;开始节点不有入边,输出 / 结束节点不有出边。保存时前端和后端都会执行严格校验。
--- ---
@@ -45,6 +46,7 @@
| `lastOutput` | `{{previous.xxx}}` | **上一个刚执行完** 的节点的输出 | | `lastOutput` | `{{previous.xxx}}` | **上一个刚执行完** 的节点的输出 |
| `outputs` | `{{outputs.xxx}}` | 全局 **命名变量池**(由节点的「输出变量名」写入) | | `outputs` | `{{outputs.xxx}}` | 全局 **命名变量池**(由节点的「输出变量名」写入) |
| `nodeOutputs` | `{{节点ID.xxx}}` | 指定节点 ID 的完整输出对象 | | `nodeOutputs` | `{{节点ID.xxx}}` | 指定节点 ID 的完整输出对象 |
| `metrics` | 运行详情中查看 | 节点耗时、工具调用数、可收集到的 token / cost 等指标 |
### 3.1 `previous` 是什么? ### 3.1 `previous` 是什么?
@@ -69,6 +71,15 @@ Agent B 的 `{{previous.output}}` = Agent A 的输出。
Agent B 的 `{{previous.output}}` = **条件节点** 的输出(`true` / `false`),**不是** Agent A 的结果。 Agent B 的 `{{previous.output}}` = **条件节点** 的输出(`true` / `false`),**不是** Agent A 的结果。
如果一个节点有 **多个上游节点** 同时连入,`previous` 会先按该节点的 **汇聚策略** 生成:
| 汇聚策略 | 含义 | 适合场景 |
|----------|------|----------|
| `all_merge` | 合并所有上游输出,`previous.output` 为数组 | 默认推荐,综合多路结果 |
| `last_by_canvas` | 按画布顺序取最后一个上游输出 | 明确只采用一路结果 |
| `first_non_empty` | 取第一个非空输出 | 多路兜底 |
| `fail_fast` | 任一上游失败则中止当前节点 | 关键链路、审批前置、安全检查 |
### 3.2 `outputs` 是什么? ### 3.2 `outputs` 是什么?
`outputs` 是引擎在运行过程中维护的 **命名变量注册表** `outputs` 是引擎在运行过程中维护的 **命名变量注册表**
@@ -135,21 +146,55 @@ outputs["你填的变量名"] = 节点输出内容
| `{{previous.matched}}` | 上一条件节点的匹配结果(`true` / `false` | | `{{previous.matched}}` | 上一条件节点的匹配结果(`true` / `false` |
| `{{outputs.变量名}}` | 某节点注册过的命名输出 | | `{{outputs.变量名}}` | 某节点注册过的命名输出 |
| `{{节点ID.output}}` | 指定节点 ID 的 `output` 字段 | | `{{节点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 条件表达式 ### 4.3 条件表达式
条件节点和连线条件支持简单比较: 条件节点和连线条件支持比较、文本匹配、正则、逻辑组合与安全 JSONPath/JQ 路径读取
```text ```text
{{outputs.agent_result1}} != "" {{outputs.agent_result1}} != ""
{{previous.output}} == "ok" {{previous.output}} == "ok"
{{outputs.count}} == "100" {{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` 视为真 - 无比较符时,非空且不为 `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]` | 从指定节点输出读取数组元素 |
--- ---
@@ -175,6 +220,7 @@ outputs["你填的变量名"] = 节点输出内容
| 输入来源 | 上游数据的模板表达式 | `{{previous.output}}` | | 输入来源 | 上游数据的模板表达式 | `{{previous.output}}` |
| 节点指令 | 本节点要完成的任务描述 | 空 | | 节点指令 | 本节点要完成的任务描述 | 空 |
| 输出变量名 | 写入 `outputs` 的键名 | `agent_result` | | 输出变量名 | 写入 `outputs` 的键名 | `agent_result` |
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
**消息拼装规则:** **消息拼装规则:**
@@ -186,6 +232,7 @@ Agent 节点执行后:
- `previous.output` 更新为本节点响应文本 - `previous.output` 更新为本节点响应文本
- 若配置了 **输出变量名**,同时写入 `outputs[输出变量名]` - 若配置了 **输出变量名**,同时写入 `outputs[输出变量名]`
- Agent 子图在 Eino 中拆为 `prepare → execute → finalize`,便于 trace 与后续局部 checkpoint
### 5.3 工具(tool ### 5.3 工具(tool
@@ -196,6 +243,7 @@ Agent 节点执行后:
| MCP 工具 | 工具名称(必填) | — | | MCP 工具 | 工具名称(必填) | — |
| 参数模板 | JSON,支持 `{{...}}` 模板 | `{}` | | 参数模板 | JSON,支持 `{{...}}` 模板 | `{}` |
| 超时秒数 | 可选 | 空 | | 超时秒数 | 可选 | 空 |
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
示例参数模板: 示例参数模板:
@@ -212,6 +260,7 @@ Agent 节点执行后:
| 字段 | 说明 | 默认值 | | 字段 | 说明 | 默认值 |
|------|------|--------| |------|------|--------|
| 条件表达式 | 支持 `{{...}}``==` / `!=` | `{{previous.output}} != ""` | | 条件表达式 | 支持 `{{...}}``==` / `!=` | `{{previous.output}} != ""` |
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
**分支规则:** **分支规则:**
@@ -229,12 +278,21 @@ Agent 节点执行后:
### 5.5 审批(hitl ### 5.5 审批(hitl
人工确认检查点(当前为记录模式,自动标记 `approved: true` 并继续) 人工确认检查点。流程运行到该节点前会通过 Eino interrupt/checkpoint 暂停,等待 API 或监控面板审批后恢复
| 字段 | 说明 | 默认值 | | 字段 | 说明 | 默认值 |
|------|------|--------| |------|------|--------|
| 审批提示 | 支持模板 | `请审批该步骤是否继续执行` | | 审批提示 | 支持模板 | `请审批该步骤是否继续执行` |
| 提示字段绑定 | 留空审批提示时,从绑定字段读取说明 | `previous.output` |
| 审批方 | `human` / `audit_agent` | `human` | | 审批方 | `human` / `audit_agent` | `human` |
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
HITL 等待信息会记录:
- `checkpointId`
- interrupt `beforeNodes`
- resume target / address / path
- resume payload schema`approved``comment`
### 5.6 输出(output ### 5.6 输出(output
@@ -244,6 +302,8 @@ Agent 节点执行后:
|------|------|--------| |------|------|--------|
| 输出变量名 | 必填,最终结果的键名 | `result` | | 输出变量名 | 必填,最终结果的键名 | `result` |
| 变量来源 | 模板表达式,决定写入的值 | `{{previous.output}}` | | 变量来源 | 模板表达式,决定写入的值 | `{{previous.output}}` |
| 固定输出值 | 可选,填写后覆盖变量来源 | 空 |
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
**注意:** 输出节点是流程的「出口」,不应再有出边。 **注意:** 输出节点是流程的「出口」,不应再有出边。
@@ -254,6 +314,7 @@ Agent 节点执行后:
| 字段 | 说明 | 默认值 | | 字段 | 说明 | 默认值 |
|------|------|--------| |------|------|--------|
| 结束摘要模板 | 支持 `{{outputs.xxx}}` | `{{outputs.result}}` | | 结束摘要模板 | 支持 `{{outputs.xxx}}` | `{{outputs.result}}` |
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
--- ---
@@ -353,7 +414,80 @@ workflow_policy: auto
--- ---
## 九、保存前校验规则 ## 九、调试、试运行与复盘
### 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。
---
## 十、保存前校验规则
保存时系统会自动检查: 保存时系统会自动检查:
@@ -363,13 +497,20 @@ workflow_policy: auto
| 必须有输出节点 | 至少 1 个 `output`,且填写输出变量名 | | 必须有输出节点 | 至少 1 个 `output`,且填写输出变量名 |
| 连线合法 | 源/目标节点存在,不能自环 | | 连线合法 | 源/目标节点存在,不能自环 |
| 开始节点无入边 | 开始节点不能被指向 | | 开始节点无入边 | 开始节点不能被指向 |
| 输出节点无出边 | 输出节点后不应再连线 | | 输出 / 结束节点无出边 | 输出 / 结束节点后不应再连线 |
| 工具节点 | 必须选择 MCP 工具 | | 非开始节点必须有入边 | 避免孤岛节点 |
| 条件节点 | 必须填写表达式;建议 1~2 条出边(是/否) | | 非输出 / 结束节点必须有出边 | 避免执行到死路 |
| 无环路 | Workflow 编排必须是 DAG |
| 可达性 | 所有节点必须能从开始节点到达,并能最终到达 output/end |
| 工具节点 | 必须选择 MCP 工具;参数 JSON 必须合法;超时必须为正整数 |
| Agent 节点 | 必须填写节点指令或输入绑定;必须填写输出变量名 |
| 条件节点 | 必须填写表达式;需要 1~2 条出边;分支必须标记是/否且不能重复 |
| 连线条件 | 表达式、正则、JSONPath/JQ 路径必须通过静态校验 |
| 汇聚策略 | 必须是 `all_merge` / `last_by_canvas` / `first_non_empty` / `fail_fast` |
--- ---
## 十、排错指南 ## 十、排错指南
| 现象 | 可能原因 | 处理建议 | | 现象 | 可能原因 | 处理建议 |
|------|----------|----------| |------|----------|----------|
@@ -379,25 +520,34 @@ workflow_policy: auto
| 流程无最终输出 | 未命中输出节点所在分支 | 检查条件分支连线;确保至少一条路径到达 **输出** 节点 | | 流程无最终输出 | 未命中输出节点所在分支 | 检查条件分支连线;确保至少一条路径到达 **输出** 节点 |
| 角色对话未跑流程 | 角色未绑定或未启用 | 确认 `workflow_id``workflow_policy: auto`、流程 `enabled: true` | | 角色对话未跑流程 | 角色未绑定或未启用 | 确认 `workflow_id``workflow_policy: auto`、流程 `enabled: true` |
| 工具节点失败 | 参数 JSON 不合法或工具未启用 | 检查参数模板;在 MCP 中启用对应工具 | | 工具节点失败 | 参数 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` 1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result``parsed_targets`,避免都叫 `agent_result`
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。 2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
3. **`previous` 仅用于直连**:A → B 且无中间节点时,`{{previous.output}}` 最简洁。 3. **`previous` 仅用于直连**:A → B 且无中间节点时,`{{previous.output}}` 最简洁。
4. **条件判断引用源数据**:判断 Agent 输出时用 `{{outputs.xxx}}`,不要用 `{{previous.output}}`(除非条件紧跟在目标 Agent 之后)。 4. **条件判断引用源数据**:判断 Agent 输出时用 `{{outputs.xxx}}`,不要用 `{{previous.output}}`(除非条件紧跟在目标 Agent 之后)。
5. **每条路径都要有出口**:确保「是」「否」分支最终都能到达 **输出** 节点(或你期望的终点)。 5. **每条路径都要有出口**:确保「是」「否」分支最终都能到达 **输出** 节点(或你期望的终点)。
6. **保存前跑一遍**:用简单指令(如固定字符串输出)验证数据传递,再替换为真实业务逻辑。 6. **多上游节点显式选择汇聚策略**:综合结果用 `all_merge`,兜底用 `first_non_empty`,关键链路用 `fail_fast`
7. **嵌套 JSON 用 JSONPath/JQ 安全路径**:例如 `jsonpath({{previous.output}}, "$.status") == "ok"`
8. **保存前先 dry-run**:用简单消息验证数据传递和分支,再绑定角色真实执行。
--- ---
## 十、相关代码位置(开发者参考) ## 十、相关代码位置(开发者参考)
| 模块 | 路径 | | 模块 | 路径 |
|------|------| |------|------|
| 执行引擎 | `internal/workflow/runner.go` | | 执行引擎 | `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` | | 画布前端 | `web/static/js/workflows.js` |
| 流程 API | `internal/handler/workflow.go` | | 流程 API | `internal/handler/workflow.go` |
| 角色绑定 | `internal/config/config.go``workflow_id` 字段) | | 角色绑定 | `internal/config/config.go``workflow_id` 字段) |
+163 -13
View File
@@ -27,9 +27,10 @@ Saved workflows can be bound to a role under **Role Management**. When `workflow
| Select | Click a node or edge; properties appear in the right panel | | Select | Click a node or edge; properties appear in the right panel |
| Delete selected | Remove the current node or edge | | Delete selected | Remove the current node or edge |
| Auto layout | Rearrange node positions | | 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 | | Delete workflow | Remove the entire workflow definition |
**Requirements:** Every workflow needs at least **one Start node** and **one Output node**. Start nodes must not have incoming edges; Output nodes must not have outgoing edges. **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.
--- ---
@@ -45,6 +46,7 @@ During a run, the engine keeps internal state. Template expressions `{{...}}` re
| `lastOutput` | `{{previous.xxx}}` | Output of the **most recently executed** node | | `lastOutput` | `{{previous.xxx}}` | Output of the **most recently executed** node |
| `outputs` | `{{outputs.xxx}}` | Global **named variable pool** (written by nodes with an output key) | | `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 | | `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`? ### 3.1 What is `previous`?
@@ -69,6 +71,15 @@ Start → Agent A → Condition → Agent B
For Agent B, `{{previous.output}}` = the **condition node** output (`true` / `false`), **not** Agent As result. For Agent B, `{{previous.output}}` = the **condition node** output (`true` / `false`), **not** Agent As result.
If a node has **multiple upstream nodes**, `previous` is built by that nodes **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`? ### 3.2 What is `outputs`?
`outputs` is a **named variable registry** maintained by the engine during execution. `outputs` is a **named variable registry** maintained by the engine during execution.
@@ -135,21 +146,55 @@ Allowed characters in paths: letters, digits, underscore, dot, hyphen. Examples:
| `{{previous.matched}}` | Match result of the previous condition node (`true` / `false`) | | `{{previous.matched}}` | Match result of the previous condition node (`true` / `false`) |
| `{{outputs.variable_name}}` | Named output registered by a node | | `{{outputs.variable_name}}` | Named output registered by a node |
| `{{nodeId.output}}` | `output` field of the node with that ID | | `{{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 ### 4.3 Condition expressions
Condition nodes and edge conditions support simple comparisons: Condition nodes and edge conditions support comparisons, text matching, regex, logical operators, and safe JSONPath/JQ path reads:
```text ```text
{{outputs.agent_result1}} != "" {{outputs.agent_result1}} != ""
{{previous.output}} == "ok" {{previous.output}} == "ok"
{{outputs.count}} == "100" {{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: Rules:
- Use `==` or `!=` for string comparison (leading/trailing spaces and quotes are trimmed) - 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 - 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 |
--- ---
@@ -175,6 +220,7 @@ Runs an LLM Agent task. Supports multiple modes.
| Input source | Template for upstream data | `{{previous.output}}` | | Input source | Template for upstream data | `{{previous.output}}` |
| Node instruction | Task description for this node | empty | | Node instruction | Task description for this node | empty |
| Output variable name | Key written into `outputs` | `agent_result` | | 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:** **Message assembly:**
@@ -186,6 +232,7 @@ After execution:
- `previous.output` becomes this nodes response text - `previous.output` becomes this nodes response text
- If **Output variable name** is set, the value is also stored in `outputs[variable_name]` - 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 ### 5.3 Tool
@@ -196,6 +243,7 @@ Calls an enabled MCP tool.
| MCP tool | Tool name (required) | — | | MCP tool | Tool name (required) | — |
| Argument template | JSON with `{{...}}` templates | `{}` | | Argument template | JSON with `{{...}}` templates | `{}` |
| Timeout (seconds) | Optional | empty | | Timeout (seconds) | Optional | empty |
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
Example argument template: Example argument template:
@@ -212,6 +260,7 @@ Evaluates an expression and outputs `matched` (`true` / `false`).
| Field | Description | Default | | Field | Description | Default |
|-------|-------------|---------| |-------|-------------|---------|
| Expression | Supports `{{...}}` and `==` / `!=` | `{{previous.output}} != ""` | | Expression | Supports `{{...}}` and `==` / `!=` | `{{previous.output}} != ""` |
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
**Branching rules:** **Branching rules:**
@@ -229,12 +278,21 @@ Edge condition examples (select an edge, configure in the right panel):
### 5.5 HITL (human-in-the-loop) ### 5.5 HITL (human-in-the-loop)
Human approval checkpoint (currently record-only; marks `approved: true` and continues). 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 | | Field | Description | Default |
|-------|-------------|---------| |-------|-------------|---------|
| Prompt | Supports templates | `Please approve before continuing` | | 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` | | 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 ### 5.6 Output
@@ -244,6 +302,8 @@ Writes the final workflow result into `outputs` for summary and chat display.
|-------|-------------|---------| |-------|-------------|---------|
| Output variable name | Required key for the final result | `result` | | Output variable name | Required key for the final result | `result` |
| Variable source | Template deciding what to write | `{{previous.output}}` | | 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. **Note:** Output nodes are workflow exits and must not have outgoing edges.
@@ -254,6 +314,7 @@ Optional node for an end summary template (less common in role-bound flows).
| Field | Description | Default | | Field | Description | Default |
|-------|-------------|---------| |-------|-------------|---------|
| Result template | Supports `{{outputs.xxx}}` | `{{outputs.result}}` | | Result template | Supports `{{outputs.xxx}}` | `{{outputs.result}}` |
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
--- ---
@@ -353,7 +414,80 @@ If no Output node is reached or no branch matches, `outputs` may be empty and th
--- ---
## 9. Validation before save ## 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: On save, the system checks:
@@ -363,13 +497,20 @@ On save, the system checks:
| Output node required | At least one `output` node with an output variable name | | Output node required | At least one `output` node with an output variable name |
| Valid edges | Source and target exist; no self-loops | | Valid edges | Source and target exist; no self-loops |
| Start has no incoming edges | Start must not be targeted | | Start has no incoming edges | Start must not be targeted |
| Output has no outgoing edges | Nothing after Output | | Output / End has no outgoing edges | Nothing after Output / End |
| Tool nodes | MCP tool must be selected | | Non-start nodes must have incoming edges | Prevent orphan nodes |
| Condition nodes | Expression required; ideally 12 outgoing edges (yes/no) | | 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; 12 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` |
--- ---
## 10. Troubleshooting ## 11. Troubleshooting
| Symptom | Likely cause | Fix | | Symptom | Likely cause | Fix |
|---------|--------------|-----| |---------|--------------|-----|
@@ -379,25 +520,34 @@ On save, the system checks:
| No final output | Output node branch not reached | Verify condition wiring; ensure every path reaches an **Output** node | | 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` | | 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 | | 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 |
--- ---
## 11. Best practices ## 12. Best practices
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere. 1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables. 2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
3. **Use `previous` only for direct links**: `A → B` with nothing in between is the ideal case for `{{previous.output}}`. 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. 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). 5. **Every path needs an exit**: Ensure both yes and no branches eventually reach an **Output** node (or your intended end).
6. **Validate with a simple run**: Use fixed-string outputs to verify data flow before swapping in real business logic. 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.
--- ---
## 12. Code references (for developers) ## 13. Code references (for developers)
| Module | Path | | Module | Path |
|--------|------| |--------|------|
| Execution engine | `internal/workflow/runner.go` | | 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` | | Canvas UI | `web/static/js/workflows.js` |
| Workflow API | `internal/handler/workflow.go` | | Workflow API | `internal/handler/workflow.go` |
| Role binding | `internal/config/config.go` (`workflow_id` field) | | Role binding | `internal/config/config.go` (`workflow_id` field) |
+10 -4
View File
@@ -3,11 +3,10 @@ module cyberstrike-ai
// go mod download : go env -w GOPROXY=https://goproxy.cn,direct // go mod download : go env -w GOPROXY=https://goproxy.cn,direct
// 使 scripts/bootstrap-go.sh // 使 scripts/bootstrap-go.sh
go 1.24.0 go 1.25
toolchain go1.24.4
require ( require (
github.com/bwmarrin/discordgo v0.29.0
github.com/bytedance/sonic v1.15.0 github.com/bytedance/sonic v1.15.0
github.com/cloudwego/eino v0.8.13 github.com/cloudwego/eino v0.8.13
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2 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/eino-contrib/jsonschema v1.0.3
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0 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/larksuite/oapi-sdk-go/v3 v3.4.22
github.com/mattn/go-sqlite3 v1.14.18 github.com/mattn/go-sqlite3 v1.14.18
github.com/modelcontextprotocol/go-sdk v1.2.0 github.com/modelcontextprotocol/go-sdk v1.2.0
@@ -29,6 +28,8 @@ require (
github.com/pkoukk/tiktoken-go v0.1.8 github.com/pkoukk/tiktoken-go v0.1.8
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e 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 v1.34.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
go.opentelemetry.io/otel/exporters/stdout/stdouttrace 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/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-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/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.3.0 // 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/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // 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/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/oauth2 v0.30.0 // 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 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/api v0.0.0-20250115164207-1a7da9e5054f // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
+116 -1
View File
@@ -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/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 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= 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/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/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/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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= 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= 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 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/eino v0.8.13 h1:z5dhaZNN8TWZbP/lgKxGmF26Ii8fPeUlQCGV/NTtms0= 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/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 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8= 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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= 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 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= 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.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 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 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= 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/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 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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= 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 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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.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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= 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 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 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.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 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= 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= 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/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/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.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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 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/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 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= 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.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.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.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 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 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.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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= 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/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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= 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 h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= 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= 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/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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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.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.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.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.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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= 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/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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= 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-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-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-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 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 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= 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/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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.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-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-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-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-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-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 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= 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-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-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-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-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-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-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-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-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-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-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.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 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 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.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.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 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= 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-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-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-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.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 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 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= 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/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 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= 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 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/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/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.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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+45 -2
View File
@@ -55,11 +55,15 @@ type App struct {
knowledgeIndexer *knowledge.Indexer // 知识库索引器(用于动态初始化) knowledgeIndexer *knowledge.Indexer // 知识库索引器(用于动态初始化)
knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化) knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化)
agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器) agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器)
robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信) robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel robotMu sync.Mutex // 保护机器人长连接的 cancel
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启 dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启 larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
wechatCancel context.CancelFunc // 微信 iLink 长轮询取消函数 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) c2Manager *c2.Manager // C2 管理器(未启用 C2 时为 nil)
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗 c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
c2WatchdogCancel context.CancelFunc // 看门狗取消函数 c2WatchdogCancel context.CancelFunc // 看门狗取消函数
@@ -728,6 +732,26 @@ func (a *App) startRobotConnections() {
a.wechatCancel = cancel a.wechatCancel = cancel
go robot.StartWechat(ctx, cfg.Robots, a.robotHandler, cfg.Version, a.logger.Logger) 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 // RestartRobotConnections 重启钉钉/飞书/微信长连接,使前端应用配置后立即生效(实现 handler.RobotRestarter
@@ -745,6 +769,22 @@ func (a *App) RestartRobotConnections() {
a.wechatCancel() a.wechatCancel()
a.wechatCancel = nil 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() a.robotMu.Unlock()
// 给旧 goroutine 一点时间退出 // 给旧 goroutine 一点时间退出
time.Sleep(200 * time.Millisecond) time.Sleep(200 * time.Millisecond)
@@ -1199,8 +1239,11 @@ func setupRoutes(
// 图编排 / 工作流定义(图结构固定,业务字段保存在 graph_json 中) // 图编排 / 工作流定义(图结构固定,业务字段保存在 graph_json 中)
protected.GET("/workflows/runs/pending", workflowHandler.ListPendingRuns) protected.GET("/workflows/runs/pending", workflowHandler.ListPendingRuns)
protected.GET("/workflows/runs/:runId/replay", workflowHandler.ReplayRun)
protected.GET("/workflows/runs/:runId", workflowHandler.GetRun) protected.GET("/workflows/runs/:runId", workflowHandler.GetRun)
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun) protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
protected.POST("/workflows/validate", workflowHandler.Validate)
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
protected.GET("/workflows", workflowHandler.List) protected.GET("/workflows", workflowHandler.List)
protected.GET("/workflows/:id", workflowHandler.Get) protected.GET("/workflows/:id", workflowHandler.Get)
protected.POST("/workflows", workflowHandler.Create) protected.POST("/workflows", workflowHandler.Create)
+41 -6
View File
@@ -453,13 +453,17 @@ type MultiAgentAPIUpdate struct {
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"` ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
} }
// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink 等) // RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink、Telegram、Slack、Discord、QQ 等)
type RobotsConfig struct { type RobotsConfig struct {
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略 Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定) Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定)
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信 Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉 Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,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 协议) // RobotWechatConfig 微信 iLink 机器人配置(个人微信 ClawBot / iLink 协议)
@@ -525,6 +529,37 @@ type RobotLarkConfig struct {
AllowChatIDFallback bool `yaml:"allow_chat_id_fallback" json:"allow_chat_id_fallback"` // 用户 ID 缺失时是否允许回退到 chat_id 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 { type ServerConfig struct {
Host string `yaml:"host" json:"host"` Host string `yaml:"host" json:"host"`
Port int `yaml:"port" json:"port"` Port int `yaml:"port" json:"port"`
+58 -14
View File
@@ -22,20 +22,20 @@ type WorkflowDefinition struct {
} }
type WorkflowRun struct { type WorkflowRun struct {
ID string `json:"id"` ID string `json:"id"`
WorkflowID string `json:"workflow_id"` WorkflowID string `json:"workflow_id"`
WorkflowVersion int `json:"workflow_version"` WorkflowVersion int `json:"workflow_version"`
ConversationID string `json:"conversation_id,omitempty"` ConversationID string `json:"conversation_id,omitempty"`
ProjectID string `json:"project_id,omitempty"` ProjectID string `json:"project_id,omitempty"`
RoleID string `json:"role_id,omitempty"` RoleID string `json:"role_id,omitempty"`
Status string `json:"status"` Status string `json:"status"`
InputJSON string `json:"input_json,omitempty"` InputJSON string `json:"input_json,omitempty"`
OutputJSON string `json:"output_json,omitempty"` OutputJSON string `json:"output_json,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
PendingHITLNodeID string `json:"pending_hitl_node_id,omitempty"` PendingHITLNodeID string `json:"pending_hitl_node_id,omitempty"`
PendingHITLJSON string `json:"pending_hitl_json,omitempty"` PendingHITLJSON string `json:"pending_hitl_json,omitempty"`
StartedAt time.Time `json:"started_at"` StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"`
} }
type WorkflowNodeRun struct { type WorkflowNodeRun struct {
@@ -50,6 +50,25 @@ type WorkflowNodeRun struct {
FinishedAt *time.Time `json:"finished_at,omitempty"` 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 { func scanWorkflowDefinition(scanner interface {
Scan(dest ...interface{}) error Scan(dest ...interface{}) error
}) (*WorkflowDefinition, error) { }) (*WorkflowDefinition, error) {
@@ -248,6 +267,31 @@ func (db *DB) FinishWorkflowNodeRun(nodeRunID, status, outputJSON, errText strin
return nil 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 { func scanWorkflowRun(scanner interface {
Scan(dest ...interface{}) error Scan(dest ...interface{}) error
}) (*WorkflowRun, error) { }) (*WorkflowRun, error) {
+7
View File
@@ -1529,6 +1529,10 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
flusher, _ := c.Writer.(http.Flusher) flusher, _ := c.Writer.(http.Flusher)
ctx := c.Request.Context() ctx := c.Request.Context()
var writeMu sync.Mutex
stopKeepalive := make(chan struct{})
go sseKeepalive(c, stopKeepalive, &writeMu)
defer close(stopKeepalive)
for { for {
select { select {
@@ -1538,12 +1542,15 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
if !ok { if !ok {
return return
} }
writeMu.Lock()
if _, err := c.Writer.Write(chunk); err != nil { if _, err := c.Writer.Write(chunk); err != nil {
writeMu.Unlock()
return return
} }
if flusher != nil { if flusher != nil {
flusher.Flush() flusher.Flush()
} }
writeMu.Unlock()
} }
} }
} }
+26
View File
@@ -808,6 +808,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled), zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled),
zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled), zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled),
zap.Bool("lark_enabled", h.config.Robots.Lark.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),
) )
} }
@@ -1870,6 +1874,28 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret) setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret)
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken) setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
setBoolInMap(larkNode, "allow_chat_id_fallback", cfg.Lark.AllowChatIDFallback) 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) { func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
+57
View File
@@ -41,6 +41,12 @@ type workflowSaveRequest struct {
GraphJSON json.RawMessage `json:"graph_json,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) { func (h *WorkflowHandler) List(c *gin.Context) {
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1" includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
items, err := h.db.ListWorkflowDefinitions(includeDisabled) items, err := h.db.ListWorkflowDefinitions(includeDisabled)
@@ -69,6 +75,57 @@ func (h *WorkflowHandler) Create(c *gin.Context) {
h.save(c, "") 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) { func (h *WorkflowHandler) Update(c *gin.Context) {
h.save(c, c.Param("id")) h.save(c, c.Param("id"))
} }
+39 -5
View File
@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"encoding/json"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -28,7 +29,40 @@ func (h *WorkflowHandler) GetRun(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "工作流运行不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "工作流运行不存在"})
return return
} }
c.JSON(http.StatusOK, gin.H{"run": run}) 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) { func (h *WorkflowHandler) ListPendingRuns(c *gin.Context) {
@@ -99,10 +133,10 @@ func (h *WorkflowHandler) ResumeRun(c *gin.Context) {
} }
if delegated { if delegated {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"workflowRunId": runID, "workflowRunId": runID,
"status": "delegated", "status": "delegated",
"streamResuming": true, "streamResuming": true,
"approved": req.Approved, "approved": req.Approved,
}) })
return return
} }
+11 -2
View File
@@ -1,9 +1,13 @@
package multiagent package multiagent
import "strings" import (
"strings"
"sync"
)
// MCPExecutionBinder maps ADK toolCallID → MCP monitor execution ID for a single agent run. // MCPExecutionBinder maps ADK toolCallID → MCP monitor execution ID for a single agent run.
type MCPExecutionBinder struct { type MCPExecutionBinder struct {
mu sync.RWMutex
byToolCall map[string]string byToolCall map[string]string
} }
@@ -20,12 +24,17 @@ func (b *MCPExecutionBinder) Bind(toolCallID, executionID string) {
if tid == "" || eid == "" { if tid == "" || eid == "" {
return return
} }
b.mu.Lock()
b.byToolCall[tid] = eid b.byToolCall[tid] = eid
b.mu.Unlock()
} }
func (b *MCPExecutionBinder) ExecutionID(toolCallID string) string { func (b *MCPExecutionBinder) ExecutionID(toolCallID string) string {
if b == nil { if b == nil {
return "" 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 package multiagent
import "testing" import (
"fmt"
"sync"
"testing"
)
func TestMCPExecutionBinder(t *testing.T) { func TestMCPExecutionBinder(t *testing.T) {
b := NewMCPExecutionBinder() b := NewMCPExecutionBinder()
@@ -12,3 +16,28 @@ func TestMCPExecutionBinder(t *testing.T) {
t.Fatalf("expected empty, got %q", got) 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)
}
}
+21 -4
View File
@@ -8,7 +8,15 @@ import (
"github.com/cloudwego/eino/adk/prebuilt/planexecute" "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) { func newPlanExecuteExecutor(ctx context.Context, cfg *planexecute.ExecutorConfig, handlers []adk.ChatModelAgentMiddleware) (adk.Agent, error) {
if cfg == nil { if cfg == nil {
return nil, fmt.Errorf("plan_execute: ExecutorConfig 为空") return nil, fmt.Errorf("plan_execute: ExecutorConfig 为空")
@@ -25,18 +33,27 @@ func newPlanExecuteExecutor(ctx context.Context, cfg *planexecute.ExecutorConfig
if !ok { if !ok {
return nil, fmt.Errorf("plan_execute executor: session value %q missing (possible session corruption)", planexecute.PlanSessionKey) 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) userInput, ok := adk.GetSessionValue(ctx, planexecute.UserInputSessionKey)
if !ok { if !ok {
return nil, fmt.Errorf("plan_execute executor: session value %q missing (possible session corruption)", planexecute.UserInputSessionKey) 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 var executedSteps_ []planexecute.ExecutedStep
executedStep, ok := adk.GetSessionValue(ctx, planexecute.ExecutedStepsSessionKey) executedStep, ok := adk.GetSessionValue(ctx, planexecute.ExecutedStepsSessionKey)
if ok { 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{ in := &planexecute.ExecutionContext{
+9
View File
@@ -100,6 +100,14 @@ func RunDeepAgent(
if orchMode == "supervisor" && len(effectiveSubs) == 0 { if orchMode == "supervisor" && len(effectiveSubs) == 0 {
return nil, fmt.Errorf("multi_agent.orchestration=supervisor 时需至少配置一个子代理(sub_agents 或 agents 目录 Markdown") 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) einoLoc, einoSkillMW, einoFSTools, skillsRoot, einoErr := prepareEinoSkills(ctx, appCfg.SkillsDir, ma, logger)
if einoErr != nil { if einoErr != nil {
@@ -351,6 +359,7 @@ func RunDeepAgent(
sb.WriteString("\n- ") sb.WriteString("\n- ")
sb.WriteString(sa.Name(ctx)) sb.WriteString(sa.Name(ctx))
} }
sb.WriteString("\n\nSupervisor 是专家路由模式:仅当任务确实需要不同专家分工时才 transfer;简单查询、单步工具调用或无需专业分流的任务由你直接完成。避免在同一子代理之间反复 transfer;除非有新的、具体的补充目标。专家返回后,你必须自行汇总、裁剪、校验证据,再用 exit 交付最终答案。")
sb.WriteString("\n\n当你已完成用户目标或需要将最终结论交付用户时,使用 exit 工具结束。") sb.WriteString("\n\n当你已完成用户目标或需要将最终结论交付用户时,使用 exit 工具结束。")
supInstr = sb.String() supInstr = sb.String()
} }
+121
View File
@@ -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 GatewayWebSocket,无需公网回调)。
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
}
}
}
+209
View File
@@ -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
}
+38
View File
@@ -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
}
}
}
+135
View File
@@ -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
}
}
}
+29
View File
@@ -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)
}
+262
View File
@@ -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
}
+28 -4
View File
@@ -9,15 +9,39 @@ import (
// compileAgentSubgraph wraps an Agent canvas node as an Eino subgraph (AddGraphNode best practice). // compileAgentSubgraph wraps an Agent canvas node as an Eino subgraph (AddGraphNode best practice).
func compileAgentSubgraph(_ context.Context, node graphNode) (compose.AnyGraph, error) { func compileAgentSubgraph(_ context.Context, node graphNode) (compose.AnyGraph, error) {
n := node n := node
innerID := n.ID + "__agent" prepareID := n.ID + "__agent_prepare"
executeID := n.ID + "__agent_execute"
finalizeID := n.ID + "__agent_finalize"
g := compose.NewGraph[WorkflowNodeOutput, WorkflowNodeOutput]() g := compose.NewGraph[WorkflowNodeOutput, WorkflowNodeOutput]()
_ = g.AddLambdaNode(innerID, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) { _ = 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) return runWorkflowNodeLambda(runCtx, n)
})) }))
if err := g.AddEdge(compose.START, innerID); err != nil { _ = 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 return nil, err
} }
if err := g.AddEdge(innerID, compose.END); err != nil { 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 nil, err
} }
return g, nil return g, nil
+12
View File
@@ -52,20 +52,32 @@ func resolveBinding(b FieldBinding, state *WorkflowLocalState) any {
field = "output" field = "output"
} }
if from == "" || from == "previous" || from == "prev" { if from == "" || from == "previous" || from == "prev" {
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
return evalJSONPathValue(state.LastOutput, field)
}
if field == "output" && state.LastOutput != nil { if field == "output" && state.LastOutput != nil {
return state.LastOutput["output"] return state.LastOutput["output"]
} }
return valueFromPath("previous."+field, state) return valueFromPath("previous."+field, state)
} }
if from == "inputs" || from == "input" { if from == "inputs" || from == "input" {
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
return evalJSONPathValue(state.Inputs, field)
}
if field == "" { if field == "" {
return state.Inputs return state.Inputs
} }
return valueFromPath("inputs."+field, state) return valueFromPath("inputs."+field, state)
} }
if from == "outputs" { if from == "outputs" {
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
return evalJSONPathValue(state.Outputs, field)
}
return valueFromPath("outputs."+field, state) return valueFromPath("outputs."+field, state)
} }
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
return evalJSONPathValue(valueFromPath(from, state), field)
}
return valueFromPath(from+"."+field, state) return valueFromPath(from+"."+field, state)
} }
+173
View File
@@ -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
}
+36 -7
View File
@@ -62,10 +62,13 @@ func extractAwaitingHITL(err error, art *compiledArtifact, runID string, args Ru
label := firstNonEmpty(node.Label, nodeID) label := firstNonEmpty(node.Label, nodeID)
if args.DB != nil { if args.DB != nil {
pending := map[string]any{ pending := map[string]any{
"nodeId": nodeID, "nodeId": nodeID,
"label": label, "label": label,
"prompt": prompt, "prompt": prompt,
"reviewer": cfgString(node.Config, "reviewer"), "reviewer": cfgString(node.Config, "reviewer"),
"checkpointId": runID,
"interrupt": workflowInterruptMetadata(info),
"resumePayload": map[string]any{"approved": "bool", "comment": "string"},
} }
pendingJSON, _ := json.Marshal(pending) pendingJSON, _ := json.Marshal(pending)
_ = args.DB.SetWorkflowRunAwaitingHITL(runID, nodeID, string(pendingJSON)) _ = args.DB.SetWorkflowRunAwaitingHITL(runID, nodeID, string(pendingJSON))
@@ -90,6 +93,30 @@ func extractAwaitingHITL(err error, art *compiledArtifact, runID string, args Ru
} }
} }
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 { func nextHITLNodeID(info *compose.InterruptInfo, hitlIDs []string) string {
if info != nil && len(info.BeforeNodes) > 0 { if info != nil && len(info.BeforeNodes) > 0 {
for _, id := range info.BeforeNodes { for _, id := range info.BeforeNodes {
@@ -176,9 +203,9 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
if err != nil { if err != nil {
if IsAwaitingHITL(err) { if IsAwaitingHITL(err) {
return &RunResult{ return &RunResult{
RunID: runID, RunID: runID,
Status: "awaiting_hitl", Status: "awaiting_hitl",
Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID), Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID),
AwaitingHITL: true, AwaitingHITL: true,
}, nil }, nil
} }
@@ -194,6 +221,7 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
"workflowRunId": runID, "workflowRunId": runID,
"status": "completed", "status": "completed",
"outputs": state.Outputs, "outputs": state.Outputs,
"metrics": state.Metrics,
"executedNodes": state.Executed, "executedNodes": state.Executed,
"skippedNodes": state.Skipped, "skippedNodes": state.Skipped,
"engine": "eino_workflow", "engine": "eino_workflow",
@@ -206,6 +234,7 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
"workflowRunId": runID, "workflowRunId": runID,
"workflowId": wf.ID, "workflowId": wf.ID,
"outputs": state.Outputs, "outputs": state.Outputs,
"metrics": state.Metrics,
"response": response, "response": response,
"engine": "eino_workflow", "engine": "eino_workflow",
}) })
+132
View File
@@ -3,6 +3,7 @@ package workflow
import ( import (
"context" "context"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
@@ -58,6 +59,137 @@ func TestValidateGraphJSON_linear(t *testing.T) {
} }
} }
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) { func TestCompileEngine_linear(t *testing.T) {
ctx := context.Background() ctx := context.Background()
SetCheckpointDir(t.TempDir()) SetCheckpointDir(t.TempDir())
+14 -11
View File
@@ -17,16 +17,16 @@ type compiledArtifact struct {
// Engine compiles and caches Eino Workflow artifacts. // Engine compiles and caches Eino Workflow artifacts.
type Engine struct { type Engine struct {
mu sync.RWMutex mu sync.RWMutex
cache map[string]*compiledArtifact cache map[string]*compiledArtifact
cpStore compose.CheckPointStore cpStore compose.CheckPointStore
cpStoreMu sync.Once cpStoreMu sync.Once
cpStoreErr error cpStoreErr error
checkpointDir string checkpointDir string
} }
var defaultEngine = &Engine{ var defaultEngine = &Engine{
cache: make(map[string]*compiledArtifact), cache: make(map[string]*compiledArtifact),
checkpointDir: "data/workflow-checkpoints", checkpointDir: "data/workflow-checkpoints",
} }
@@ -69,11 +69,8 @@ func ValidateGraphJSON(ctx context.Context, graphJSON string) error {
return err return err
} }
idx := indexGraph(g) idx := indexGraph(g)
if len(findStartNodeIDs(idx)) == 0 { if err := validateGraphDefinition(g, idx); err != nil {
return fmt.Errorf("工作流缺少可执行的起点节点") return err
}
if !hasTerminalNode(idx) {
return fmt.Errorf("工作流至少需要一个无出边的终点或 output/end 节点")
} }
_, err = defaultEngine.compile(ctx, g) _, err = defaultEngine.compile(ctx, g)
return err return err
@@ -120,6 +117,9 @@ func (e *Engine) compile(ctx context.Context, g *graphDef) (*compiledArtifact, e
return nil, err return nil, err
} }
idx := indexGraph(g) idx := indexGraph(g)
if err := validateGraphDefinition(g, idx); err != nil {
return nil, err
}
hitlIDs := collectHITLNodeIDs(idx) hitlIDs := collectHITLNodeIDs(idx)
compileOpts := []compose.GraphCompileOption{ compileOpts := []compose.GraphCompileOption{
compose.WithGraphName("CyberStrikeWorkflow"), compose.WithGraphName("CyberStrikeWorkflow"),
@@ -219,6 +219,9 @@ func runWorkflowNodeLambda(runCtx context.Context, n graphNode) (WorkflowNodeOut
if localRT == nil { if localRT == nil {
return nil, fmt.Errorf("workflow runtime missing in context") 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) result, proceed, err := executeNode(runCtx, localRT.args, localRT.runID, n, localRT.state)
if err != nil { if err != nil {
return nil, err return nil, err
+4 -4
View File
@@ -4,11 +4,11 @@ import "errors"
// AwaitingHITLError indicates the workflow paused before a HITL node for human approval. // AwaitingHITLError indicates the workflow paused before a HITL node for human approval.
type AwaitingHITLError struct { type AwaitingHITLError struct {
RunID string RunID string
NodeID string NodeID string
NodeLabel string NodeLabel string
Prompt string Prompt string
Reviewer string Reviewer string
} }
func (e *AwaitingHITLError) Error() string { func (e *AwaitingHITLError) Error() string {
+186
View File
@@ -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
}
+107
View File
@@ -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()
}
+117
View File
@@ -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
}
+115
View File
@@ -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
}
+57
View File
@@ -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)
}
}
}
}
+23 -1
View File
@@ -18,12 +18,21 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
label = node.ID label = node.ID
} }
nodeRunID := uuid.NewString() 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{ input := map[string]any{
"nodeId": node.ID, "nodeId": node.ID,
"nodeType": node.Type, "nodeType": node.Type,
"label": label, "label": label,
"inputs": state.Inputs, "inputs": state.Inputs,
"previous": state.LastOutput, "previous": state.LastOutput,
"join": map[string]any{
"strategy": joinStrategy(node),
"incoming": incomingCount,
},
} }
inputJSON, _ := json.Marshal(input) inputJSON, _ := json.Marshal(input)
if err := args.DB.CreateWorkflowNodeRun(&database.WorkflowNodeRun{ if err := args.DB.CreateWorkflowNodeRun(&database.WorkflowNodeRun{
@@ -32,7 +41,7 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
NodeID: node.ID, NodeID: node.ID,
Status: "running", Status: "running",
InputJSON: string(inputJSON), InputJSON: string(inputJSON),
StartedAt: time.Now(), StartedAt: startedAt,
}); err != nil { }); err != nil {
return nil, false, err return nil, false, err
} }
@@ -47,6 +56,18 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
} }
result, proceed, status, errText := runBuiltinNode(ctx, args, node, state) 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) outputJSON, _ := json.Marshal(result)
if err := args.DB.FinishWorkflowNodeRun(nodeRunID, status, string(outputJSON), errText); err != nil { if err := args.DB.FinishWorkflowNodeRun(nodeRunID, status, string(outputJSON), errText); err != nil {
return nil, false, err return nil, false, err
@@ -64,6 +85,7 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
"nodeType": node.Type, "nodeType": node.Type,
"label": label, "label": label,
"status": status, "status": status,
"durationMs": duration.Milliseconds(),
"output": result, "output": result,
} }
progressMsg := fmt.Sprintf("节点完成:%s%s", label, status) progressMsg := fmt.Sprintf("节点完成:%s%s", label, status)
+17 -38
View File
@@ -13,18 +13,11 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
cfg := node.Config cfg := node.Config
switch strings.ToLower(strings.TrimSpace(node.Type)) { switch strings.ToLower(strings.TrimSpace(node.Type)) {
case "start": case "start":
out := map[string]any{ return startOutputMap(node, state.Inputs["message"], state.Inputs["conversationId"], state.Inputs["projectId"]), true, "completed", ""
"output": state.Inputs["message"],
"message": state.Inputs["message"],
"conversationId": state.Inputs["conversationId"],
"projectId": state.Inputs["projectId"],
}
return out, true, "completed", ""
case "condition": case "condition":
expr := cfgString(cfg, "expression") expr := cfgString(cfg, "expression")
ok := evalCondition(expr, state) ok := evalCondition(expr, state)
out := map[string]any{"output": ok, "condition": expr, "matched": ok} return conditionOutputMap(node, expr, ok), true, "completed", ""
return out, true, "completed", ""
case "output": case "output":
key := cfgString(cfg, "output_key") key := cfgString(cfg, "output_key")
if key == "" { if key == "" {
@@ -37,13 +30,13 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
value = resolveOutputSourceBinding(cfg, state) value = resolveOutputSourceBinding(cfg, state)
} }
state.Outputs[key] = value state.Outputs[key] = value
return map[string]any{"output": value, "outputs": map[string]any{key: value}}, true, "completed", "" return outputNodeOutputMap(node, key, value), true, "completed", ""
case "end": case "end":
value := resolveOutputSourceBinding(cfg, state) value := resolveOutputSourceBinding(cfg, state)
if b, ok := parseFieldBinding(cfg, "result_binding"); ok { if b, ok := parseFieldBinding(cfg, "result_binding"); ok {
value = resolveBinding(b, state) value = resolveBinding(b, state)
} }
return map[string]any{"output": value}, false, "completed", "" return endOutputMap(node, value), false, "completed", ""
case "tool": case "tool":
return runToolNode(ctx, args, node, state) return runToolNode(ctx, args, node, state)
case "agent": case "agent":
@@ -52,7 +45,8 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
return runHITLNode(args, node, state) return runHITLNode(args, node, state)
default: default:
reason := "未知节点类型" reason := "未知节点类型"
return map[string]any{"output": "", "skipped": true, "reason": reason, "node_type": node.Type}, true, "skipped", reason out := outputMap(envelope("unknown", node.ID, node.Type, "skipped", ""), map[string]any{"skipped": true, "reason": reason})
return out, true, "skipped", reason
} }
} }
@@ -60,16 +54,16 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
toolName := cfgString(node.Config, "tool_name") toolName := cfgString(node.Config, "tool_name")
if toolName == "" { if toolName == "" {
errText := "工具节点未选择 MCP 工具" errText := "工具节点未选择 MCP 工具"
return map[string]any{"output": "", "error": errText}, false, "failed", errText return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
} }
if args.Agent == nil { if args.Agent == nil {
errText := "工具节点执行失败:Agent 为空" errText := "工具节点执行失败:Agent 为空"
return map[string]any{"output": "", "tool_name": toolName, "error": errText}, false, "failed", errText 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) toolArgs, err := resolveToolArguments(node.Config, state)
if err != nil { if err != nil {
errText := fmt.Sprintf("工具参数不是合法 JSON%v", err) errText := fmt.Sprintf("工具参数不是合法 JSON%v", err)
return map[string]any{"output": "", "tool_name": toolName, "error": errText}, false, "failed", errText return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "error": errText}), false, "failed", errText
} }
if args.Progress != nil { if args.Progress != nil {
args.Progress("workflow_tool_start", fmt.Sprintf("调用工具:%s", toolName), map[string]any{ args.Progress("workflow_tool_start", fmt.Sprintf("调用工具:%s", toolName), map[string]any{
@@ -81,7 +75,7 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
result, err := args.Agent.ExecuteMCPToolForConversation(ctx, args.ConversationID, toolName, toolArgs) result, err := args.Agent.ExecuteMCPToolForConversation(ctx, args.ConversationID, toolName, toolArgs)
if err != nil { if err != nil {
errText := err.Error() errText := err.Error()
return map[string]any{"output": "", "tool_name": toolName, "arguments": toolArgs, "error": errText}, false, "failed", errText return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "arguments": toolArgs, "error": errText}), false, "failed", errText
} }
output := "" output := ""
executionID := "" executionID := ""
@@ -91,13 +85,7 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
executionID = result.ExecutionID executionID = result.ExecutionID
isError = result.IsError isError = result.IsError
} }
out := map[string]any{ out := toolOutputMap(node, output, toolName, toolArgs, executionID, isError)
"output": output,
"tool_name": toolName,
"arguments": toolArgs,
"execution_id": executionID,
"is_error": isError,
}
if key := cfgString(node.Config, "output_key"); key != "" { if key := cfgString(node.Config, "output_key"); key != "" {
state.Outputs[key] = output state.Outputs[key] = output
} }
@@ -114,7 +102,7 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) { func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
if args.AppCfg == nil || args.Agent == nil { if args.AppCfg == nil || args.Agent == nil {
errText := "Agent 节点执行失败:应用配置或 Agent 为空" errText := "Agent 节点执行失败:应用配置或 Agent 为空"
return map[string]any{"output": "", "error": errText}, false, "failed", errText 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")) mode := strings.ToLower(cfgString(node.Config, "agent_mode"))
if mode == "" { if mode == "" {
@@ -167,7 +155,7 @@ func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *Work
if err != nil { if err != nil {
errText := err.Error() errText := err.Error()
state.MainIterationOffset += state.SegmentMaxIteration state.MainIterationOffset += state.SegmentMaxIteration
return map[string]any{"output": "", "mode": mode, "error": errText}, false, "failed", errText return outputMap(envelope("agent", node.ID, node.Type, "failed", ""), map[string]any{"mode": mode, "error": errText}), false, "failed", errText
} }
state.MainIterationOffset += state.SegmentMaxIteration state.MainIterationOffset += state.SegmentMaxIteration
response := "" response := ""
@@ -189,11 +177,7 @@ func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *Work
if key := cfgString(node.Config, "output_key"); key != "" { if key := cfgString(node.Config, "output_key"); key != "" {
state.Outputs[key] = response state.Outputs[key] = response
} }
return map[string]any{ return agentOutputMap(node, response, mode, mcpIDs), true, "completed", ""
"output": response,
"mode": mode,
"mcp_execution_ids": mcpIDs,
}, true, "completed", ""
} }
func buildAgentNodeMessage(node graphNode, state *WorkflowLocalState, upstreamInput string) string { func buildAgentNodeMessage(node graphNode, state *WorkflowLocalState, upstreamInput string) string {
@@ -221,6 +205,7 @@ func workflowAgentProgress(progress agent.ProgressCallback, state *WorkflowLocal
return return
default: default:
enrichWorkflowAgentEventData(data, state, node) enrichWorkflowAgentEventData(data, state, node)
collectAgentMetrics(state, data)
if eventType == "iteration" { if eventType == "iteration" {
applyWorkflowMainIterationOffset(data, state) applyWorkflowMainIterationOffset(data, state)
} }
@@ -302,7 +287,7 @@ func runHITLNode(args RunArgs, node graphNode, state *WorkflowLocalState) (map[s
} }
} }
} }
return map[string]any{"output": "", "prompt": prompt, "approved": false, "mode": "interactive"}, false, "failed", reason return hitlOutputMap(node, "failed", "", prompt, reviewer, false), false, "failed", reason
} }
if args.Progress != nil { if args.Progress != nil {
args.Progress("workflow_hitl_checkpoint", "人工确认节点已通过", map[string]any{ args.Progress("workflow_hitl_checkpoint", "人工确认节点已通过", map[string]any{
@@ -313,11 +298,5 @@ func runHITLNode(args RunArgs, node graphNode, state *WorkflowLocalState) (map[s
"approved": true, "approved": true,
}) })
} }
return map[string]any{ return hitlOutputMap(node, "completed", prompt, prompt, reviewer, true), true, "completed", ""
"output": prompt,
"prompt": prompt,
"reviewer": reviewer,
"approved": true,
"mode": "interactive",
}, true, "completed", ""
} }
+2
View File
@@ -189,6 +189,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error)
"workflowRunId": runID, "workflowRunId": runID,
"status": "completed", "status": "completed",
"outputs": state.Outputs, "outputs": state.Outputs,
"metrics": state.Metrics,
"executedNodes": state.Executed, "executedNodes": state.Executed,
"skippedNodes": state.Skipped, "skippedNodes": state.Skipped,
"engine": "eino_workflow", "engine": "eino_workflow",
@@ -204,6 +205,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error)
"workflowRunId": runID, "workflowRunId": runID,
"workflowId": wf.ID, "workflowId": wf.ID,
"outputs": state.Outputs, "outputs": state.Outputs,
"metrics": state.Metrics,
"response": response, "response": response,
"engine": "eino_workflow", "engine": "eino_workflow",
}) })
+6 -23
View File
@@ -20,6 +20,7 @@ type WorkflowLocalState struct {
NodeOutputs map[string]map[string]any `json:"nodeOutputs,omitempty"` NodeOutputs map[string]map[string]any `json:"nodeOutputs,omitempty"`
NodeProceed map[string]bool `json:"nodeProceed,omitempty"` NodeProceed map[string]bool `json:"nodeProceed,omitempty"`
LastOutput map[string]any `json:"lastOutput,omitempty"` LastOutput map[string]any `json:"lastOutput,omitempty"`
Metrics map[string]any `json:"metrics,omitempty"`
Executed []string `json:"executed,omitempty"` Executed []string `json:"executed,omitempty"`
Skipped []string `json:"skipped,omitempty"` Skipped []string `json:"skipped,omitempty"`
WorkflowRunID string `json:"workflowRunId,omitempty"` WorkflowRunID string `json:"workflowRunId,omitempty"`
@@ -33,10 +34,11 @@ func newWorkflowLocalState(inputs map[string]interface{}, runID string) *Workflo
in[k] = v in[k] = v
} }
return &WorkflowLocalState{ return &WorkflowLocalState{
Inputs: in, Inputs: in,
Outputs: make(map[string]any), Outputs: make(map[string]any),
NodeOutputs: make(map[string]map[string]any), NodeOutputs: make(map[string]map[string]any),
NodeProceed: make(map[string]bool), NodeProceed: make(map[string]bool),
Metrics: make(map[string]any),
WorkflowRunID: runID, WorkflowRunID: runID,
} }
} }
@@ -91,25 +93,6 @@ func valueFromPath(path string, state *WorkflowLocalState) any {
return cur return cur
} }
func evalCondition(expr string, state *WorkflowLocalState) bool {
expr = strings.TrimSpace(expr)
if expr == "" {
return true
}
resolved := strings.TrimSpace(resolveTemplate(expr, state))
switch {
case strings.Contains(resolved, "!="):
parts := strings.SplitN(resolved, "!=", 2)
return cleanComparable(parts[0]) != cleanComparable(parts[1])
case strings.Contains(resolved, "=="):
parts := strings.SplitN(resolved, "==", 2)
return cleanComparable(parts[0]) == cleanComparable(parts[1])
default:
v := strings.ToLower(cleanComparable(resolved))
return v != "" && v != "false" && v != "0" && v != "null"
}
}
func cleanComparable(s string) string { func cleanComparable(s string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
s = strings.Trim(s, `"'`) s = strings.Trim(s, `"'`)
+150
View File
@@ -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,
})
}
+366
View File
@@ -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
}
+19 -3
View File
@@ -2411,13 +2411,21 @@ html[data-theme="dark"] .c2-session-main-empty__icon {
width: 100%; width: 100%;
max-width: 540px; max-width: 540px;
max-height: 85vh; max-height: 85vh;
overflow-y: auto; overflow: hidden;
box-shadow: var(--c2-shadow-lg); box-shadow: var(--c2-shadow-lg);
border: 1px solid var(--c2-border); border: 1px solid var(--c2-border);
animation: c2-slide-up 0.18s ease-out; animation: c2-slide-up 0.18s ease-out;
contain: layout style paint; 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 { @keyframes c2-slide-up {
from { opacity: 0; transform: translateY(12px) scale(0.98); } from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
@@ -2427,6 +2435,7 @@ html[data-theme="dark"] .c2-session-main-empty__icon {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex: 0 0 auto;
padding: 24px 28px 20px; padding: 24px 28px 20px;
border-bottom: 1px solid var(--c2-border); border-bottom: 1px solid var(--c2-border);
} }
@@ -2439,15 +2448,22 @@ html[data-theme="dark"] .c2-session-main-empty__icon {
/* .c2-modal-close 样式见 style.css 统一关闭按钮 */ /* .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 { .c2-modal-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
flex: 0 0 auto;
padding: 16px 28px; padding: 16px 28px;
border-top: 1px solid var(--c2-border); border-top: 1px solid var(--c2-border);
background: var(--c2-surface-alt); background: var(--c2-surface);
border-radius: 0 0 16px 16px; border-radius: 0 0 16px 16px;
} }
+1177 -47
View File
File diff suppressed because it is too large Load Diff
+86 -6
View File
@@ -597,6 +597,7 @@
"knowledgeRetrievalTag": "Knowledge retrieval", "knowledgeRetrievalTag": "Knowledge retrieval",
"error": "Error", "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.", "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", "taskCancelled": "Task cancelled",
"userInterruptContinueTitle": "⏸️ User interrupt & continue", "userInterruptContinueTitle": "⏸️ User interrupt & continue",
"unknownTool": "Unknown tool", "unknownTool": "Unknown tool",
@@ -635,15 +636,15 @@
"agentModeEinoSingle": "Eino single (ADK)", "agentModeEinoSingle": "Eino single (ADK)",
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner with MCP tools (/api/eino-agent)", "agentModeEinoSingleHint": "Eino ChatModelAgent + Runner with MCP tools (/api/eino-agent)",
"agentModeDeep": "Deep (DeepAgent)", "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", "agentModePlanExecuteLabel": "Plan-Execute",
"agentModePlanExecuteHint": "Plan → execute → replan (single executor with tools)", "agentModePlanExecuteHint": "Plan → execute → replan (single executor with tools)",
"agentModeSupervisorLabel": "Supervisor", "agentModeSupervisorLabel": "Supervisor (Expert routing)",
"agentModeSupervisorHint": "Supervisor coordinates via transfer to sub-agents", "agentModeSupervisorHint": "Expert-routing scenarios: route across multiple specialist sub-agents via transfer",
"agentModeSingle": "Single-agent", "agentModeSingle": "Single-agent",
"agentModeMulti": "Multi-agent", "agentModeMulti": "Multi-agent",
"agentModeSingleHint": "Eino ADK single-agent for chat and tool use", "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", "reasoningModeLabel": "Model reasoning",
"reasoningEffortLabel": "Reasoning effort", "reasoningEffortLabel": "Reasoning effort",
"reasoningModeDefault": "Use system default", "reasoningModeDefault": "Use system default",
@@ -1243,6 +1244,17 @@
"robots": { "robots": {
"title": "Bot settings", "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.", "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": { "wechat": {
"title": "WeChat / iLink", "title": "WeChat / iLink",
"subtitle": "Bind personal WeChat via QR code and chat with CyberStrikeAI on your phone", "subtitle": "Bind personal WeChat via QR code and chat with CyberStrikeAI on your phone",
@@ -1271,6 +1283,7 @@
}, },
"wecom": { "wecom": {
"title": "WeCom", "title": "WeCom",
"subtitle": "HTTP callback mode for enterprise internal app bots",
"enabled": "Enable WeCom bot", "enabled": "Enable WeCom bot",
"token": "Token", "token": "Token",
"tokenPlaceholder": "Token", "tokenPlaceholder": "Token",
@@ -1285,6 +1298,7 @@
}, },
"dingtalk": { "dingtalk": {
"title": "DingTalk", "title": "DingTalk",
"subtitle": "Enterprise internal app bot using Stream connection",
"enabled": "Enable DingTalk bot", "enabled": "Enable DingTalk bot",
"clientIdLabel": "Client ID (AppKey)", "clientIdLabel": "Client ID (AppKey)",
"clientIdPlaceholder": "DingTalk App Key", "clientIdPlaceholder": "DingTalk App Key",
@@ -1294,6 +1308,7 @@
}, },
"lark": { "lark": {
"title": "Lark", "title": "Lark",
"subtitle": "Enterprise app bot for direct chat and group mentions",
"enabled": "Enable Lark bot", "enabled": "Enable Lark bot",
"appIdLabel": "App ID", "appIdLabel": "App ID",
"appIdPlaceholder": "Lark/Feishu App ID", "appIdPlaceholder": "Lark/Feishu App ID",
@@ -1301,6 +1316,40 @@
"appSecretPlaceholder": "Lark/Feishu App Secret", "appSecretPlaceholder": "Lark/Feishu App Secret",
"verifyTokenLabel": "Verify Token (Optional)", "verifyTokenLabel": "Verify Token (Optional)",
"verifyTokenPlaceholder": "Event subscription Verification Token" "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": { "apply": {
@@ -2160,10 +2209,10 @@
"enableMultiAgent": "Enable Eino multi-agent", "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.", "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", "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)", "multiAgentOrchDeep": "deep — DeepAgent (task sub-agents)",
"multiAgentOrchPlanExecute": "plan_execute — plan / execute / replan", "multiAgentOrchPlanExecute": "plan_execute — plan / execute / replan",
"multiAgentOrchSupervisor": "supervisor — supervisor + transfer", "multiAgentOrchSupervisor": "supervisor — expert routing (transfer)",
"multiAgentPeLoop": "plan_execute outer loop limit", "multiAgentPeLoop": "plan_execute outer loop limit",
"multiAgentPeLoopPlaceholder": "0 uses Eino default (10)", "multiAgentPeLoopPlaceholder": "0 uses Eino default (10)",
"multiAgentPeLoopHint": "Only for plan_execute; max execute↔replan rounds.", "multiAgentPeLoopHint": "Only for plan_execute; max execute↔replan rounds.",
@@ -2516,6 +2565,8 @@
}, },
"settingsRobotsExtra": { "settingsRobotsExtra": {
"botCommandsTitle": "Bot command instructions", "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):", "botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
"botCmdCategoryGeneral": "General", "botCmdCategoryGeneral": "General",
"botCmdCategoryConversation": "Conversation", "botCmdCategoryConversation": "Conversation",
@@ -2937,12 +2988,21 @@
"metaName": "Name", "metaName": "Name",
"metaDescription": "Description", "metaDescription": "Description",
"metaEnabled": "Enabled", "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", "namePlaceholder": "Basic Web scan",
"descriptionPlaceholder": "Optional", "descriptionPlaceholder": "Optional",
"connect": "Connect", "connect": "Connect",
"connecting": "Connecting", "connecting": "Connecting",
"deleteSelected": "Delete selected", "deleteSelected": "Delete selected",
"autoLayout": "Auto layout", "autoLayout": "Auto layout",
"dryRun": "Dry run",
"canvasEmpty": "Drag nodes from the left onto the canvas, or click node buttons to add quickly", "canvasEmpty": "Drag nodes from the left onto the canvas, or click node buttons to add quickly",
"properties": "Properties", "properties": "Properties",
"nodeProperties": "Node properties", "nodeProperties": "Node properties",
@@ -2987,10 +3047,18 @@
"outputKey": "Output variable name", "outputKey": "Output variable name",
"conditionExpression": "Condition expression", "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.", "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", "edgeCondition": "Edge condition",
"edgeBranch": "Condition branch",
"selectBranch": "Select",
"edgeConditionHintCondition": "{{previous.matched}} == \"true\" (Yes) or == \"false\" (No)", "edgeConditionHintCondition": "{{previous.matched}} == \"true\" (Yes) or == \"false\" (No)",
"edgeConditionHintExample": "e.g. {{previous.output}} == \"ok\"", "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.", "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", "hitlPrompt": "Approval prompt",
"hitlPromptPlaceholder": "Approve to continue", "hitlPromptPlaceholder": "Approve to continue",
"hitlReviewer": "Reviewer", "hitlReviewer": "Reviewer",
@@ -3009,6 +3077,11 @@
"nodeFallback": "Node {{n}}", "nodeFallback": "Node {{n}}",
"loadFailed": "Failed to load workflows", "loadFailed": "Failed to load workflows",
"saveFailed": "Failed to save workflow", "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", "deleteFailed": "Failed to delete workflow",
"saved": "Workflow saved", "saved": "Workflow saved",
"deleted": "Workflow deleted", "deleted": "Workflow deleted",
@@ -3030,6 +3103,13 @@
"conditionNeedsExpr": "Condition node {{label}} requires a condition expression", "conditionNeedsExpr": "Condition node {{label}} requires a condition expression",
"conditionNeedsOutEdge": "Condition node {{label}} needs at least one outgoing edge (Yes/No branch)", "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", "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" "outputNeedsKey": "Output node {{label}} requires an output variable name"
} }
}, },
+86 -6
View File
@@ -585,6 +585,7 @@
"knowledgeRetrievalTag": "知识检索", "knowledgeRetrievalTag": "知识检索",
"error": "错误", "error": "错误",
"streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。", "streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。",
"streamEndedWithoutDone": "连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部「运行中」任务或刷新当前对话。",
"taskCancelled": "任务已取消", "taskCancelled": "任务已取消",
"userInterruptContinueTitle": "⏸️ 用户中断并继续", "userInterruptContinueTitle": "⏸️ 用户中断并继续",
"unknownTool": "未知工具", "unknownTool": "未知工具",
@@ -623,15 +624,15 @@
"agentModeEinoSingle": "Eino 单代理(ADK", "agentModeEinoSingle": "Eino 单代理(ADK",
"agentModeEinoSingleHint": "Eino ChatModelAgent + RunnerMCP 工具(/api/eino-agent", "agentModeEinoSingleHint": "Eino ChatModelAgent + RunnerMCP 工具(/api/eino-agent",
"agentModeDeep": "DeepDeepAgent", "agentModeDeep": "DeepDeepAgent",
"agentModeDeepHint": "Eino DeepAgenttask 调度子代理", "agentModeDeepHint": "Eino DeepAgent适合复杂安全测试、多阶段 task 子代理委派与汇总",
"agentModePlanExecuteLabel": "Plan-Execute", "agentModePlanExecuteLabel": "Plan-Execute",
"agentModePlanExecuteHint": "规划 → 执行 → 重规划(单执行器带工具)", "agentModePlanExecuteHint": "规划 → 执行 → 重规划(单执行器带工具)",
"agentModeSupervisorLabel": "Supervisor", "agentModeSupervisorLabel": "Supervisor(专家路由)",
"agentModeSupervisorHint": "监督者协调,transfer 委派子代理", "agentModeSupervisorHint": "专家路由场景:监督者通过 transfer 动态分派多个专业子代理",
"agentModeSingle": "单代理", "agentModeSingle": "单代理",
"agentModeMulti": "多代理", "agentModeMulti": "多代理",
"agentModeSingleHint": "Eino ADK 单代理,适合常规对话与工具调用", "agentModeSingleHint": "Eino ADK 单代理,适合常规对话与工具调用",
"agentModeMultiHint": "Eino 预置编排deep / plan_execute / supervisor),适合复杂任务", "agentModeMultiHint": "Eino 预置编排Deep 适合复杂任务,Plan-Execute 适合结构化闭环,Supervisor 适合专家路由",
"reasoningModeLabel": "模型推理", "reasoningModeLabel": "模型推理",
"reasoningEffortLabel": "推理强度", "reasoningEffortLabel": "推理强度",
"reasoningModeDefault": "跟随系统", "reasoningModeDefault": "跟随系统",
@@ -1231,6 +1232,17 @@
"robots": { "robots": {
"title": "机器人设置", "title": "机器人设置",
"description": "配置微信、企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。", "description": "配置微信、企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
"managerTitle": "机器人管理",
"managerDesc": "先选择机器人类型,再进入对应配置;已配置的平台会显示连接状态。",
"newBot": "新建机器人",
"configure": "配置",
"statusNotConfigured": "未配置",
"statusConfigured": "已配置",
"statusEnabled": "已启用",
"emptyTitle": "选择一个机器人开始配置",
"emptyDesc": "从上方列表点击已有平台,或点击“新建机器人”按类型创建。",
"createTitle": "新建机器人",
"createDesc": "选择机器人类型后进入专属配置。当前版本每个平台支持一个内置机器人配置。",
"wechat": { "wechat": {
"title": "微信 / iLink", "title": "微信 / iLink",
"subtitle": "扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话", "subtitle": "扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话",
@@ -1259,6 +1271,7 @@
}, },
"wecom": { "wecom": {
"title": "企业微信", "title": "企业微信",
"subtitle": "HTTP 回调模式,适合企业内部应用机器人",
"enabled": "启用企业微信机器人", "enabled": "启用企业微信机器人",
"token": "Token", "token": "Token",
"tokenPlaceholder": "Token", "tokenPlaceholder": "Token",
@@ -1273,6 +1286,7 @@
}, },
"dingtalk": { "dingtalk": {
"title": "钉钉", "title": "钉钉",
"subtitle": "企业内部应用机器人,使用 Stream 长连接",
"enabled": "启用钉钉机器人", "enabled": "启用钉钉机器人",
"clientIdLabel": "Client ID (AppKey)", "clientIdLabel": "Client ID (AppKey)",
"clientIdPlaceholder": "钉钉应用 AppKey", "clientIdPlaceholder": "钉钉应用 AppKey",
@@ -1282,6 +1296,7 @@
}, },
"lark": { "lark": {
"title": "飞书 (Lark)", "title": "飞书 (Lark)",
"subtitle": "飞书企业应用机器人,支持单聊与群聊 @",
"enabled": "启用飞书机器人", "enabled": "启用飞书机器人",
"appIdLabel": "App ID", "appIdLabel": "App ID",
"appIdPlaceholder": "飞书应用 App ID", "appIdPlaceholder": "飞书应用 App ID",
@@ -1289,6 +1304,40 @@
"appSecretPlaceholder": "飞书应用 App Secret", "appSecretPlaceholder": "飞书应用 App Secret",
"verifyTokenLabel": "Verify Token(可选)", "verifyTokenLabel": "Verify Token(可选)",
"verifyTokenPlaceholder": "事件订阅 Verification 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 开放平台 WebSocketC2C 与群 @",
"enabled": "启用 QQ 机器人",
"appId": "App ID",
"clientSecret": "Client Secret",
"secretHint": "从 QQ 机器人开放平台获取;上线前可勾选沙箱",
"sandbox": "沙箱环境"
} }
}, },
"apply": { "apply": {
@@ -2148,10 +2197,10 @@
"enableMultiAgent": "启用 Eino 多代理", "enableMultiAgent": "启用 Eino 多代理",
"enableMultiAgentHint": "开启后对话页可选「多代理」模式;子代理在 multi_agent.sub_agents 或 agents 目录配置;编排方式见下方「预置编排」。", "enableMultiAgentHint": "开启后对话页可选「多代理」模式;子代理在 multi_agent.sub_agents 或 agents 目录配置;编排方式见下方「预置编排」。",
"multiAgentOrchestration": "多代理预置编排", "multiAgentOrchestration": "多代理预置编排",
"multiAgentOrchestrationHint": "deep=DeepAgent+taskplan_execute=规划/执行/重规划(单执行器工具链)supervisor=监督者+transfer。保存并应用后生效。", "multiAgentOrchestrationHint": "deep=DeepAgent+taskplan_execute=规划/执行/重规划;supervisor=专家路由(transfer,特定场景)。保存并应用后生效。",
"multiAgentOrchDeep": "deep — DeepAgenttask 子代理)", "multiAgentOrchDeep": "deep — DeepAgenttask 子代理)",
"multiAgentOrchPlanExecute": "plan_execute — 规划 / 执行 / 重规划", "multiAgentOrchPlanExecute": "plan_execute — 规划 / 执行 / 重规划",
"multiAgentOrchSupervisor": "supervisor — 监督者 + transfer", "multiAgentOrchSupervisor": "supervisor — 专家路由(transfer",
"multiAgentPeLoop": "plan_execute 外层循环上限", "multiAgentPeLoop": "plan_execute 外层循环上限",
"multiAgentPeLoopPlaceholder": "0 表示 Eino 默认 10", "multiAgentPeLoopPlaceholder": "0 表示 Eino 默认 10",
"multiAgentPeLoopHint": "仅 plan_execute 有效;execute 与 replan 之间的最大轮次。", "multiAgentPeLoopHint": "仅 plan_execute 有效;execute 与 replan 之间的最大轮次。",
@@ -2504,6 +2553,8 @@
}, },
"settingsRobotsExtra": { "settingsRobotsExtra": {
"botCommandsTitle": "机器人命令说明", "botCommandsTitle": "机器人命令说明",
"botCommandsEntryDesc": "查看机器人对话中可用的中英文命令,包括对话、角色、项目等常用操作。",
"viewAllCommands": "查看全部命令",
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):", "botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
"botCmdCategoryGeneral": "通用", "botCmdCategoryGeneral": "通用",
"botCmdCategoryConversation": "对话", "botCmdCategoryConversation": "对话",
@@ -2925,12 +2976,21 @@
"metaName": "名称", "metaName": "名称",
"metaDescription": "描述", "metaDescription": "描述",
"metaEnabled": "启用", "metaEnabled": "启用",
"metaIdHint": "创建后不可修改,用于 API 与角色绑定",
"metaModalTitle": "流程信息",
"editMeta": "编辑",
"untitled": "未命名流程",
"toggleEnabled": "启用/禁用",
"metaEnabledHint": "禁用后无法被角色自动触发",
"enabledUpdated": "启用状态已更新",
"enabledUpdateFailed": "更新启用状态失败",
"namePlaceholder": "基础 Web 扫描", "namePlaceholder": "基础 Web 扫描",
"descriptionPlaceholder": "可选", "descriptionPlaceholder": "可选",
"connect": "连线", "connect": "连线",
"connecting": "连线中", "connecting": "连线中",
"deleteSelected": "删除选中", "deleteSelected": "删除选中",
"autoLayout": "自动布局", "autoLayout": "自动布局",
"dryRun": "试运行",
"canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加", "canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加",
"properties": "属性", "properties": "属性",
"nodeProperties": "节点属性", "nodeProperties": "节点属性",
@@ -2975,10 +3035,18 @@
"outputKey": "输出变量名", "outputKey": "输出变量名",
"conditionExpression": "条件表达式", "conditionExpression": "条件表达式",
"conditionHint": "节点会计算 matchedtrue/false),由出边决定分支:第一条线为「是」,第二条为「否」;也可在连线上写 <code>{{previous.matched}} == \"true\"</code>。", "conditionHint": "节点会计算 matchedtrue/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": "连线条件", "edgeCondition": "连线条件",
"edgeBranch": "条件分支",
"selectBranch": "请选择",
"edgeConditionHintCondition": "{{previous.matched}} == \"true\"(是)或 == \"false\"(否)", "edgeConditionHintCondition": "{{previous.matched}} == \"true\"(是)或 == \"false\"(否)",
"edgeConditionHintExample": "例如: {{previous.output}} == \"ok\"", "edgeConditionHintExample": "例如: {{previous.output}} == \"ok\"",
"edgeBranchHint": "从条件节点连出的第一条线默认为「是」分支,第二条为「否」分支;也可在此自定义条件。", "edgeBranchHint": "从条件节点连出的第一条线默认为「是」分支,第二条为「否」分支;也可在此自定义条件。",
"joinStrategy": "汇聚策略",
"joinStrategyHint": "当多个上游同时进入该节点时,决定如何生成 previous:合并全部、按画布顺序取最后、取第一个非空或上游失败即中止。",
"hitlPrompt": "审批提示", "hitlPrompt": "审批提示",
"hitlPromptPlaceholder": "请审批是否继续", "hitlPromptPlaceholder": "请审批是否继续",
"hitlReviewer": "审批方", "hitlReviewer": "审批方",
@@ -2997,6 +3065,11 @@
"nodeFallback": "节点 {{n}}", "nodeFallback": "节点 {{n}}",
"loadFailed": "加载工作流失败", "loadFailed": "加载工作流失败",
"saveFailed": "保存工作流失败", "saveFailed": "保存工作流失败",
"dryRunPrompt": "输入试运行消息",
"dryRunFailed": "试运行失败",
"dryRunDone": "试运行完成",
"dryRunTrace": "试运行轨迹",
"dryRunNoTrace": "暂无轨迹",
"deleteFailed": "删除工作流失败", "deleteFailed": "删除工作流失败",
"saved": "工作流已保存", "saved": "工作流已保存",
"deleted": "工作流已删除", "deleted": "工作流已删除",
@@ -3018,6 +3091,13 @@
"conditionNeedsExpr": "条件节点 {{label}} 需要条件表达式", "conditionNeedsExpr": "条件节点 {{label}} 需要条件表达式",
"conditionNeedsOutEdge": "条件节点 {{label}} 至少需要一条出边(是/否分支)", "conditionNeedsOutEdge": "条件节点 {{label}} 至少需要一条出边(是/否分支)",
"conditionTooManyEdges": "条件节点 {{label}} 建议最多两条出边(是/否);第三条及以后需配置连线条件", "conditionTooManyEdges": "条件节点 {{label}} 建议最多两条出边(是/否);第三条及以后需配置连线条件",
"conditionBranchLabel": "条件节点 {{label}} 的出边必须标记为是/否",
"conditionBranchDuplicate": "条件节点 {{label}} 存在重复分支",
"nodeNeedsIncoming": "节点 {{label}} 需要入边",
"nodeNeedsOutgoing": "节点 {{label}} 需要出边",
"nodeUnreachable": "节点 {{label}} 无法从开始节点到达",
"graphCycle": "工作流存在环路,请保持 DAG",
"serverFailed": "后端图校验失败",
"outputNeedsKey": "输出节点 {{label}} 需要输出变量名" "outputNeedsKey": "输出节点 {{label}} 需要输出变量名"
} }
}, },
+21
View File
@@ -1105,7 +1105,11 @@ async function sendMessage() {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
let streamSawDone = false;
const dispatchStreamEvent = function (eventData) { const dispatchStreamEvent = function (eventData) {
if (eventData && eventData.type === 'done') {
streamSawDone = true;
}
handleStreamEvent(eventData, progressElement, progressId, handleStreamEvent(eventData, progressElement, progressId,
() => assistantMessageId, (id) => { assistantMessageId = id; }, () => assistantMessageId, (id) => { assistantMessageId = id; },
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; }); () => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
@@ -1142,6 +1146,23 @@ async function sendMessage() {
const lines = buffer.split('\n'); const lines = buffer.split('\n');
await processSseLines(lines, dispatchStreamEvent); 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 { } finally {
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null }; window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
if (window.CyberStrikeChatScroll) { if (window.CyberStrikeChatScroll) {
+1
View File
@@ -13,6 +13,7 @@
'agent-md-modal', 'agent-md-modal',
'batch-manage-modal', 'batch-manage-modal',
'create-group-modal', 'create-group-modal',
'workflow-meta-modal',
'login-overlay', 'login-overlay',
]); ]);
+75 -23
View File
@@ -3331,7 +3331,11 @@ async function attachRunningTaskEventStream(conversationId) {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buffer = ''; let buffer = '';
let replaySawDone = false;
const dispatchTaskEvent = function (eventData) { 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 || []); }); handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
}; };
while (true) { while (true) {
@@ -3351,14 +3355,14 @@ async function attachRunningTaskEventStream(conversationId) {
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) { if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
clearCsTaskReplay(); clearCsTaskReplay();
} }
if (progressTaskState.has(progressId)) { if (replaySawDone && progressTaskState.has(progressId)) {
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成'); finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
} }
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') { if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
window.CyberStrikeChatScroll.onTaskEventStreamEnd(); window.CyberStrikeChatScroll.onTaskEventStreamEnd();
} }
if (typeof loadActiveTasks === 'function') loadActiveTasks(); 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); await window.loadConversation(conversationId);
} }
return true; return true;
@@ -4373,6 +4377,8 @@ function buildMcpTimelineSvg(points, rangeKey) {
const maxVal = Math.max(1, ...points.map((p) => p.total || 0)); const maxVal = Math.max(1, ...points.map((p) => p.total || 0));
const hasFailed = points.some((p) => (p.failed || 0) > 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 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 coords = points.map((p, i) => {
const x = padL + (points.length <= 1 ? plotW / 2 : (i / (points.length - 1)) * plotW); const x = padL + (points.length <= 1 ? plotW / 2 : (i / (points.length - 1)) * plotW);
@@ -4429,6 +4435,21 @@ function buildMcpTimelineSvg(points, rangeKey) {
data-failed="${c.p.failed || 0}" />`; data-failed="${c.p.failed || 0}" />`;
}).join(''); }).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 peakC = coords[peakIdx];
const peakMarker = (peakC.p.total || 0) > 0 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" />` ? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="5" />`
@@ -4449,6 +4470,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
</defs> </defs>
${yLines} ${yLines}
<path class="mcp-stats-timeline-area" d="${areaPath}" fill="url(#mcpTimelineAreaFill)" /> <path class="mcp-stats-timeline-area" d="${areaPath}" fill="url(#mcpTimelineAreaFill)" />
${bars}
${peakMarker} ${peakMarker}
<path class="mcp-stats-timeline-line" d="${linePath}" stroke="url(#mcpTimelineLineStroke)" /> <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}" />` : ''} ${hasFailed ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--fail" d="${failPath}" />` : ''}
@@ -4480,14 +4502,17 @@ function bindMcpStatsTimelineEvents() {
} }
root.addEventListener('mousemove', function (e) { 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) { if (!dot || !mcpTimelineTooltipEl) {
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active')); 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'; mcpTimelineTooltipEl.style.display = 'none';
return; return;
} }
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active')); 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-active');
dot.classList.add('is-hover');
const time = dot.getAttribute('data-time') || ''; const time = dot.getAttribute('data-time') || '';
const total = dot.getAttribute('data-total') || '0'; const total = dot.getAttribute('data-total') || '0';
const failed = dot.getAttribute('data-failed') || '0'; const failed = dot.getAttribute('data-failed') || '0';
@@ -4503,6 +4528,7 @@ function bindMcpStatsTimelineEvents() {
if (!e.target.closest || !e.target.closest('.mcp-stats-combined__timeline, .mcp-stats-timeline')) return; if (!e.target.closest || !e.target.closest('.mcp-stats-combined__timeline, .mcp-stats-timeline')) return;
if (e.relatedTarget && root.contains(e.relatedTarget)) 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-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'; if (mcpTimelineTooltipEl) mcpTimelineTooltipEl.style.display = 'none';
}); });
@@ -4564,6 +4590,37 @@ function buildTimelineSparseHint(points, timeline) {
|| `该时段多数时间为 0,峰值 ${peak} 次出现在 ${peakTime}`; || `该时段多数时间为 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) { async function setMcpMonitorTimelineRange(range) {
if (!MCP_TIMELINE_RANGES.includes(range)) return; if (!MCP_TIMELINE_RANGES.includes(range)) return;
localStorage.setItem('mcpMonitorTimelineRange', range); localStorage.setItem('mcpMonitorTimelineRange', range);
@@ -4639,12 +4696,14 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
const failLegend = mcpMonitorT('timelineFailedLegend') || '失败'; const failLegend = mcpMonitorT('timelineFailedLegend') || '失败';
const hasFailed = points.some((p) => (p.failed || 0) > 0); const hasFailed = points.some((p) => (p.failed || 0) > 0);
const sparseHint = buildTimelineSparseHint(points, timeline); const sparseHint = buildTimelineSparseHint(points, timeline);
const momentsHtml = renderMcpTimelineActiveMoments(points, rangeKey);
const sparseHtml = sparseHint const sparseHtml = sparseHint
? `<p class="mcp-stats-timeline__sparse-hint">${escapeHtml(sparseHint)}</p>` ? `<p class="mcp-stats-timeline__sparse-hint">${escapeHtml(sparseHint)}</p>`
: ''; : '';
return ` return `
<p class="mcp-stats-timeline__inline-meta">${escapeHtml(hint)} · ${escapeHtml(summaryText)}</p> <p class="mcp-stats-timeline__inline-meta">${escapeHtml(hint)} · ${escapeHtml(summaryText)}</p>
${momentsHtml}
<div class="mcp-stats-timeline__chart-wrap">${chartSvg}</div> <div class="mcp-stats-timeline__chart-wrap">${chartSvg}</div>
${sparseHtml} ${sparseHtml}
<div class="mcp-stats-timeline__legend"> <div class="mcp-stats-timeline__legend">
@@ -5290,10 +5349,6 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const caption = mcpMonitorT('rankingSummary', { n: MCP_STATS_TOP_N, pct: topNSharePct, total: totals.total }) const caption = mcpMonitorT('rankingSummary', { n: MCP_STATS_TOP_N, pct: topNSharePct, total: totals.total })
|| `Top ${MCP_STATS_TOP_N}${topNSharePct}% · 共 ${totals.total}`; || `Top ${MCP_STATS_TOP_N}${topNSharePct}% · 共 ${totals.total}`;
const unknownToolLabel = mcpMonitorT('unknownTool') || '未知工具'; 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 distAria = mcpMonitorT('distTitle') || '调用分布';
const stackedHtml = segments.map((s) => { const stackedHtml = segments.map((s) => {
@@ -5332,20 +5387,27 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const failNote = failed > 0 const failNote = failed > 0
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>` ? `<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' : ''}" return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}"
data-tool-name="${escapeHtml(rawName)}" tabindex="0" role="button" data-tool-name="${escapeHtml(rawName)}" tabindex="0" role="button"
aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}"> aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span> <div class="mcp-stats-tool-item__top">
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span> <span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
<div class="mcp-stats-tool-item__body"> <span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
<span class="mcp-stats-tool-item__name" title="${escapeHtml(name)}">${escapeHtml(name)}</span> <span class="mcp-stats-tool-item__name" title="${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__track" aria-hidden="true">
<span class="mcp-stats-tool-item__fill" style="width:${barPct}%;background:${color}"></span> <span class="mcp-stats-tool-item__fill" style="width:${barPct}%;background:${color}"></span>
</span> </span>
</div> </div>
<div class="mcp-stats-tool-item__metrics"> <div class="mcp-stats-tool-item__bottom">
<span class="mcp-stats-tool-item__share">${sharePct}%</span> <span class="mcp-stats-tool-item__pill is-success">${escapeHtml(successLabel)}</span>
<span class="mcp-stats-tool-item__calls">${total}</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> <span class="mcp-stats-tool-item__rate ${rateClass}">${toolRate}%${failNote}</span>
</div> </div>
</li>`; </li>`;
@@ -5360,16 +5422,6 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
${escapeHtml(caption)} ${escapeHtml(caption)}
</p> </p>
</div> </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> <ol class="mcp-stats-tools-panel__list">${listHtml}</ol>
</div>`; </div>`;
} }
+5 -1
View File
@@ -2152,13 +2152,17 @@ function appendChatProjectPanelItem(list, project, selectedId, onSelect, tFn) {
const t = tFn || tp; const t = tFn || tp;
const isNone = !project.id; const isNone = !project.id;
const isSelected = isNone ? !selectedId : selectedId === project.id; const isSelected = isNone ? !selectedId : selectedId === project.id;
const fullDesc = isNone
? (project.description || '')
: (project.description || '').trim() || t('projects.sharedFactBoard');
const desc = isNone const desc = isNone
? (project.description || '') ? (project.description || '')
: (project.description || '').trim().slice(0, 80) || t('projects.sharedFactBoard'); : fullDesc.slice(0, 80);
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : ''); btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
btn.setAttribute('role', 'option'); btn.setAttribute('role', 'option');
btn.setAttribute('data-selection-detail', fullDesc);
btn.onclick = () => onSelect(project.id || ''); btn.onclick = () => onSelect(project.id || '');
btn.innerHTML = ` btn.innerHTML = `
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div> <div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
+82
View File
@@ -24,6 +24,79 @@ function rolePlainDescription(role) {
if (raw === 'roles.noDescription' || raw === 'roles.noDescriptionShort') return ''; if (raw === 'roles.noDescription' || raw === 'roles.noDescriptionShort') return '';
return raw; 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 currentRole = localStorage.getItem('currentRole') || '';
let roles = []; let roles = [];
let rolesSearchKeyword = ''; // 角色搜索关键词 let rolesSearchKeyword = ''; // 角色搜索关键词
@@ -209,10 +282,18 @@ function renderRoleSelectionSidebar() {
const isSelected = isDefaultRole ? (currentRole === '' || currentRole === '默认') : (currentRole === role.name); const isSelected = isDefaultRole ? (currentRole === '' || currentRole === '默认') : (currentRole === role.name);
const roleItem = document.createElement('div'); const roleItem = document.createElement('div');
roleItem.className = 'role-selection-item-main' + (isSelected ? ' selected' : ''); roleItem.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
roleItem.setAttribute('role', 'option');
roleItem.tabIndex = 0;
roleItem.onclick = () => { roleItem.onclick = () => {
selectRole(role.name); selectRole(role.name);
closeRoleSelectionPanel(); // 选择后自动关闭面板 closeRoleSelectionPanel(); // 选择后自动关闭面板
}; };
roleItem.onkeydown = (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
roleItem.click();
}
};
const icon = getRoleIcon(role); const icon = getRoleIcon(role);
// 处理默认角色的描述 // 处理默认角色的描述
@@ -221,6 +302,7 @@ function renderRoleSelectionSidebar() {
if (isDefaultRole && !plainDesc) { if (isDefaultRole && !plainDesc) {
description = _t('roles.defaultRoleDescription'); description = _t('roles.defaultRoleDescription');
} }
roleItem.setAttribute('data-selection-detail', description);
roleItem.innerHTML = ` roleItem.innerHTML = `
<div class="role-selection-item-icon-main">${icon}</div> <div class="role-selection-item-icon-main">${icon}</div>
+209
View File
@@ -6,6 +6,156 @@ let alwaysVisibleBuiltinToolNames = new Set();
// 全局工具状态映射,用于保存用户在所有页面的修改 // 全局工具状态映射,用于保存用户在所有页面的修改
// key: 唯一工具标识符(toolKey),value: { enabled: boolean, is_external: boolean, external_mcp: string } // key: 唯一工具标识符(toolKey),value: { enabled: boolean, is_external: boolean, external_mcp: string }
let toolStateMap = new Map(); 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) { function getToolKey(tool) {
@@ -500,6 +650,10 @@ async function loadConfig(loadTools = true) {
const wecom = robots.wecom || {}; const wecom = robots.wecom || {};
const dingtalk = robots.dingtalk || {}; const dingtalk = robots.dingtalk || {};
const lark = robots.lark || {}; 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'); const wechatEnabled = document.getElementById('robot-wechat-enabled');
if (wechatEnabled) wechatEnabled.checked = wechat.enabled === true; if (wechatEnabled) wechatEnabled.checked = wechat.enabled === true;
const wechatBase = document.getElementById('robot-wechat-base-url'); const wechatBase = document.getElementById('robot-wechat-base-url');
@@ -539,6 +693,36 @@ async function loadConfig(loadTools = true) {
if (larkAppSecret) larkAppSecret.value = lark.app_secret || ''; if (larkAppSecret) larkAppSecret.value = lark.app_secret || '';
const larkVerify = document.getElementById('robot-lark-verify-token'); const larkVerify = document.getElementById('robot-lark-verify-token');
if (larkVerify) larkVerify.value = 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管理页面需要,系统设置页面不需要) // 只有在需要时才加载工具列表(MCP管理页面需要,系统设置页面不需要)
if (loadTools) { if (loadTools) {
@@ -1425,6 +1609,31 @@ async function applySettings() {
app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '', app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '',
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '', verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '',
allow_chat_id_fallback: !!(prevRobots.lark && prevRobots.lark.allow_chat_id_fallback) 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: [] tools: []
+16 -10
View File
@@ -223,12 +223,13 @@ function wsRenderRoleList() {
var html = ''; var html = '';
// 默认角色 // 默认角色
var defSelected = !cur ? ' selected' : ''; var defSelected = !cur ? ' selected' : '';
html += '<button type="button" class="role-selection-item-main' + defSelected + '" onclick="wsSelectRole(\'\')">' + var defDesc = wsTOr('roles.defaultRoleDescription', '默认角色,不额外携带用户提示词,使用所有工具');
html += '<button type="button" class="role-selection-item-main' + defSelected + '" data-selection-detail="' + escapeHtmlAttr(defDesc) + '" onclick="wsSelectRole(\'\')">' +
'<div class="role-selection-item-icon-main">\ud83d\udd35</div>' + '<div class="role-selection-item-icon-main">\ud83d\udd35</div>' +
'<div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + '<div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' +
(wsTOr('chat.defaultRole', '默认')) + (wsTOr('chat.defaultRole', '默认')) +
'</div><div class="role-selection-item-description-main">' + '</div><div class="role-selection-item-description-main">' +
(wsTOr('roles.defaultRoleDescription', '默认角色,不额外携带用户提示词,使用所有工具')) + escapeHtml(defDesc) +
'</div></div>' + '</div></div>' +
(defSelected ? '<div class="role-selection-checkmark-main">\u2713</div>' : '') + (defSelected ? '<div class="role-selection-checkmark-main">\u2713</div>' : '') +
'</button>'; '</button>';
@@ -238,10 +239,11 @@ function wsRenderRoleList() {
if (!r.enabled) continue; if (!r.enabled) continue;
if (r.name === '默认') continue; // 已在上方硬编码默认角色,跳过 API 返回的默认项 if (r.name === '默认') continue; // 已在上方硬编码默认角色,跳过 API 返回的默认项
var sel = (r.name === cur) ? ' selected' : ''; var sel = (r.name === cur) ? ' selected' : '';
html += '<button type="button" class="role-selection-item-main' + sel + '" onclick="wsSelectRole(\'' + r.name.replace(/'/g, "\\'") + '\')">' + var desc = r.description || '';
'<div class="role-selection-item-icon-main">' + (r.icon || '\ud83d\udd35') + '</div>' + html += '<button type="button" class="role-selection-item-main' + sel + '" data-selection-detail="' + escapeHtmlAttr(desc) + '" onclick="wsSelectRole(\'' + r.name.replace(/'/g, "\\'") + '\')">' +
'<div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + r.name + '</div>' + '<div class="role-selection-item-icon-main">' + escapeHtml(r.icon || '\ud83d\udd35') + '</div>' +
'<div class="role-selection-item-description-main">' + (r.description || '').substring(0, 60) + '</div></div>' + '<div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + escapeHtml(r.name) + '</div>' +
'<div class="role-selection-item-description-main">' + escapeHtml(desc.substring(0, 60)) + '</div></div>' +
(sel ? '<div class="role-selection-checkmark-main">\u2713</div>' : '') + (sel ? '<div class="role-selection-checkmark-main">\u2713</div>' : '') +
'</button>'; '</button>';
} }
@@ -1136,6 +1138,10 @@ function escapeHtml(s) {
return div.innerHTML; return div.innerHTML;
} }
function escapeHtmlAttr(s) {
return escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeSingleQuotedShellArg(value) { function escapeSingleQuotedShellArg(value) {
var s = value == null ? '' : String(value); var s = value == null ? '' : String(value);
return "'" + s.replace(/'/g, "'\\''") + "'"; return "'" + s.replace(/'/g, "'\\''") + "'";
@@ -2260,10 +2266,10 @@ function selectWebshell(id, stateReady) {
'<button type="button" class="role-selection-panel-close" onclick="wsCloseAgentModePanel()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' + '<button type="button" class="role-selection-panel-close" onclick="wsCloseAgentModePanel()"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>' +
'</div>' + '</div>' +
'<div class="agent-mode-options">' + '<div class="agent-mode-options">' +
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="eino_single" role="option" onclick="wsSelectAgentMode(\'eino_single\')"><div class="role-selection-item-icon-main">\u26a1</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeEinoSingle') || 'Eino 单代理(ADK') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeEinoSingleHint') || 'Eino ChatModelAgent + Runner') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single">\u2713</div></button>' + '<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="eino_single" role="option" onclick="wsSelectAgentMode(\'eino_single\')" data-agent-mode-detail="' + escapeHtmlAttr(wsT('chat.agentModeEinoSingleHint') || 'Eino ChatModelAgent + Runner') + '"><div class="role-selection-item-icon-main">\u26a1</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeEinoSingle') || 'Eino 单代理(ADK') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeEinoSingleHint') || 'Eino ChatModelAgent + Runner') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single">\u2713</div></button>' +
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="deep" role="option" onclick="wsSelectAgentMode(\'deep\')"><div class="role-selection-item-icon-main">\ud83e\udde9</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeDeep') || 'DeepDeepAgent') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeDeepHint') || 'Eino DeepAgenttask 调度子代理') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">\u2713</div></button>' + '<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="deep" role="option" onclick="wsSelectAgentMode(\'deep\')" data-agent-mode-detail="' + escapeHtmlAttr(wsT('chat.agentModeDeepHint') || 'Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总') + '"><div class="role-selection-item-icon-main">\ud83e\udde9</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeDeep') || 'DeepDeepAgent') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeDeepHint') || 'Eino DeepAgent适合复杂安全测试、多阶段 task 子代理委派与汇总') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">\u2713</div></button>' +
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="plan_execute" role="option" onclick="wsSelectAgentMode(\'plan_execute\')"><div class="role-selection-item-icon-main">\ud83d\udccb</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModePlanExecuteLabel') || 'Plan-Execute') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModePlanExecuteHint') || '规划 → 执行 → 重规划') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute">\u2713</div></button>' + '<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="plan_execute" role="option" onclick="wsSelectAgentMode(\'plan_execute\')" data-agent-mode-detail="' + escapeHtmlAttr(wsT('chat.agentModePlanExecuteHint') || '规划 → 执行 → 重规划') + '"><div class="role-selection-item-icon-main">\ud83d\udccb</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModePlanExecuteLabel') || 'Plan-Execute') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModePlanExecuteHint') || '规划 → 执行 → 重规划') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute">\u2713</div></button>' +
'<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="supervisor" role="option" onclick="wsSelectAgentMode(\'supervisor\')"><div class="role-selection-item-icon-main">\ud83c\udfaf</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeSupervisorLabel') || 'Supervisor') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeSupervisorHint') || '监督者协调,transfer 委派子代理') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="supervisor">\u2713</div></button>' + '<button type="button" class="role-selection-item-main agent-mode-option ws-agent-mode-option" data-value="supervisor" role="option" onclick="wsSelectAgentMode(\'supervisor\')" data-agent-mode-detail="' + escapeHtmlAttr(wsT('chat.agentModeSupervisorHint') || '专家路由场景:监督者通过 transfer 动态分派多个专业子代理') + '"><div class="role-selection-item-icon-main">\ud83c\udfaf</div><div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + (wsT('chat.agentModeSupervisorLabel') || 'Supervisor(专家路由)') + '</div><div class="role-selection-item-description-main">' + (wsT('chat.agentModeSupervisorHint') || '专家路由场景:监督者通过 transfer 动态分派多个专业子代理') + '</div></div><div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="supervisor">\u2713</div></button>' +
'</div></div></div>' + '</div></div></div>' +
'<input type="hidden" id="ws-agent-mode-select" value="eino_single" autocomplete="off" />' + '<input type="hidden" id="ws-agent-mode-select" value="eino_single" autocomplete="off" />' +
'</div>' + '</div>' +
+9
View File
@@ -170,6 +170,9 @@ function showWechatBoundUI(wechat) {
if (btn) { if (btn) {
btn.textContent = wechatT('settings.robots.wechat.rebindButton', '重新绑定'); btn.textContent = wechatT('settings.robots.wechat.rebindButton', '重新绑定');
} }
if (typeof refreshRobotManager === 'function') {
refreshRobotManager();
}
} }
function escapeHtml(text) { function escapeHtml(text) {
@@ -342,6 +345,9 @@ async function pollWechatBindStatus() {
bound: true bound: true
}); });
} }
if (typeof refreshRobotManager === 'function') {
refreshRobotManager();
}
return; return;
case 'need_verifycode': case 'need_verifycode':
updateWechatSteps('scan'); updateWechatSteps('scan');
@@ -405,5 +411,8 @@ function refreshWechatRobotBoundUI(wechat) {
if (btn) { if (btn) {
btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定'); btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定');
} }
if (typeof refreshRobotManager === 'function') {
refreshRobotManager();
}
} }
} }
+574 -52
View File
@@ -44,6 +44,13 @@
} }
const AGENT_MODES = ['eino_single', 'deep', 'plan_execute', 'supervisor']; const AGENT_MODES = ['eino_single', 'deep', 'plan_execute', 'supervisor'];
const JOIN_STRATEGIES = ['all_merge', 'last_by_canvas', 'first_non_empty', 'fail_fast'];
const NODE_DEFAULT_SIZE = { w: 150, h: 52 };
const NODE_TYPE_SIZES = { condition: { w: 118, h: 86 } };
const NODE_PLACEMENT_GAP = 48;
const NODE_PLACEMENT_PADDING = 20;
const WORKFLOW_EDIT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
function esc(text) { function esc(text) {
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text)); if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
@@ -103,17 +110,17 @@
case 'start': case 'start':
return { input_keys: 'message, conversationId, projectId' }; return { input_keys: 'message, conversationId, projectId' };
case 'tool': case 'tool':
return { tool_name: '', arguments: '{}', timeout_seconds: '' }; return { tool_name: '', arguments: '{}', timeout_seconds: '', join_strategy: 'all_merge' };
case 'agent': case 'agent':
return { agent_mode: 'eino_single', input_binding: { from: 'previous', field: 'output' }, instruction: '', output_key: 'agent_result' }; return { agent_mode: 'eino_single', input_binding: { from: 'previous', field: 'output' }, instruction: '', output_key: 'agent_result', join_strategy: 'all_merge' };
case 'condition': case 'condition':
return { expression: '{{previous.output}} != ""' }; return { expression: '{{previous.output}} != ""', join_strategy: 'all_merge' };
case 'hitl': case 'hitl':
return { prompt: _t('workflows.defaultHitlPrompt'), prompt_binding: { from: 'previous', field: 'output' }, reviewer: 'human' }; return { prompt: _t('workflows.defaultHitlPrompt'), prompt_binding: { from: 'previous', field: 'output' }, reviewer: 'human', join_strategy: 'all_merge' };
case 'output': case 'output':
return { output_key: 'result', source_binding: { from: 'previous', field: 'output' } }; return { output_key: 'result', source_binding: { from: 'previous', field: 'output' }, join_strategy: 'all_merge' };
case 'end': case 'end':
return { result_binding: { from: 'outputs', field: 'result' } }; return { result_binding: { from: 'outputs', field: 'result' }, join_strategy: 'all_merge' };
default: default:
return {}; return {};
} }
@@ -191,6 +198,19 @@
empty.style.display = cy.nodes().length ? 'none' : 'flex'; empty.style.display = cy.nodes().length ? 'none' : 'flex';
} }
let workflowResizeObserver = null;
function setupWorkflowResizeObserver(container) {
if (workflowResizeObserver || typeof ResizeObserver === 'undefined' || !container) return;
workflowResizeObserver = new ResizeObserver(function () {
if (cy) cy.resize();
});
const canvasWrap = container.closest('.workflow-canvas-wrap');
const pageContent = container.closest('.workflow-page-content');
if (canvasWrap) workflowResizeObserver.observe(canvasWrap);
if (pageContent) workflowResizeObserver.observe(pageContent);
}
function initCy() { function initCy() {
const container = document.getElementById('workflow-canvas'); const container = document.getElementById('workflow-canvas');
if (!container || typeof cytoscape !== 'function') return; if (!container || typeof cytoscape !== 'function') return;
@@ -260,6 +280,15 @@
'border-width': 4, 'border-width': 4,
'border-color': '#fbbf24' 'border-color': '#fbbf24'
} }
},
{
selector: 'node.just-added',
style: {
'border-width': 4,
'border-color': '#fbbf24',
'border-opacity': 1,
'z-index': 999
}
} }
], ],
layout: { name: 'preset' } layout: { name: 'preset' }
@@ -291,6 +320,7 @@
deleteWorkflowSelection(); deleteWorkflowSelection();
} }
}); });
setupWorkflowResizeObserver(container);
} }
async function loadWorkflows(includeDisabled) { async function loadWorkflows(includeDisabled) {
@@ -329,6 +359,79 @@
return workflowToolOptions; return workflowToolOptions;
} }
function readWorkflowMetaFromForm() {
const idEl = document.getElementById('workflow-id');
const nameEl = document.getElementById('workflow-name');
const descEl = document.getElementById('workflow-description');
const enabledEl = document.getElementById('workflow-enabled');
return {
id: idEl ? idEl.value.trim() : '',
name: nameEl ? nameEl.value.trim() : '',
description: descEl ? descEl.value.trim() : '',
enabled: enabledEl ? enabledEl.checked : true
};
}
function updateWorkflowCanvasTitle() {
const titleEl = document.getElementById('workflow-canvas-title');
const subtitleEl = document.getElementById('workflow-canvas-subtitle');
if (!titleEl) return;
const meta = readWorkflowMetaFromForm();
const wf = workflows.find(item => item.id === currentWorkflowId);
if (!meta.name && !meta.id) {
titleEl.textContent = _t('workflows.untitled');
} else {
titleEl.textContent = meta.name || meta.id;
}
titleEl.classList.toggle('is-disabled', !meta.enabled);
titleEl.title = meta.description || '';
if (subtitleEl) {
const parts = [];
if (meta.id) parts.push(meta.id);
if (wf && wf.version) parts.push(`v${wf.version}`);
parts.push(meta.enabled ? _t('workflows.statusEnabled') : _t('workflows.statusDisabled'));
subtitleEl.textContent = parts.join(' · ');
subtitleEl.hidden = !parts.length;
}
}
function syncWorkflowMetaIdField(locked, id) {
const idEl = document.getElementById('workflow-id');
const lockedEl = document.getElementById('workflow-id-locked');
const displayEl = document.getElementById('workflow-id-display');
const hintEl = document.querySelector('.workflow-meta-id-hint');
const idGroup = document.getElementById('workflow-meta-id-group');
if (!idEl) return;
idEl.value = id || '';
if (locked) {
idEl.hidden = true;
idEl.disabled = true;
if (lockedEl) lockedEl.hidden = false;
if (displayEl) displayEl.textContent = id || '';
if (hintEl) hintEl.hidden = true;
if (idGroup) idGroup.classList.add('is-locked');
} else {
idEl.hidden = false;
idEl.disabled = false;
if (lockedEl) lockedEl.hidden = true;
if (displayEl) displayEl.textContent = '';
if (hintEl) hintEl.hidden = false;
if (idGroup) idGroup.classList.remove('is-locked');
}
}
function syncWorkflowMetaForm(wf) {
const nameEl = document.getElementById('workflow-name');
const descEl = document.getElementById('workflow-description');
const enabledEl = document.getElementById('workflow-enabled');
if (!nameEl || !descEl || !enabledEl) return;
syncWorkflowMetaIdField(!!wf.id, wf.id || '');
nameEl.value = wf.name || '';
descEl.value = wf.description || '';
enabledEl.checked = wf.enabled !== false;
updateWorkflowCanvasTitle();
}
function renderWorkflowList() { function renderWorkflowList() {
const list = document.getElementById('workflow-list'); const list = document.getElementById('workflow-list');
if (!list) return; if (!list) return;
@@ -336,12 +439,28 @@
list.innerHTML = '<div class="empty-state">' + esc(_t('workflows.emptyList')) + '</div>'; list.innerHTML = '<div class="empty-state">' + esc(_t('workflows.emptyList')) + '</div>';
return; return;
} }
list.innerHTML = workflows.map(wf => ` list.innerHTML = workflows.map(wf => {
<button type="button" class="workflow-list-item ${wf.id === currentWorkflowId ? 'is-active' : ''}" onclick="selectWorkflow(decodeURIComponent('${encodeURIComponent(wf.id)}'))"> const encodedId = encodeURIComponent(wf.id);
<span class="workflow-list-title">${esc(wf.name || wf.id)}</span> const isActive = wf.id === currentWorkflowId;
<span class="workflow-list-meta">${esc(wf.id)} · v${wf.version || 1} · ${wf.enabled ? esc(_t('workflows.statusEnabled')) : esc(_t('workflows.statusDisabled'))}</span> const toggleTitle = esc(_t('workflows.toggleEnabled'));
</button> const editTitle = esc(_t('workflows.editMeta'));
`).join(''); const enabled = wf.enabled !== false;
return `
<div class="workflow-list-item ${isActive ? 'is-active' : ''}">
<button type="button" class="workflow-list-main" onclick="selectWorkflow(decodeURIComponent('${encodedId}'))">
<span class="workflow-list-title">${esc(wf.name || wf.id)}</span>
<span class="workflow-list-meta">${esc(wf.id)} · v${wf.version || 1}</span>
</button>
<div class="workflow-list-actions">
<label class="workflow-switch" title="${toggleTitle}" onclick="event.stopPropagation()">
<input type="checkbox" ${enabled ? 'checked' : ''} aria-label="${toggleTitle}" onchange="toggleWorkflowEnabled(decodeURIComponent('${encodedId}'), this.checked)">
<span class="workflow-switch-slider" aria-hidden="true"></span>
</label>
<button type="button" class="btn-icon workflow-list-edit" title="${editTitle}" aria-label="${editTitle}" onclick="event.stopPropagation(); editWorkflowFromList(decodeURIComponent('${encodedId}'))">${WORKFLOW_EDIT_ICON}</button>
</div>
</div>
`;
}).join('');
} }
function nextNodeId(type) { function nextNodeId(type) {
@@ -372,19 +491,12 @@
} }
function fillWorkflowForm(wf) { function fillWorkflowForm(wf) {
const data = wf || {};
syncWorkflowMetaForm(data);
currentWorkflowId = data.id ? data.id : '';
initCy(); initCy();
const idEl = document.getElementById('workflow-id'); if (!cy) return;
const nameEl = document.getElementById('workflow-name'); const graph = parseGraph(data.graph_json || data.graph || defaultGraph());
const descEl = document.getElementById('workflow-description');
const enabledEl = document.getElementById('workflow-enabled');
if (!idEl || !nameEl || !descEl || !enabledEl || !cy) return;
idEl.value = wf.id || '';
idEl.disabled = !!wf.id;
nameEl.value = wf.name || '';
descEl.value = wf.description || '';
enabledEl.checked = wf.enabled !== false;
currentWorkflowId = wf.id || '';
const graph = parseGraph(wf.graph_json || wf.graph || defaultGraph());
resetSequences(graph); resetSequences(graph);
cy.elements().remove(); cy.elements().remove();
cy.add(graphToElements(graph)); cy.add(graphToElements(graph));
@@ -411,8 +523,6 @@
if (deleteBtn) deleteBtn.hidden = true; if (deleteBtn) deleteBtn.hidden = true;
return; return;
} }
cy.elements().unselect();
selectedElement.select();
empty.hidden = true; empty.hidden = true;
form.hidden = false; form.hidden = false;
if (title) title.textContent = selectedElement.isNode() ? _t('workflows.nodeProperties') : _t('workflows.edgeProperties'); if (title) title.textContent = selectedElement.isNode() ? _t('workflows.nodeProperties') : _t('workflows.edgeProperties');
@@ -420,6 +530,8 @@
deleteBtn.hidden = false; deleteBtn.hidden = false;
deleteBtn.textContent = selectedElement.isNode() ? _t('workflows.deleteNode') : _t('workflows.deleteEdge'); deleteBtn.textContent = selectedElement.isNode() ? _t('workflows.deleteNode') : _t('workflows.deleteEdge');
} }
cy.elements().unselect();
selectedElement.select();
const typeWrap = document.getElementById('workflow-prop-type-wrap'); const typeWrap = document.getElementById('workflow-prop-type-wrap');
const label = document.getElementById('workflow-prop-label'); const label = document.getElementById('workflow-prop-label');
const type = document.getElementById('workflow-prop-type'); const type = document.getElementById('workflow-prop-type');
@@ -463,6 +575,41 @@
`; `;
} }
function joinStrategyHtml(cfg) {
const selected = cfg.join_strategy || 'all_merge';
return `
<div class="form-group">
<label for="workflow-join-strategy">${esc(_t('workflows.config.joinStrategy') || '汇聚策略')}</label>
<select id="workflow-join-strategy" onchange="updateWorkflowTypedConfig()">
${JOIN_STRATEGIES.map(strategy => `<option value="${strategy}" ${strategy === selected ? 'selected' : ''}>${strategy}</option>`).join('')}
</select>
<p class="workflow-config-hint">${esc(_t('workflows.config.joinStrategyHint') || '多个上游进入同一节点时如何生成 previous。')}</p>
</div>
`;
}
function conditionExpressionGuideHtml() {
const examples = [
'{{previous.output}} != ""',
'{{outputs.risk_score}} >= 8',
'{{previous.output}} contains "success"',
'{{previous.output}} matches "^ok"',
'jsonpath({{previous.output}}, "$.status") == "ok"',
'jq({{outputs.scan}}, ".severity") == "high"'
];
return `
<div class="workflow-config-hint workflow-condition-guide">
<div><strong>${esc(_t('workflows.config.conditionGuideTitle'))}</strong></div>
<div>${esc(_t('workflows.config.conditionGuideVars'))}</div>
<div>${esc(_t('workflows.config.conditionGuideOps'))}</div>
<div>${esc(_t('workflows.config.conditionGuideJson'))}</div>
<div class="workflow-example-chips">
${examples.map(expr => `<button type="button" class="btn-secondary btn-small" onclick="useWorkflowConditionExample(this.dataset.expression)" data-expression="${esc(expr)}">${esc(expr)}</button>`).join('')}
</div>
</div>
`;
}
function renderTypedConfig(ele) { function renderTypedConfig(ele) {
const wrap = document.getElementById('workflow-typed-config'); const wrap = document.getElementById('workflow-typed-config');
if (!wrap || !ele) return; if (!wrap || !ele) return;
@@ -474,7 +621,17 @@
: _t('workflows.config.edgeConditionHintExample'); : _t('workflows.config.edgeConditionHintExample');
wrap.innerHTML = ` wrap.innerHTML = `
${typedField('workflow-edge-condition', _t('workflows.config.edgeCondition'), cfg.condition || '', edgeHint)} ${typedField('workflow-edge-condition', _t('workflows.config.edgeCondition'), cfg.condition || '', edgeHint)}
${sourceType === 'condition' ? '<p class="workflow-config-hint">' + esc(_t('workflows.config.edgeBranchHint')) + '</p>' : ''} ${sourceType === 'condition' ? `
<div class="form-group">
<label for="workflow-edge-branch">${esc(_t('workflows.config.edgeBranch') || '条件分支')}</label>
<select id="workflow-edge-branch" onchange="updateWorkflowTypedConfig()">
<option value="">${esc(_t('workflows.config.selectBranch') || '请选择')}</option>
<option value="true" ${cfg.branch === 'true' ? 'selected' : ''}>true / </option>
<option value="false" ${cfg.branch === 'false' ? 'selected' : ''}>false / </option>
</select>
</div>
<p class="workflow-config-hint">${esc(_t('workflows.config.edgeBranchHint'))}</p>
` : ''}
`; `;
return; return;
} }
@@ -485,6 +642,7 @@
break; break;
case 'tool': case 'tool':
wrap.innerHTML = ` wrap.innerHTML = `
${joinStrategyHtml(cfg)}
<div class="form-group"> <div class="form-group">
<label for="workflow-tool-name">${esc(_t('workflows.config.mcpTool'))}</label> <label for="workflow-tool-name">${esc(_t('workflows.config.mcpTool'))}</label>
<select id="workflow-tool-name" onchange="updateWorkflowTypedConfig()"> <select id="workflow-tool-name" onchange="updateWorkflowTypedConfig()">
@@ -503,6 +661,7 @@
break; break;
case 'agent': case 'agent':
wrap.innerHTML = ` wrap.innerHTML = `
${joinStrategyHtml(cfg)}
<div class="form-group"> <div class="form-group">
<label for="workflow-agent-mode">${esc(_t('workflows.config.agentMode'))}</label> <label for="workflow-agent-mode">${esc(_t('workflows.config.agentMode'))}</label>
<select id="workflow-agent-mode" onchange="updateWorkflowTypedConfig()"> <select id="workflow-agent-mode" onchange="updateWorkflowTypedConfig()">
@@ -516,12 +675,15 @@
break; break;
case 'condition': case 'condition':
wrap.innerHTML = ` wrap.innerHTML = `
${joinStrategyHtml(cfg)}
${typedField('workflow-condition-expression', _t('workflows.config.conditionExpression'), cfg.expression, '{{previous.output}} != ""')} ${typedField('workflow-condition-expression', _t('workflows.config.conditionExpression'), cfg.expression, '{{previous.output}} != ""')}
<p class="workflow-config-hint">${_t('workflows.config.conditionHint')}</p> <p class="workflow-config-hint">${_t('workflows.config.conditionHint')}</p>
${conditionExpressionGuideHtml()}
`; `;
break; break;
case 'hitl': case 'hitl':
wrap.innerHTML = ` wrap.innerHTML = `
${joinStrategyHtml(cfg)}
${typedTextarea('workflow-hitl-prompt', _t('workflows.config.hitlPrompt'), cfg.prompt, _t('workflows.config.hitlPromptPlaceholder'))} ${typedTextarea('workflow-hitl-prompt', _t('workflows.config.hitlPrompt'), cfg.prompt, _t('workflows.config.hitlPromptPlaceholder'))}
${bindingFieldHtml('workflow-hitl-prompt-binding', 'workflows.config.promptBinding', bindingFromConfig(cfg, 'prompt_binding', 'previous', 'output'), 'workflows.config.promptBindingHint')} ${bindingFieldHtml('workflow-hitl-prompt-binding', 'workflows.config.promptBinding', bindingFromConfig(cfg, 'prompt_binding', 'previous', 'output'), 'workflows.config.promptBindingHint')}
<p class="workflow-config-hint">${_t('workflows.config.hitlInteractiveHint')}</p> <p class="workflow-config-hint">${_t('workflows.config.hitlInteractiveHint')}</p>
@@ -536,13 +698,14 @@
break; break;
case 'output': case 'output':
wrap.innerHTML = ` wrap.innerHTML = `
${joinStrategyHtml(cfg)}
${typedField('workflow-output-key', _t('workflows.config.outputKey'), cfg.output_key, 'result')} ${typedField('workflow-output-key', _t('workflows.config.outputKey'), cfg.output_key, 'result')}
${bindingFieldHtml('workflow-output-source', 'workflows.config.sourceBinding', bindingFromConfig(cfg, 'source_binding', 'previous', 'output'), 'workflows.config.sourceBindingHint')} ${bindingFieldHtml('workflow-output-source', 'workflows.config.sourceBinding', bindingFromConfig(cfg, 'source_binding', 'previous', 'output'), 'workflows.config.sourceBindingHint')}
${typedField('workflow-output-static', _t('workflows.config.staticValue'), cfg.static_value || '', _t('workflows.config.optional'))} ${typedField('workflow-output-static', _t('workflows.config.staticValue'), cfg.static_value || '', _t('workflows.config.optional'))}
`; `;
break; break;
case 'end': case 'end':
wrap.innerHTML = bindingFieldHtml('workflow-end-result', 'workflows.config.resultBinding', bindingFromConfig(cfg, 'result_binding', 'outputs', 'result'), 'workflows.config.resultBindingHint'); wrap.innerHTML = joinStrategyHtml(cfg) + bindingFieldHtml('workflow-end-result', 'workflows.config.resultBinding', bindingFromConfig(cfg, 'result_binding', 'outputs', 'result'), 'workflows.config.resultBindingHint');
break; break;
default: default:
wrap.innerHTML = ''; wrap.innerHTML = '';
@@ -579,9 +742,13 @@
function readTypedConfig(ele) { function readTypedConfig(ele) {
if (!ele) return {}; if (!ele) return {};
if (!ele.isNode()) { if (!ele.isNode()) {
return { condition: (document.getElementById('workflow-edge-condition') || {}).value || '' }; const cfg = { condition: (document.getElementById('workflow-edge-condition') || {}).value || '' };
const branchEl = document.getElementById('workflow-edge-branch');
if (branchEl) cfg.branch = branchEl.value || '';
return cfg;
} }
const type = ele.data('type') || 'tool'; const type = ele.data('type') || 'tool';
const join_strategy = (document.getElementById('workflow-join-strategy') || {}).value || 'all_merge';
switch (type) { switch (type) {
case 'start': case 'start':
return { input_keys: (document.getElementById('workflow-start-input-keys') || {}).value || '' }; return { input_keys: (document.getElementById('workflow-start-input-keys') || {}).value || '' };
@@ -589,31 +756,35 @@
return { return {
tool_name: (document.getElementById('workflow-tool-name') || {}).value || '', tool_name: (document.getElementById('workflow-tool-name') || {}).value || '',
arguments: (document.getElementById('workflow-tool-arguments') || {}).value || '{}', arguments: (document.getElementById('workflow-tool-arguments') || {}).value || '{}',
timeout_seconds: (document.getElementById('workflow-tool-timeout') || {}).value || '' timeout_seconds: (document.getElementById('workflow-tool-timeout') || {}).value || '',
join_strategy
}; };
case 'agent': case 'agent':
return { return {
agent_mode: (document.getElementById('workflow-agent-mode') || {}).value || 'eino_single', agent_mode: (document.getElementById('workflow-agent-mode') || {}).value || 'eino_single',
input_binding: readBinding('workflow-agent-input'), input_binding: readBinding('workflow-agent-input'),
instruction: (document.getElementById('workflow-agent-instruction') || {}).value || '', instruction: (document.getElementById('workflow-agent-instruction') || {}).value || '',
output_key: (document.getElementById('workflow-agent-output-key') || {}).value || 'agent_result' output_key: (document.getElementById('workflow-agent-output-key') || {}).value || 'agent_result',
join_strategy
}; };
case 'condition': case 'condition':
return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '' }; return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '', join_strategy };
case 'hitl': case 'hitl':
return { return {
prompt: (document.getElementById('workflow-hitl-prompt') || {}).value || '', prompt: (document.getElementById('workflow-hitl-prompt') || {}).value || '',
prompt_binding: readBinding('workflow-hitl-prompt-binding'), prompt_binding: readBinding('workflow-hitl-prompt-binding'),
reviewer: (document.getElementById('workflow-hitl-reviewer') || {}).value || 'human' reviewer: (document.getElementById('workflow-hitl-reviewer') || {}).value || 'human',
join_strategy
}; };
case 'output': case 'output':
return { return {
output_key: (document.getElementById('workflow-output-key') || {}).value || 'result', output_key: (document.getElementById('workflow-output-key') || {}).value || 'result',
source_binding: readBinding('workflow-output-source'), source_binding: readBinding('workflow-output-source'),
static_value: (document.getElementById('workflow-output-static') || {}).value || '' static_value: (document.getElementById('workflow-output-static') || {}).value || '',
join_strategy
}; };
case 'end': case 'end':
return { result_binding: readBinding('workflow-end-result') }; return { result_binding: readBinding('workflow-end-result'), join_strategy };
default: default:
return {}; return {};
} }
@@ -676,6 +847,100 @@
connectSourceId = ''; connectSourceId = '';
} }
function nodeSizeForType(type) {
return NODE_TYPE_SIZES[type] || NODE_DEFAULT_SIZE;
}
function viewportCenterPosition() {
const pan = cy.pan();
const zoom = cy.zoom();
const container = cy.container();
return {
x: (container.clientWidth / 2 - pan.x) / zoom,
y: (container.clientHeight / 2 - pan.y) / zoom
};
}
function positionOverlaps(x, y, width, height, excludeId) {
const pad = NODE_PLACEMENT_PADDING;
const hw = width / 2 + pad;
const hh = height / 2 + pad;
return cy.nodes().some(node => {
if (excludeId && node.id() === excludeId) return false;
const p = node.position();
const bb = node.boundingBox();
return Math.abs(p.x - x) < hw + bb.w / 2 && Math.abs(p.y - y) < hh + bb.h / 2;
});
}
function findOpenPosition(anchor, type) {
const size = nodeSizeForType(type);
const step = 36;
for (let i = 0; i < 20; i++) {
const x = anchor.x + (i % 5) * step;
const y = anchor.y + Math.floor(i / 5) * step;
if (!positionOverlaps(x, y, size.w, size.h)) {
return { x, y };
}
}
return anchor;
}
function anchorFromSelection(type) {
if (selectedElement && selectedElement.length && selectedElement.isNode()) {
const p = selectedElement.position();
const srcBb = selectedElement.boundingBox();
const size = nodeSizeForType(type);
const gap = NODE_PLACEMENT_GAP;
const candidates = [
{ x: p.x + srcBb.w / 2 + gap + size.w / 2, y: p.y },
{ x: p.x, y: p.y + srcBb.h / 2 + gap + size.h / 2 },
{ x: p.x - srcBb.w / 2 - gap - size.w / 2, y: p.y },
{ x: p.x, y: p.y - srcBb.h / 2 - gap - size.h / 2 }
];
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
if (!positionOverlaps(c.x, c.y, size.w, size.h)) {
return c;
}
}
return findOpenPosition(candidates[0], type);
}
return viewportCenterPosition();
}
function defaultNodePosition(type) {
return findOpenPosition(anchorFromSelection(type), type);
}
function isPositionInViewport(x, y, padding) {
const extent = cy.extent();
const pad = padding == null ? 40 : padding;
return x >= extent.x1 + pad && x <= extent.x2 - pad &&
y >= extent.y1 + pad && y <= extent.y2 - pad;
}
function highlightNewNode(node) {
if (!node || !node.length) return;
if (typeof node.flashClass === 'function') {
node.flashClass('just-added', 650);
} else {
node.addClass('just-added');
setTimeout(function () {
if (node.nonempty()) node.removeClass('just-added');
}, 650);
}
}
function revealWorkflowNode(node) {
if (!node || !node.length) return;
const p = node.position();
if (!isPositionInViewport(p.x, p.y)) {
cy.animate({ center: { eles: node }, duration: 200 });
}
highlightNewNode(node);
}
function addNode(type, position) { function addNode(type, position) {
initCy(); initCy();
if (!cy) return; if (!cy) return;
@@ -687,10 +952,15 @@
label: wfNodeLabel(type), label: wfNodeLabel(type),
config: defaultConfigForType(type) config: defaultConfigForType(type)
}, },
position: position || { x: 180 + cy.nodes().length * 28, y: 160 + cy.nodes().length * 28 } position: position || defaultNodePosition(type)
}); });
selectWorkflowElement(node); selectWorkflowElement(node);
updateEmptyState(); updateEmptyState();
if (position) {
highlightNewNode(node);
} else {
revealWorkflowNode(node);
}
} }
window.refreshWorkflows = async function () { window.refreshWorkflows = async function () {
@@ -699,19 +969,27 @@
if (list) list.innerHTML = '<div class="loading-spinner">' + esc(_t('common.loading')) + '</div>'; if (list) list.innerHTML = '<div class="loading-spinner">' + esc(_t('common.loading')) + '</div>';
try { try {
await loadWorkflows(true); await loadWorkflows(true);
renderWorkflowList(); if (currentWorkflowId) {
if (!currentWorkflowId && workflows.length) { const wf = workflows.find(item => item.id === currentWorkflowId);
if (wf) {
syncWorkflowMetaForm(wf);
}
} else if (workflows.length) {
fillWorkflowForm(workflows[0]); fillWorkflowForm(workflows[0]);
} else if (!workflows.length) { } else {
newWorkflowDraft(); newWorkflowDraft({ openMeta: false });
return;
} }
renderWorkflowList();
} catch (error) { } catch (error) {
if (list) list.innerHTML = `<div class="empty-state">${esc(error.message)}</div>`; if (list) list.innerHTML = `<div class="empty-state">${esc(error.message)}</div>`;
if (typeof showNotification === 'function') showNotification(error.message, 'error'); if (typeof showNotification === 'function') showNotification(error.message, 'error');
} }
}; };
window.newWorkflowDraft = function () { window.newWorkflowDraft = function (options) {
const shouldOpenMeta = !options || options.openMeta !== false;
currentWorkflowId = '';
fillWorkflowForm({ fillWorkflowForm({
id: '', id: '',
name: '', name: '',
@@ -719,6 +997,10 @@
enabled: true, enabled: true,
graph_json: defaultGraph() graph_json: defaultGraph()
}); });
syncWorkflowMetaIdField(false, '');
if (shouldOpenMeta) {
openWorkflowMetaModal();
}
}; };
window.selectWorkflow = function (id) { window.selectWorkflow = function (id) {
@@ -726,6 +1008,100 @@
if (wf) fillWorkflowForm(wf); if (wf) fillWorkflowForm(wf);
}; };
window.openWorkflowMetaModal = function () {
const nameEl = document.getElementById('workflow-name');
const idEl = document.getElementById('workflow-id');
if (currentWorkflowId) {
syncWorkflowMetaIdField(true, currentWorkflowId);
} else {
syncWorkflowMetaIdField(false, idEl ? idEl.value.trim() : '');
}
if (typeof openAppModal === 'function') {
openAppModal('workflow-meta-modal', {
focusEl: currentWorkflowId ? nameEl : (idEl && !idEl.hidden ? idEl : nameEl)
});
}
};
window.closeWorkflowMetaModal = function () {
if (typeof closeAppModal === 'function') {
closeAppModal('workflow-meta-modal');
}
};
window.applyWorkflowMetaModal = function () {
const meta = readWorkflowMetaFromForm();
if (!meta.id || !meta.name) {
if (typeof showNotification === 'function') {
showNotification(_t('workflows.idNameRequired'), 'error');
}
return;
}
updateWorkflowCanvasTitle();
renderWorkflowList();
closeWorkflowMetaModal();
};
window.editWorkflowFromList = function (id) {
if (id !== currentWorkflowId) {
selectWorkflow(id);
}
openWorkflowMetaModal();
};
window.toggleWorkflowEnabled = async function (id, enabled) {
const wf = workflows.find(item => item.id === id);
if (!wf) return;
const previous = wf.enabled !== false;
wf.enabled = enabled;
if (id === currentWorkflowId) {
const enabledEl = document.getElementById('workflow-enabled');
if (enabledEl) enabledEl.checked = enabled;
updateWorkflowCanvasTitle();
}
renderWorkflowList();
let graph = defaultGraph();
if (id === currentWorkflowId && cy) {
graph = elementsToGraph();
} else {
graph = parseGraph(wf.graph_json || wf.graph || defaultGraph());
}
try {
const response = await apiFetch(`/api/workflows/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: wf.id,
name: wf.name,
description: wf.description || '',
enabled,
graph
})
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || _t('workflows.enabledUpdateFailed'));
}
if (typeof showNotification === 'function') {
showNotification(_t('workflows.enabledUpdated'), 'success');
}
if (typeof loadWorkflowOptionsForRoleModal === 'function') {
await loadWorkflowOptionsForRoleModal();
}
} catch (error) {
wf.enabled = previous;
if (id === currentWorkflowId) {
const enabledEl = document.getElementById('workflow-enabled');
if (enabledEl) enabledEl.checked = previous;
updateWorkflowCanvasTitle();
}
renderWorkflowList();
if (typeof showNotification === 'function') {
showNotification(error.message || _t('workflows.enabledUpdateFailed'), 'error');
}
}
};
function validateWorkflowGraph(graph) { function validateWorkflowGraph(graph) {
const errors = []; const errors = [];
const nodes = graph.nodes || []; const nodes = graph.nodes || [];
@@ -733,6 +1109,7 @@
const ids = new Set(nodes.map(node => node.id)); const ids = new Set(nodes.map(node => node.id));
const starts = nodes.filter(node => node.type === 'start'); const starts = nodes.filter(node => node.type === 'start');
const outputs = nodes.filter(node => node.type === 'output'); const outputs = nodes.filter(node => node.type === 'output');
const terminals = nodes.filter(node => node.type === 'output' || node.type === 'end');
if (!starts.length) errors.push(_t('workflows.validation.needStart')); if (!starts.length) errors.push(_t('workflows.validation.needStart'));
if (!outputs.length) errors.push(_t('workflows.validation.needOutput')); if (!outputs.length) errors.push(_t('workflows.validation.needOutput'));
edges.forEach(edge => { edges.forEach(edge => {
@@ -746,6 +1123,15 @@
outputs.forEach(node => { outputs.forEach(node => {
if (edges.some(edge => edge.source === node.id)) errors.push(_t('workflows.validation.outputOutgoing', { label: node.label || node.id })); if (edges.some(edge => edge.source === node.id)) errors.push(_t('workflows.validation.outputOutgoing', { label: node.label || node.id }));
}); });
nodes.filter(node => node.type === 'end').forEach(node => {
if (edges.some(edge => edge.source === node.id)) errors.push(_t('workflows.validation.outputOutgoing', { label: node.label || node.id }));
});
nodes.filter(node => node.type !== 'start').forEach(node => {
if (!edges.some(edge => edge.target === node.id)) errors.push(_t('workflows.validation.nodeNeedsIncoming', { label: node.label || node.id }));
});
nodes.filter(node => node.type !== 'output' && node.type !== 'end').forEach(node => {
if (!edges.some(edge => edge.source === node.id)) errors.push(_t('workflows.validation.nodeNeedsOutgoing', { label: node.label || node.id }));
});
nodes.filter(node => node.type === 'tool').forEach(node => { nodes.filter(node => node.type === 'tool').forEach(node => {
if (!String((node.config || {}).tool_name || '').trim()) { if (!String((node.config || {}).tool_name || '').trim()) {
errors.push(_t('workflows.validation.toolNeedsMcp', { label: node.label || node.id })); errors.push(_t('workflows.validation.toolNeedsMcp', { label: node.label || node.id }));
@@ -761,23 +1147,96 @@
} else if (outEdges.length > 2) { } else if (outEdges.length > 2) {
errors.push(_t('workflows.validation.conditionTooManyEdges', { label: node.label || node.id })); errors.push(_t('workflows.validation.conditionTooManyEdges', { label: node.label || node.id }));
} }
const branches = outEdges.map(edge => String(((edge.config || {}).branch || edge.label || '')).trim().toLowerCase());
if (branches.some(branch => !['true', 'false', '是', '否', 'yes', 'no', 'y', 'n'].includes(branch))) {
errors.push(_t('workflows.validation.conditionBranchLabel', { label: node.label || node.id }));
}
if (new Set(branches).size !== branches.length) {
errors.push(_t('workflows.validation.conditionBranchDuplicate', { label: node.label || node.id }));
}
}); });
nodes.filter(node => node.type === 'output').forEach(node => { nodes.filter(node => node.type === 'output').forEach(node => {
if (!String((node.config || {}).output_key || '').trim()) { if (!String((node.config || {}).output_key || '').trim()) {
errors.push(_t('workflows.validation.outputNeedsKey', { label: node.label || node.id })); errors.push(_t('workflows.validation.outputNeedsKey', { label: node.label || node.id }));
} }
}); });
return errors; if (terminals.length) {
const outgoing = new Map();
edges.forEach(edge => {
if (!outgoing.has(edge.source)) outgoing.set(edge.source, []);
outgoing.get(edge.source).push(edge.target);
});
const reached = new Set();
const queue = starts.map(node => node.id);
while (queue.length) {
const id = queue.shift();
if (reached.has(id)) continue;
reached.add(id);
(outgoing.get(id) || []).forEach(next => queue.push(next));
}
nodes.forEach(node => {
if (!reached.has(node.id)) errors.push(_t('workflows.validation.nodeUnreachable', { label: node.label || node.id }));
});
const visiting = new Set();
const visited = new Set();
function visit(id) {
if (visiting.has(id)) return true;
if (visited.has(id)) return false;
visiting.add(id);
for (const next of (outgoing.get(id) || [])) {
if (visit(next)) return true;
}
visiting.delete(id);
visited.add(id);
return false;
}
nodes.forEach(node => {
if (visit(node.id)) errors.push(_t('workflows.validation.graphCycle', { label: node.label || node.id }));
});
}
return Array.from(new Set(errors));
}
async function validateWorkflowGraphOnServer(graph) {
const response = await apiFetch('/api/workflows/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || _t('workflows.validation.serverFailed'));
}
}
function renderWorkflowDryRunTrace(result) {
const panel = document.getElementById('workflow-dry-run-panel');
const output = document.getElementById('workflow-dry-run-output');
if (!panel || !output) return;
const trace = (result && result.trace) || [];
panel.hidden = false;
if (!trace.length) {
output.textContent = _t('workflows.dryRunNoTrace') || 'No trace';
return;
}
output.innerHTML = trace.map((item, index) => {
const status = item.status || '';
const label = item.label || item.nodeId || ('#' + (index + 1));
return `<div class="workflow-dry-run-step">
<strong>${index + 1}. ${esc(label)}</strong>
<span>${esc(item.type || '')} · ${esc(status)}</span>
</div>`;
}).join('');
} }
window.saveWorkflowDraft = async function () { window.saveWorkflowDraft = async function () {
initCy(); initCy();
const id = document.getElementById('workflow-id').value.trim(); const meta = readWorkflowMetaFromForm();
const name = document.getElementById('workflow-name').value.trim(); if (!meta.id || !meta.name) {
const description = document.getElementById('workflow-description').value.trim(); if (typeof showNotification === 'function') {
const enabled = document.getElementById('workflow-enabled').checked; showNotification(_t('workflows.idNameRequired'), 'error');
if (!id || !name) { }
showNotification(_t('workflows.idNameRequired'), 'error'); openWorkflowMetaModal();
return; return;
} }
const graph = elementsToGraph(); const graph = elementsToGraph();
@@ -786,12 +1245,24 @@
showNotification(errors.slice(0, 4).join(''), 'error'); showNotification(errors.slice(0, 4).join(''), 'error');
return; return;
} }
try {
await validateWorkflowGraphOnServer(graph);
} catch (error) {
showNotification(error.message || _t('workflows.validation.serverFailed'), 'error');
return;
}
const method = currentWorkflowId ? 'PUT' : 'POST'; const method = currentWorkflowId ? 'PUT' : 'POST';
const url = currentWorkflowId ? `/api/workflows/${encodeURIComponent(currentWorkflowId)}` : '/api/workflows'; const url = currentWorkflowId ? `/api/workflows/${encodeURIComponent(currentWorkflowId)}` : '/api/workflows';
const response = await apiFetch(url, { const response = await apiFetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name, description, enabled, graph }) body: JSON.stringify({
id: meta.id,
name: meta.name,
description: meta.description,
enabled: meta.enabled,
graph
})
}); });
if (!response.ok) { if (!response.ok) {
const err = await response.json().catch(() => ({})); const err = await response.json().catch(() => ({}));
@@ -799,7 +1270,9 @@
return; return;
} }
const data = await response.json(); const data = await response.json();
currentWorkflowId = data.workflow && data.workflow.id ? data.workflow.id : id; currentWorkflowId = data.workflow && data.workflow.id ? data.workflow.id : meta.id;
syncWorkflowMetaIdField(true, currentWorkflowId);
closeWorkflowMetaModal();
showNotification(_t('workflows.saved'), 'success'); showNotification(_t('workflows.saved'), 'success');
await refreshWorkflows(); await refreshWorkflows();
if (typeof loadWorkflowOptionsForRoleModal === 'function') { if (typeof loadWorkflowOptionsForRoleModal === 'function') {
@@ -807,8 +1280,48 @@
} }
}; };
window.dryRunWorkflowDraft = async function () {
initCy();
const graph = elementsToGraph();
const errors = validateWorkflowGraph(graph);
if (errors.length) {
showNotification(errors.slice(0, 4).join(''), 'error');
return;
}
const message = window.prompt(_t('workflows.dryRunPrompt') || 'Input message for dry-run', 'ping');
if (message === null) return;
try {
const response = await apiFetch('/api/workflows/dry-run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph, inputs: { message } })
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || _t('workflows.dryRunFailed'));
}
const result = data.result || {};
const trace = result.trace || [];
console.groupCollapsed('[Workflow dry-run]');
console.table(trace.map(item => ({
nodeId: item.nodeId,
label: item.label,
type: item.type,
status: item.status
})));
console.log(result);
console.groupEnd();
renderWorkflowDryRunTrace(result);
const summary = trace.slice(0, 8).map(item => `${item.label || item.nodeId}: ${item.status}`).join('\n');
window.alert((_t('workflows.dryRunDone') || 'Dry-run completed') + '\n\n' + summary);
} catch (error) {
showNotification(error.message || _t('workflows.dryRunFailed'), 'error');
}
};
window.deleteCurrentWorkflow = async function () { window.deleteCurrentWorkflow = async function () {
const id = currentWorkflowId || document.getElementById('workflow-id').value.trim(); const meta = readWorkflowMetaFromForm();
const id = currentWorkflowId || meta.id;
if (!id) { if (!id) {
showNotification(_t('workflows.selectToDelete'), 'warning'); showNotification(_t('workflows.selectToDelete'), 'warning');
return; return;
@@ -822,7 +1335,7 @@
} }
currentWorkflowId = ''; currentWorkflowId = '';
showNotification(_t('workflows.deleted'), 'success'); showNotification(_t('workflows.deleted'), 'success');
newWorkflowDraft(); newWorkflowDraft({ openMeta: false });
await refreshWorkflows(); await refreshWorkflows();
}; };
@@ -927,6 +1440,14 @@
mergeVisibleConfig(); mergeVisibleConfig();
}; };
window.useWorkflowConditionExample = function (expr) {
const input = document.getElementById('workflow-condition-expression');
if (!input) return;
input.value = expr || '';
updateWorkflowTypedConfig();
input.focus();
};
window.removeWorkflowCustomField = function (index) { window.removeWorkflowCustomField = function (index) {
if (!selectedElement) return; if (!selectedElement) return;
const entries = Object.entries(stripTypedConfig(selectedElement)); const entries = Object.entries(stripTypedConfig(selectedElement));
@@ -984,6 +1505,7 @@
connectBtn.textContent = connectMode ? _t('workflows.connecting') : _t('workflows.connect'); connectBtn.textContent = connectMode ? _t('workflows.connecting') : _t('workflows.connect');
} }
refreshCanvasLabels(); refreshCanvasLabels();
updateWorkflowCanvasTitle();
renderWorkflowList(); renderWorkflowList();
if (selectedElement && selectedElement.length) { if (selectedElement && selectedElement.length) {
selectWorkflowElement(selectedElement); selectWorkflowElement(selectedElement);
+366 -53
View File
@@ -175,10 +175,10 @@
</div> </div>
<div class="nav-item" data-page="hitl"> <div class="nav-item" data-page="hitl">
<div class="nav-item-content" data-title="人机协同" onclick="switchPage('hitl')"> <div class="nav-item-content" data-title="人机协同" onclick="switchPage('hitl')">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path> <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path>
<circle cx="9" cy="7" r="4"></circle> <circle cx="9" cy="7" r="4"></circle>
<polyline points="16 11 18 13 22 9"></polyline> <path d="m16 11 2 2 4-4"></path>
</svg> </svg>
<span data-i18n="nav.hitl">人机协同</span> <span data-i18n="nav.hitl">人机协同</span>
</div> </div>
@@ -1146,7 +1146,7 @@
</button> </button>
</div> </div>
<div class="agent-mode-options"> <div class="agent-mode-options">
<button type="button" class="role-selection-item-main agent-mode-option" data-value="eino_single" role="option" onclick="selectAgentMode('eino_single')"> <button type="button" class="role-selection-item-main agent-mode-option" data-value="eino_single" role="option" onclick="selectAgentMode('eino_single')" data-agent-mode-detail="CloudWeGo Eino ChatModelAgent + RunnerMCP 工具(/api/eino-agent" data-i18n="chat.agentModeEinoSingleHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
<div class="role-selection-item-icon-main" aria-hidden="true"></div> <div class="role-selection-item-icon-main" aria-hidden="true"></div>
<div class="role-selection-item-content-main"> <div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK</div> <div class="role-selection-item-name-main" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK</div>
@@ -1154,15 +1154,15 @@
</div> </div>
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single"></div> <div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single"></div>
</button> </button>
<button type="button" class="role-selection-item-main agent-mode-option" data-value="deep" role="option" onclick="selectAgentMode('deep')"> <button type="button" class="role-selection-item-main agent-mode-option" data-value="deep" role="option" onclick="selectAgentMode('deep')" data-agent-mode-detail="Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总" data-i18n="chat.agentModeDeepHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
<div class="role-selection-item-icon-main" aria-hidden="true">🧩</div> <div class="role-selection-item-icon-main" aria-hidden="true">🧩</div>
<div class="role-selection-item-content-main"> <div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModeDeep">DeepDeepAgent</div> <div class="role-selection-item-name-main" data-i18n="chat.agentModeDeep">DeepDeepAgent</div>
<div class="role-selection-item-description-main" data-i18n="chat.agentModeDeepHint">Eino DeepAgenttask 调度子代理</div> <div class="role-selection-item-description-main" data-i18n="chat.agentModeDeepHint">Eino DeepAgent适合复杂安全测试、多阶段 task 子代理委派与汇总</div>
</div> </div>
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep"></div> <div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep"></div>
</button> </button>
<button type="button" class="role-selection-item-main agent-mode-option" data-value="plan_execute" role="option" onclick="selectAgentMode('plan_execute')"> <button type="button" class="role-selection-item-main agent-mode-option" data-value="plan_execute" role="option" onclick="selectAgentMode('plan_execute')" data-agent-mode-detail="规划 → 执行 → 重规划(单执行器工具链)" data-i18n="chat.agentModePlanExecuteHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
<div class="role-selection-item-icon-main" aria-hidden="true">📋</div> <div class="role-selection-item-icon-main" aria-hidden="true">📋</div>
<div class="role-selection-item-content-main"> <div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</div> <div class="role-selection-item-name-main" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</div>
@@ -1170,11 +1170,11 @@
</div> </div>
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute"></div> <div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute"></div>
</button> </button>
<button type="button" class="role-selection-item-main agent-mode-option" data-value="supervisor" role="option" onclick="selectAgentMode('supervisor')"> <button type="button" class="role-selection-item-main agent-mode-option" data-value="supervisor" role="option" onclick="selectAgentMode('supervisor')" data-agent-mode-detail="专家路由场景:监督者通过 transfer 动态分派多个专业子代理" data-i18n="chat.agentModeSupervisorHint" data-i18n-attr="data-agent-mode-detail" data-i18n-skip-text="true">
<div class="role-selection-item-icon-main" aria-hidden="true">🎯</div> <div class="role-selection-item-icon-main" aria-hidden="true">🎯</div>
<div class="role-selection-item-content-main"> <div class="role-selection-item-content-main">
<div class="role-selection-item-name-main" data-i18n="chat.agentModeSupervisorLabel">Supervisor</div> <div class="role-selection-item-name-main" data-i18n="chat.agentModeSupervisorLabel">Supervisor(专家路由)</div>
<div class="role-selection-item-description-main" data-i18n="chat.agentModeSupervisorHint">监督者协调,transfer 委派子代理</div> <div class="role-selection-item-description-main" data-i18n="chat.agentModeSupervisorHint">专家路由场景:监督者通过 transfer 动态分派多个专业子代理</div>
</div> </div>
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="supervisor"></div> <div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="supervisor"></div>
</button> </button>
@@ -2583,16 +2583,17 @@
</aside> </aside>
<main class="workflow-main"> <main class="workflow-main">
<section class="workflow-meta-bar"> <section class="workflow-meta-bar">
<div class="workflow-meta-fields"> <div class="workflow-canvas-header">
<label><span data-i18n="workflows.metaId">ID</span> <input type="text" id="workflow-id" placeholder="web-scan-basic" autocomplete="off"></label> <div class="workflow-canvas-heading">
<label><span data-i18n="workflows.metaName">名称</span> <input type="text" id="workflow-name" data-i18n="workflows.namePlaceholder" data-i18n-attr="placeholder" placeholder="基础 Web 扫描" autocomplete="off"></label> <h3 id="workflow-canvas-title" class="workflow-canvas-title" data-i18n="workflows.untitled">未命名流程</h3>
<label><span data-i18n="workflows.metaDescription">描述</span> <input type="text" id="workflow-description" data-i18n="workflows.descriptionPlaceholder" data-i18n-attr="placeholder" placeholder="可选" autocomplete="off"></label> <span id="workflow-canvas-subtitle" class="workflow-canvas-subtitle" hidden></span>
<label class="workflow-enabled-toggle"><input type="checkbox" id="workflow-enabled" checked> <span data-i18n="workflows.metaEnabled">启用</span></label> </div>
</div> </div>
<div class="workflow-toolbar"> <div class="workflow-toolbar">
<button class="btn-secondary btn-small" type="button" onclick="toggleWorkflowConnectMode()" id="workflow-connect-btn" data-i18n="workflows.connect">连线</button> <button class="btn-secondary btn-small" type="button" onclick="toggleWorkflowConnectMode()" id="workflow-connect-btn" data-i18n="workflows.connect">连线</button>
<button class="btn-secondary btn-small" type="button" onclick="deleteWorkflowSelection()" data-i18n="workflows.deleteSelected">删除选中</button> <button class="btn-secondary btn-small" type="button" onclick="deleteWorkflowSelection()" data-i18n="workflows.deleteSelected">删除选中</button>
<button class="btn-secondary btn-small" type="button" onclick="layoutWorkflowGraph()" data-i18n="workflows.autoLayout">自动布局</button> <button class="btn-secondary btn-small" type="button" onclick="layoutWorkflowGraph()" data-i18n="workflows.autoLayout">自动布局</button>
<button class="btn-secondary btn-small" type="button" onclick="dryRunWorkflowDraft()" data-i18n="workflows.dryRun">试运行</button>
<button class="btn-secondary btn-small" onclick="deleteCurrentWorkflow()" data-i18n="common.delete">删除</button> <button class="btn-secondary btn-small" onclick="deleteCurrentWorkflow()" data-i18n="common.delete">删除</button>
<button class="btn-primary btn-small" onclick="saveWorkflowDraft()" data-i18n="common.save">保存</button> <button class="btn-primary btn-small" onclick="saveWorkflowDraft()" data-i18n="common.save">保存</button>
</div> </div>
@@ -2632,6 +2633,12 @@
</div> </div>
<div id="workflow-custom-fields" class="workflow-custom-fields"></div> <div id="workflow-custom-fields" class="workflow-custom-fields"></div>
</div> </div>
<div id="workflow-dry-run-panel" class="workflow-property-form" hidden>
<div class="workflow-custom-fields-head">
<span data-i18n="workflows.dryRunTrace">试运行轨迹</span>
</div>
<div id="workflow-dry-run-output" class="workflow-property-empty workflow-property-empty--compact"></div>
</div>
</aside> </aside>
</div> </div>
</div> </div>
@@ -3300,8 +3307,106 @@
<p class="settings-description" data-i18n="settings.robots.description">配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。</p> <p class="settings-description" data-i18n="settings.robots.description">配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。</p>
</div> </div>
<div class="robot-manager">
<div class="robot-manager-toolbar">
<div>
<h4 data-i18n="settings.robots.managerTitle">机器人管理</h4>
<p data-i18n="settings.robots.managerDesc">先选择机器人类型,再进入对应配置;已配置的平台会显示连接状态。</p>
</div>
<button type="button" class="btn-primary" onclick="openRobotCreateModal()" data-i18n="settings.robots.newBot">新建机器人</button>
</div>
<div class="robot-card-grid">
<button type="button" class="robot-card" data-robot-card="wechat" onclick="openRobotEditor('wechat')">
<span class="robot-card-main">
<span class="robot-card-title" data-i18n="settings.robots.wechat.title">微信 / iLink</span>
<span class="robot-card-desc" data-i18n="settings.robots.wechat.subtitle">扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话</span>
</span>
<span class="robot-card-meta">
<span id="robot-card-wechat-status" class="robot-status-pill robot-status-pill--idle" data-i18n="settings.robots.statusNotConfigured">未配置</span>
<span class="robot-card-action" data-i18n="settings.robots.configure">配置</span>
</span>
</button>
<button type="button" class="robot-card" data-robot-card="wecom" onclick="openRobotEditor('wecom')">
<span class="robot-card-main">
<span class="robot-card-title" data-i18n="settings.robots.wecom.title">企业微信</span>
<span class="robot-card-desc" data-i18n="settings.robots.wecom.subtitle">HTTP 回调模式,适合企业内部应用机器人</span>
</span>
<span class="robot-card-meta">
<span id="robot-card-wecom-status" class="robot-status-pill robot-status-pill--idle" data-i18n="settings.robots.statusNotConfigured">未配置</span>
<span class="robot-card-action" data-i18n="settings.robots.configure">配置</span>
</span>
</button>
<button type="button" class="robot-card" data-robot-card="dingtalk" onclick="openRobotEditor('dingtalk')">
<span class="robot-card-main">
<span class="robot-card-title" data-i18n="settings.robots.dingtalk.title">钉钉</span>
<span class="robot-card-desc" data-i18n="settings.robots.dingtalk.subtitle">企业内部应用机器人,使用 Stream 长连接</span>
</span>
<span class="robot-card-meta">
<span id="robot-card-dingtalk-status" class="robot-status-pill robot-status-pill--idle" data-i18n="settings.robots.statusNotConfigured">未配置</span>
<span class="robot-card-action" data-i18n="settings.robots.configure">配置</span>
</span>
</button>
<button type="button" class="robot-card" data-robot-card="lark" onclick="openRobotEditor('lark')">
<span class="robot-card-main">
<span class="robot-card-title" data-i18n="settings.robots.lark.title">飞书 (Lark)</span>
<span class="robot-card-desc" data-i18n="settings.robots.lark.subtitle">飞书企业应用机器人,支持单聊与群聊 @</span>
</span>
<span class="robot-card-meta">
<span id="robot-card-lark-status" class="robot-status-pill robot-status-pill--idle" data-i18n="settings.robots.statusNotConfigured">未配置</span>
<span class="robot-card-action" data-i18n="settings.robots.configure">配置</span>
</span>
</button>
<button type="button" class="robot-card" data-robot-card="telegram" onclick="openRobotEditor('telegram')">
<span class="robot-card-main">
<span class="robot-card-title" data-i18n="settings.robots.telegram.title">Telegram</span>
<span class="robot-card-desc" data-i18n="settings.robots.telegram.subtitle">Bot API 长轮询,私聊与群聊 @</span>
</span>
<span class="robot-card-meta">
<span id="robot-card-telegram-status" class="robot-status-pill robot-status-pill--idle" data-i18n="settings.robots.statusNotConfigured">未配置</span>
<span class="robot-card-action" data-i18n="settings.robots.configure">配置</span>
</span>
</button>
<button type="button" class="robot-card" data-robot-card="slack" onclick="openRobotEditor('slack')">
<span class="robot-card-main">
<span class="robot-card-title" data-i18n="settings.robots.slack.title">Slack</span>
<span class="robot-card-desc" data-i18n="settings.robots.slack.subtitle">Socket Mode,无需公网回调</span>
</span>
<span class="robot-card-meta">
<span id="robot-card-slack-status" class="robot-status-pill robot-status-pill--idle" data-i18n="settings.robots.statusNotConfigured">未配置</span>
<span class="robot-card-action" data-i18n="settings.robots.configure">配置</span>
</span>
</button>
<button type="button" class="robot-card" data-robot-card="discord" onclick="openRobotEditor('discord')">
<span class="robot-card-main">
<span class="robot-card-title" data-i18n="settings.robots.discord.title">Discord</span>
<span class="robot-card-desc" data-i18n="settings.robots.discord.subtitle">Gateway WebSocket,私聊与服务器 @</span>
</span>
<span class="robot-card-meta">
<span id="robot-card-discord-status" class="robot-status-pill robot-status-pill--idle" data-i18n="settings.robots.statusNotConfigured">未配置</span>
<span class="robot-card-action" data-i18n="settings.robots.configure">配置</span>
</span>
</button>
<button type="button" class="robot-card" data-robot-card="qq" onclick="openRobotEditor('qq')">
<span class="robot-card-main">
<span class="robot-card-title" data-i18n="settings.robots.qq.title">QQ 机器人</span>
<span class="robot-card-desc" data-i18n="settings.robots.qq.subtitle">QQ 开放平台 WebSocketC2C 与群 @</span>
</span>
<span class="robot-card-meta">
<span id="robot-card-qq-status" class="robot-status-pill robot-status-pill--idle" data-i18n="settings.robots.statusNotConfigured">未配置</span>
<span class="robot-card-action" data-i18n="settings.robots.configure">配置</span>
</span>
</button>
</div>
</div>
<div id="robot-editor-empty" class="robot-editor-empty">
<h4 data-i18n="settings.robots.emptyTitle">选择一个机器人开始配置</h4>
<p data-i18n="settings.robots.emptyDesc">从上方列表点击已有平台,或点击“新建机器人”按类型创建。</p>
</div>
<!-- 微信 / iLink --> <!-- 微信 / iLink -->
<div class="settings-subsection robot-wechat-card" id="robot-wechat-subsection"> <div class="settings-subsection robot-editor-panel robot-wechat-card" id="robot-wechat-subsection" data-robot-editor="wechat" hidden>
<div class="robot-wechat-header"> <div class="robot-wechat-header">
<div class="robot-wechat-header-text"> <div class="robot-wechat-header-text">
<h4 data-i18n="settings.robots.wechat.title">微信 / iLink</h4> <h4 data-i18n="settings.robots.wechat.title">微信 / iLink</h4>
@@ -3375,7 +3480,7 @@
</div> </div>
<!-- 企业微信 --> <!-- 企业微信 -->
<div class="settings-subsection"> <div class="settings-subsection robot-editor-panel" data-robot-editor="wecom" hidden>
<h4 data-i18n="settings.robots.wecom.title">企业微信</h4> <h4 data-i18n="settings.robots.wecom.title">企业微信</h4>
<div class="settings-form"> <div class="settings-form">
<div class="form-group"> <div class="form-group">
@@ -3409,7 +3514,7 @@
</div> </div>
<!-- 钉钉 --> <!-- 钉钉 -->
<div class="settings-subsection"> <div class="settings-subsection robot-editor-panel" data-robot-editor="dingtalk" hidden>
<h4 data-i18n="settings.robots.dingtalk.title">钉钉</h4> <h4 data-i18n="settings.robots.dingtalk.title">钉钉</h4>
<div class="settings-form"> <div class="settings-form">
<div class="form-group"> <div class="form-group">
@@ -3432,7 +3537,7 @@
</div> </div>
<!-- 飞书 --> <!-- 飞书 -->
<div class="settings-subsection"> <div class="settings-subsection robot-editor-panel" data-robot-editor="lark" hidden>
<h4 data-i18n="settings.robots.lark.title">飞书 (Lark)</h4> <h4 data-i18n="settings.robots.lark.title">飞书 (Lark)</h4>
<div class="settings-form"> <div class="settings-form">
<div class="form-group"> <div class="form-group">
@@ -3457,42 +3562,212 @@
</div> </div>
</div> </div>
<div class="settings-subsection"> <!-- Telegram -->
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4> <div class="settings-subsection robot-editor-panel" data-robot-editor="telegram" hidden>
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsDesc">在对话中可发送以下命令(支持中英文):</p> <h4 data-i18n="settings.robots.telegram.title">Telegram</h4>
<div class="settings-form">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="robot-telegram-enabled" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settings.robots.telegram.enabled">启用 Telegram 机器人</span>
</label>
</div>
<div class="form-group">
<label for="robot-telegram-bot-token" data-i18n="settings.robots.telegram.botToken">Bot Token</label>
<input type="password" id="robot-telegram-bot-token" autocomplete="off" placeholder="从 @BotFather 获取" />
<small class="form-hint" data-i18n="settings.robots.telegram.botTokenHint">通过 getUpdates 长轮询收消息,无需公网回调</small>
</div>
<div class="form-group">
<label for="robot-telegram-bot-username" data-i18n="settings.robots.telegram.botUsername">Bot Username(可选)</label>
<input type="text" id="robot-telegram-bot-username" autocomplete="off" placeholder="不含 @,留空则自动 getMe" />
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="robot-telegram-allow-group" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settings.robots.telegram.allowGroup">允许群聊(仅响应 @ 机器人)</span>
</label>
</div>
</div>
</div>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryGeneral">通用</p> <!-- Slack -->
<ul class="robot-cmd-list"> <div class="settings-subsection robot-editor-panel" data-robot-editor="slack" hidden>
<li><code>帮助</code> <code>help</code><span data-i18n="settingsRobotsExtra.botCmdHelp">显示本帮助 | Show this help</span></li> <h4 data-i18n="settings.robots.slack.title">Slack</h4>
<li><code>版本</code> <code>version</code><span data-i18n="settingsRobotsExtra.botCmdVersion">显示当前版本号 | Show version</span></li> <div class="settings-form">
</ul> <div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="robot-slack-enabled" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settings.robots.slack.enabled">启用 Slack 机器人</span>
</label>
</div>
<div class="form-group">
<label for="robot-slack-bot-token" data-i18n="settings.robots.slack.botToken">Bot Token (xoxb-)</label>
<input type="password" id="robot-slack-bot-token" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-slack-app-token" data-i18n="settings.robots.slack.appToken">App-Level Token (xapp-)</label>
<input type="password" id="robot-slack-app-token" autocomplete="off" />
<small class="form-hint" data-i18n="settings.robots.slack.appTokenHint">需 connections:write 权限,用于 Socket Mode</small>
</div>
</div>
</div>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryConversation">对话</p> <!-- Discord -->
<ul class="robot-cmd-list"> <div class="settings-subsection robot-editor-panel" data-robot-editor="discord" hidden>
<li><code>列表</code> <code>list</code><span data-i18n="settingsRobotsExtra.botCmdList">列出所有对话标题与 ID | List conversations</span></li> <h4 data-i18n="settings.robots.discord.title">Discord</h4>
<li><code>切换 &lt;ID&gt;</code> <code>switch &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdSwitch">指定对话继续 | Switch to conversation</span></li> <div class="settings-form">
<li><code>新对话</code> <code>new</code><span data-i18n="settingsRobotsExtra.botCmdNew">开启新对话 | Start new conversation</span></li> <div class="form-group">
<li><code>清空</code> <code>clear</code> <span data-i18n="settingsRobotsExtra.botCmdClear">清空当前上下文 | Clear context</span></li> <label class="checkbox-label">
<li><code>当前</code> <code>current</code> <span data-i18n="settingsRobotsExtra.botCmdCurrent">显示当前对话、角色与项目 | Show current conversation</span></li> <input type="checkbox" id="robot-discord-enabled" class="modern-checkbox" />
<li><code>停止</code> <code>stop</code> <span data-i18n="settingsRobotsExtra.botCmdStop">中断当前任务 | Stop running task</span></li> <span class="checkbox-custom"></span>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li> <span class="checkbox-text" data-i18n="settings.robots.discord.enabled">启用 Discord 机器人</span>
</ul> </label>
</div>
<div class="form-group">
<label for="robot-discord-bot-token" data-i18n="settings.robots.discord.botToken">Bot Token</label>
<input type="password" id="robot-discord-bot-token" autocomplete="off" />
<small class="form-hint" data-i18n="settings.robots.discord.botTokenHint">开发者门户创建 Bot,开启 Message Content Intent</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="robot-discord-allow-guild" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settings.robots.discord.allowGuild">允许服务器频道(仅响应 @ 机器人)</span>
</label>
</div>
</div>
</div>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryRole">角色</p> <!-- QQ -->
<ul class="robot-cmd-list"> <div class="settings-subsection robot-editor-panel" data-robot-editor="qq" hidden>
<li><code>角色</code> <code>roles</code><span data-i18n="settingsRobotsExtra.botCmdRoles">列出所有可用角色 | List roles</span></li> <h4 data-i18n="settings.robots.qq.title">QQ 机器人</h4>
<li><code>角色 &lt;&gt;</code> <code>role &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li> <div class="settings-form">
</ul> <div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="robot-qq-enabled" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settings.robots.qq.enabled">启用 QQ 机器人</span>
</label>
</div>
<div class="form-group">
<label for="robot-qq-app-id" data-i18n="settings.robots.qq.appId">App ID</label>
<input type="text" id="robot-qq-app-id" autocomplete="off" />
</div>
<div class="form-group">
<label for="robot-qq-client-secret" data-i18n="settings.robots.qq.clientSecret">Client Secret</label>
<input type="password" id="robot-qq-client-secret" autocomplete="off" />
<small class="form-hint" data-i18n="settings.robots.qq.secretHint">从 QQ 机器人开放平台获取;上线前可勾选沙箱</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="robot-qq-sandbox" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="settings.robots.qq.sandbox">沙箱环境</span>
</label>
</div>
</div>
</div>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryProject">项目</p> <div id="robot-create-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="robot-create-title">
<ul class="robot-cmd-list"> <div class="modal-content robot-create-modal-content">
<li><code>项目</code> <code>projects</code><span data-i18n="settingsRobotsExtra.botCmdProjects">列出所有项目 | List projects</span></li> <div class="modal-header">
<li><code>新建项目 &lt;名称&gt;</code> <code>new project &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdNewProject">创建项目并绑定当前对话 | Create &amp; bind project</span></li> <h2 id="robot-create-title" data-i18n="settings.robots.createTitle">新建机器人</h2>
<li><code>绑定项目 &lt;ID或名称&gt;</code> <code>bind project &lt;ID|name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdBindProject">将当前对话绑定到项目 | Bind conversation</span></li> <button type="button" class="modal-close" onclick="closeRobotCreateModal()" aria-label="Close"></button>
<li><code>解除项目</code> <code>unbind project</code><span data-i18n="settingsRobotsExtra.botCmdUnbindProject">解除当前对话的项目绑定 | Unbind project</span></li> </div>
</ul> <div class="modal-body">
<p class="settings-description" data-i18n="settings.robots.createDesc">选择机器人类型后进入专属配置。当前版本每个平台支持一个内置机器人配置。</p>
<div class="robot-type-grid">
<button type="button" class="robot-type-option" onclick="selectRobotType('wechat')">
<span data-i18n="settings.robots.wechat.title">微信 / iLink</span>
<small data-i18n="settings.robots.wechat.subtitle">扫码绑定个人微信</small>
</button>
<button type="button" class="robot-type-option" onclick="selectRobotType('wecom')">
<span data-i18n="settings.robots.wecom.title">企业微信</span>
<small data-i18n="settings.robots.wecom.subtitle">HTTP 回调模式</small>
</button>
<button type="button" class="robot-type-option" onclick="selectRobotType('dingtalk')">
<span data-i18n="settings.robots.dingtalk.title">钉钉</span>
<small data-i18n="settings.robots.dingtalk.subtitle">Stream 长连接</small>
</button>
<button type="button" class="robot-type-option" onclick="selectRobotType('lark')">
<span data-i18n="settings.robots.lark.title">飞书 (Lark)</span>
<small data-i18n="settings.robots.lark.subtitle">企业应用机器人</small>
</button>
<button type="button" class="robot-type-option" onclick="selectRobotType('telegram')">
<span data-i18n="settings.robots.telegram.title">Telegram</span>
<small data-i18n="settings.robots.telegram.subtitle">Bot API 长轮询</small>
</button>
<button type="button" class="robot-type-option" onclick="selectRobotType('slack')">
<span data-i18n="settings.robots.slack.title">Slack</span>
<small data-i18n="settings.robots.slack.subtitle">Socket Mode</small>
</button>
<button type="button" class="robot-type-option" onclick="selectRobotType('discord')">
<span data-i18n="settings.robots.discord.title">Discord</span>
<small data-i18n="settings.robots.discord.subtitle">Gateway WebSocket</small>
</button>
<button type="button" class="robot-type-option" onclick="selectRobotType('qq')">
<span data-i18n="settings.robots.qq.title">QQ 机器人</span>
<small data-i18n="settings.robots.qq.subtitle">QQ 开放平台</small>
</button>
</div>
</div>
</div>
</div>
<p class="settings-description robot-cmd-footer" data-i18n="settingsRobotsExtra.botCommandsFooter">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p> <div id="robot-commands-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="robot-commands-title">
<div class="modal-content robot-commands-modal-content">
<div class="modal-header">
<h2 id="robot-commands-title" data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h2>
<button type="button" class="modal-close" onclick="closeRobotCommandsModal()" aria-label="Close"></button>
</div>
<div class="modal-body robot-commands-modal-body">
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsDesc">在对话中可发送以下命令(支持中英文):</p>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryGeneral">通用</p>
<ul class="robot-cmd-list">
<li><code>帮助</code> <code>help</code><span data-i18n="settingsRobotsExtra.botCmdHelp">显示本帮助 | Show this help</span></li>
<li><code>版本</code> <code>version</code><span data-i18n="settingsRobotsExtra.botCmdVersion">显示当前版本号 | Show version</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryConversation">对话</p>
<ul class="robot-cmd-list">
<li><code>列表</code> <code>list</code><span data-i18n="settingsRobotsExtra.botCmdList">列出所有对话标题与 ID | List conversations</span></li>
<li><code>切换 &lt;ID&gt;</code> <code>switch &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdSwitch">指定对话继续 | Switch to conversation</span></li>
<li><code>新对话</code> <code>new</code><span data-i18n="settingsRobotsExtra.botCmdNew">开启新对话 | Start new conversation</span></li>
<li><code>清空</code> <code>clear</code><span data-i18n="settingsRobotsExtra.botCmdClear">清空当前上下文 | Clear context</span></li>
<li><code>当前</code> <code>current</code><span data-i18n="settingsRobotsExtra.botCmdCurrent">显示当前对话、角色与项目 | Show current conversation</span></li>
<li><code>停止</code> <code>stop</code><span data-i18n="settingsRobotsExtra.botCmdStop">中断当前任务 | Stop running task</span></li>
<li><code>删除 &lt;ID&gt;</code> <code>delete &lt;ID&gt;</code><span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryRole">角色</p>
<ul class="robot-cmd-list">
<li><code>角色</code> <code>roles</code><span data-i18n="settingsRobotsExtra.botCmdRoles">列出所有可用角色 | List roles</span></li>
<li><code>角色 &lt;&gt;</code> <code>role &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
</ul>
<p class="robot-cmd-category" data-i18n="settingsRobotsExtra.botCmdCategoryProject">项目</p>
<ul class="robot-cmd-list">
<li><code>项目</code> <code>projects</code><span data-i18n="settingsRobotsExtra.botCmdProjects">列出所有项目 | List projects</span></li>
<li><code>新建项目 &lt;名称&gt;</code> <code>new project &lt;name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdNewProject">创建项目并绑定当前对话 | Create &amp; bind project</span></li>
<li><code>绑定项目 &lt;ID或名称&gt;</code> <code>bind project &lt;ID|name&gt;</code><span data-i18n="settingsRobotsExtra.botCmdBindProject">将当前对话绑定到项目 | Bind conversation</span></li>
<li><code>解除项目</code> <code>unbind project</code><span data-i18n="settingsRobotsExtra.botCmdUnbindProject">解除当前对话的项目绑定 | Unbind project</span></li>
</ul>
<p class="settings-description robot-cmd-footer" data-i18n="settingsRobotsExtra.botCommandsFooter">除以上命令外,直接输入内容将发送给 AI 进行渗透测试/安全分析。Otherwise, send any text for AI penetration testing / security analysis.</p>
</div>
</div>
</div>
<div class="settings-subsection robot-command-entry">
<div class="robot-command-entry-copy">
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4>
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsEntryDesc">查看机器人对话中可用的中英文命令,包括对话、角色、项目等常用操作。</p>
</div>
<button type="button" class="btn-secondary" onclick="openRobotCommandsModal()" data-i18n="settingsRobotsExtra.viewAllCommands">查看全部命令</button>
</div> </div>
<div class="settings-actions"> <div class="settings-actions">
@@ -3796,6 +4071,10 @@
<div class="form-group"> <div class="form-group">
<label for="external-mcp-json"><span data-i18n="externalMcpModal.configJson">配置JSON</span> <span style="color: red;">*</span></label> <label for="external-mcp-json"><span data-i18n="externalMcpModal.configJson">配置JSON</span> <span style="color: red;">*</span></label>
<textarea id="external-mcp-json" rows="15" data-i18n="externalMcpModal.placeholder" data-i18n-attr="placeholder" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea> <textarea id="external-mcp-json" rows="15" data-i18n="externalMcpModal.placeholder" data-i18n-attr="placeholder" style="font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.875rem; line-height: 1.5;"></textarea>
<div class="form-actions" style="justify-content: flex-start; margin-top: 0;">
<button type="button" class="btn-secondary" onclick="formatExternalMCPJSON()" data-i18n="externalMcpModal.formatJson">格式化JSON</button>
<button type="button" class="btn-secondary" onclick="loadExternalMCPExample()" data-i18n="externalMcpModal.loadExample">加载示例</button>
</div>
<div class="password-hint"> <div class="password-hint">
<strong data-i18n="externalMcpModal.formatLabel">配置格式:</strong><span data-i18n="externalMcpModal.formatDesc">JSON对象,key为配置名称,value为配置内容。状态通过"启动/停止"按钮控制,无需在JSON中配置。</span><br> <strong data-i18n="externalMcpModal.formatLabel">配置格式:</strong><span data-i18n="externalMcpModal.formatDesc">JSON对象,key为配置名称,value为配置内容。状态通过"启动/停止"按钮控制,无需在JSON中配置。</span><br>
<strong data-i18n="externalMcpModal.configExample">配置示例:</strong><br> <strong data-i18n="externalMcpModal.configExample">配置示例:</strong><br>
@@ -3825,10 +4104,6 @@
</div> </div>
<div id="external-mcp-json-error" class="error-message" style="display: none; margin-top: 8px; padding: 8px; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 4px; color: var(--error-color); font-size: 0.875rem;"></div> <div id="external-mcp-json-error" class="error-message" style="display: none; margin-top: 8px; padding: 8px; background: rgba(220, 53, 69, 0.1); border: 1px solid rgba(220, 53, 69, 0.3); border-radius: 4px; color: var(--error-color); font-size: 0.875rem;"></div>
</div> </div>
<div class="form-group">
<button type="button" class="btn-secondary" onclick="formatExternalMCPJSON()" style="margin-top: 8px;" data-i18n="externalMcpModal.formatJson">格式化JSON</button>
<button type="button" class="btn-secondary" onclick="loadExternalMCPExample()" style="margin-top: 8px; margin-left: 8px;" data-i18n="externalMcpModal.loadExample">加载示例</button>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn-secondary" onclick="closeExternalMCPModal()" data-i18n="common.cancel">取消</button> <button class="btn-secondary" onclick="closeExternalMCPModal()" data-i18n="common.cancel">取消</button>
@@ -4037,6 +4312,44 @@
window.ELK = elk; window.ELK = elk;
} }
</script> </script>
<!-- 图编排流程信息 -->
<div id="workflow-meta-modal" class="modal" style="display: none;">
<div class="modal-content workflow-meta-modal-content">
<div class="modal-header workflow-meta-modal-header">
<h2 data-i18n="workflows.metaModalTitle">流程信息</h2>
<span class="modal-close" onclick="closeWorkflowMetaModal()">&times;</span>
</div>
<div class="modal-body workflow-meta-modal-body">
<div class="form-group workflow-meta-field">
<label for="workflow-name"><span data-i18n="workflows.metaName">名称</span> <span class="form-required">*</span></label>
<input type="text" id="workflow-name" class="form-input" data-i18n="workflows.namePlaceholder" data-i18n-attr="placeholder" placeholder="基础 Web 扫描" autocomplete="off">
</div>
<div class="form-group workflow-meta-field workflow-meta-id-group" id="workflow-meta-id-group">
<label for="workflow-id"><span data-i18n="workflows.metaId">ID</span> <span class="form-required">*</span></label>
<div id="workflow-id-locked" class="workflow-meta-id-locked" hidden>
<code id="workflow-id-display"></code>
</div>
<input type="text" id="workflow-id" class="form-input workflow-meta-id-input" placeholder="web-scan-basic" autocomplete="off">
<small class="workflow-meta-id-hint form-hint" data-i18n="workflows.metaIdHint">创建后不可修改,用于 API 与角色绑定</small>
</div>
<div class="form-group workflow-meta-field">
<label for="workflow-description" data-i18n="workflows.metaDescription">描述</label>
<input type="text" id="workflow-description" class="form-input" data-i18n="workflows.descriptionPlaceholder" data-i18n-attr="placeholder" placeholder="可选" autocomplete="off">
</div>
<div class="workflow-meta-enable-row">
<span class="workflow-meta-enable-label" data-i18n="workflows.metaEnabled">启用</span>
<label class="workflow-switch workflow-switch--modal" data-i18n="workflows.metaEnabledHint" data-i18n-attr="title" title="禁用后无法被角色自动触发">
<input type="checkbox" id="workflow-enabled" checked>
<span class="workflow-switch-slider" aria-hidden="true"></span>
</label>
</div>
</div>
<div class="modal-footer workflow-meta-modal-footer">
<button type="button" class="btn-secondary btn-small" onclick="closeWorkflowMetaModal()" data-i18n="common.cancel">取消</button>
<button type="button" class="btn-primary btn-small" onclick="applyWorkflowMetaModal()" data-i18n="common.confirm">确认</button>
</div>
</div>
</div>
<!-- 知识项编辑模态框 --> <!-- 知识项编辑模态框 -->
<!-- Skill模态框 --> <!-- Skill模态框 -->
<div id="skill-modal" class="modal"> <div id="skill-modal" class="modal">