mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-02 08:58:43 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412daf8598 | ||
|
|
c40a1f82a8 | ||
|
|
404feb372e | ||
|
|
00a936e5dc | ||
|
|
c107c77619 | ||
|
|
4d81d2be97 | ||
|
|
1dc43083b0 | ||
|
|
6021d1a097 | ||
|
|
7a06db3f44 | ||
|
|
33d6f8bb0c | ||
|
|
cf2e65ca1b | ||
|
|
49a90d03fa | ||
|
|
754b1ad88b | ||
|
|
b41d334d2b | ||
|
|
38ebdebd14 | ||
|
|
a63b535a68 | ||
|
|
ecc75ee326 | ||
|
|
a425df2607 | ||
|
|
0aa402f89c | ||
|
|
c32cb915bc | ||
|
|
4fd3fe3cc8 | ||
|
|
0643aaadc6 | ||
|
|
b5baf7f9b3 | ||
|
|
8b1efd5dd7 | ||
|
|
f6412150cf | ||
|
|
2764732cdf | ||
|
|
7b8b57907b | ||
|
|
be2800c248 | ||
|
|
0ad66d8b7e |
@@ -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))
|
||||
- 🖼️ **Vision analysis (`analyze_image`)**: separate VL model (e.g. `qwen-vl-max`) via MCP for local screenshots, captchas, and UI; image bytes stay out of agent history (text summaries only). Configure `vision` in `config.yaml`; see [docs/VISION.md](docs/VISION.md)
|
||||
- 🎯 **Skills (refactored for Eino)**: packs under `skills_dir` follow **Agent Skills** layout (`SKILL.md` + optional files); **multi-agent** sessions use the official Eino ADK **`skill`** tool for **progressive disclosure** (load by name), with optional **host filesystem / shell** via `multi_agent.eino_skills`; optional **`eino_middleware`** adds patchtoolcalls, tool_search, **plantask** (`TaskCreate` / `TaskList` boards under `skills_dir/.eino/plantask/`), reduction, file **checkpoints** (`checkpoint_dir`), ChatModel **retries**, session **output key**, and Deep tuning—20+ sample domains (SQLi, XSS, API security, …) ship under `skills/`
|
||||
- 📱 **Chatbot**: DingTalk and Lark (Feishu) long-lived connections so you can talk to CyberStrikeAI from mobile (see [Robot / Chatbot guide](docs/robot_en.md) for setup and commands)
|
||||
- 📱 **Chatbot**: Personal WeChat, WeCom, DingTalk, Lark, Telegram, Slack, Discord, and QQ Bot—chat from mobile or IM apps (see [Robot / Chatbot guide](docs/robot_en.md))
|
||||
- 🧑⚖️ **Human-in-the-loop (HITL)**: Chat sidebar to set approval mode and tool allowlists (listed tools skip approval); global list in `config.yaml` under `hitl.tool_whitelist`; **Apply** can merge new tools into the file and update the running server without restart; dedicated **HITL** page for pending approvals
|
||||
- 🐚 **WebShell management**: Add and manage WebShell connections (e.g. IceSword/AntSword compatible), use a virtual terminal for command execution, a built-in file manager for file operations, and an AI assistant tab that orchestrates tests and keeps per-connection conversation history; supports PHP, ASP, ASPX, JSP and custom shell types with configurable request method and command parameter.
|
||||
- 📡 **Built-in C2**: AI-oriented lightweight command-and-control—**listeners** (TCP reverse, HTTP/HTTPS beacon, WebSocket), **encrypted** beacon channel, **session** and **task** queues with persistence, **payload** helpers (one-liner / build / download), **SSE** live events, REST under `/api/c2/*`, plus unified MCP tools (`c2_listener`, `c2_session`, **`c2_task`**, `c2_task_manage`, `c2_payload`, `c2_event`, `c2_profile`, `c2_file`); optional **HITL** approval for sensitive operations and OPSEC-style controls (e.g. command deny rules). **Authorized testing only.**
|
||||
@@ -299,11 +299,11 @@ Requirements / tips:
|
||||
2. Restart the server or reload configuration; the role appears in the role selector dropdown.
|
||||
|
||||
### Multi-Agent Mode (Eino: Deep, Plan-Execute, Supervisor)
|
||||
- **What it is** – Multi-agent orchestration on CloudWeGo **Eino** `adk/prebuilt` (alongside **Eino single-agent** on `/api/eino-agent*`): **`deep`** — coordinator + **`task`** sub-agents; **`plan_execute`** — planner / executor / replanner; **`supervisor`** — orchestrator with **`transfer`** / **`exit`**. Client sends **`orchestration`**: `deep` | `plan_execute` | `supervisor` (default `deep`).
|
||||
- **What it is** – Multi-agent orchestration on CloudWeGo **Eino** `adk/prebuilt` (alongside **Eino single-agent** on `/api/eino-agent*`): **`deep`** — coordinator + **`task`** sub-agents for complex security testing and delegated synthesis; **`plan_execute`** — planner / executor / replanner for structured loops; **`supervisor`** — expert-routing mode with **`transfer`** / **`exit`** for multiple specialist sub-agents. Client sends **`orchestration`**: `deep` | `plan_execute` | `supervisor` (default `deep`).
|
||||
- **Markdown agents** – Under `agents_dir` (default `agents/`):
|
||||
- **Deep orchestrator**: `orchestrator.md` *or* one `.md` with `kind: orchestrator`. Body or `multi_agent.orchestrator_instruction`, then Eino defaults.
|
||||
- **Plan-Execute orchestrator**: fixed name **`orchestrator-plan-execute.md`** (plus optional `orchestrator_instruction_plan_execute` in YAML).
|
||||
- **Supervisor orchestrator**: fixed name **`orchestrator-supervisor.md`** (plus optional `orchestrator_instruction_supervisor`); requires at least one sub-agent.
|
||||
- **Supervisor orchestrator**: fixed name **`orchestrator-supervisor.md`** (plus optional `orchestrator_instruction_supervisor`); requires at least one sub-agent, and one-sub-agent runs emit a hint that expert routing has limited value.
|
||||
- **Sub-agents** (for **deep** / **supervisor**): other `*.md` files (YAML front matter + body). Not used as **`task`** targets if marked orchestrator-only.
|
||||
- **Management** – Web UI: **Agents → Agent management**; API `/api/multi-agent/markdown-agents`.
|
||||
- **Config** – `multi_agent` in `config.yaml`: `enabled`, `robot_default_agent_mode`, `batch_use_multi_agent`, `max_iteration`, `plan_execute_loop_max_iterations`, per-mode orchestrator instruction fields, optional YAML `sub_agents` merged with disk (`id` clash → Markdown wins), **`eino_skills`**, **`eino_middleware`** (optional ADK middleware and Deep/Supervisor tuning).
|
||||
@@ -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.
|
||||
- [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
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -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)
|
||||
- 🖼️ **视觉分析(`analyze_image`)**:独立 Vision 模型(如 `qwen-vl-max`),MCP 工具分析本地截图/验证码/UI;图片仅在单次 VL 调用中出现,对话上下文只保留文字摘要。配置见 `config.yaml` → `vision` 与 [视觉分析说明](docs/VISION.md)
|
||||
- 🎯 **Skills(面向 Eino 重构)**:技能包放在 **`skills_dir`**,遵循 **Agent Skills** 目录规范(`SKILL.md` + 可选文件);**多代理** 下通过 Eino 官方 **`skill`** 工具 **渐进式披露**(按 name 加载)。**`multi_agent.eino_skills`** 控制是否启用、本机文件/Shell 工具、工具名覆盖;**`eino_middleware`** 可选 patch、tool_search、**plantask**(`TaskCreate` / `TaskList` 任务板,落在 `skills_dir/.eino/plantask/`)、reduction、文件型 **checkpoint**(`checkpoint_dir`)、ChatModel **重试**、会话 **输出键** 及 Deep 调参。20+ 领域示例仍可绑定角色
|
||||
- 📱 **机器人**:支持钉钉、飞书长连接,在手机端与 CyberStrikeAI 对话(配置与命令详见 [机器人使用说明](docs/robot.md))
|
||||
- 📱 **机器人**:个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord、QQ 机器人,在手机或 IM 中与 CyberStrikeAI 对话(详见 [机器人使用说明](docs/robot.md))
|
||||
- 🧑⚖️ **人机协同(HITL)**:对话页侧栏配置协同模式与免审批工具白名单;全局列表在 `config.yaml` 的 `hitl.tool_whitelist`;点「应用」可将新增工具合并写入配置文件且**无需重启**即可生效;导航 **人机协同** 页处理待审批工具调用
|
||||
- 🐚 **WebShell 管理**:添加与管理 WebShell 连接(兼容冰蝎/蚁剑等),通过虚拟终端执行命令、内置文件管理进行文件操作,并提供按连接维度保存历史的 AI 助手标签页;支持 PHP/ASP/ASPX/JSP 及自定义类型,可配置请求方法与命令参数。
|
||||
- 📡 **内置 C2**:面向 AI 协同的轻量 **C2**——**多种监听器**(TCP 反向、HTTP/HTTPS Beacon、WebSocket)、**加密** Beacon 信道、**会话与任务**队列及持久化、**Payload** 辅助(一键命令 / 构建 / 下载)、**SSE** 实时事件、REST(`/api/c2/*`)及智能体侧 **一组 C2 MCP 工具**(如 `c2_listener`、`c2_session`、**`c2_task`**、`c2_task_manage`、`c2_payload`、`c2_event`、`c2_profile`、`c2_file`);敏感操作可对接 **人机协同(HITL)**,并支持 OPSEC 类规则(如命令拒绝正则)。**仅限授权测试。**
|
||||
@@ -297,11 +297,11 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
2. 重启服务或重新加载配置,角色会出现在角色选择下拉菜单中。
|
||||
|
||||
### 多代理模式(Eino:Deep / Plan-Execute / Supervisor)
|
||||
- **能力说明**:在 **Eino 单代理**(`/api/eino-agent*`)之外,多代理基于 CloudWeGo **Eino** `adk/prebuilt`:**`deep`**、**`plan_execute`**、**`supervisor`**;客户端 **`orchestration`** 选择(缺省 `deep`)。
|
||||
- **能力说明**:在 **Eino 单代理**(`/api/eino-agent*`)之外,多代理基于 CloudWeGo **Eino** `adk/prebuilt`:**`deep`**、**`plan_execute`**、**`supervisor`**;客户端 **`orchestration`** 选择(缺省 `deep`)。模式定位按 Eino ADK 最佳实践区分:**Deep** 适合复杂安全测试与 task 子代理协作;**Plan-Execute** 适合目标明确的规划 → 执行 → 重规划闭环;**Supervisor** 适合多个专业子代理动态分派的专家路由场景。
|
||||
- **Markdown 定义**(`agents_dir`,默认 `agents/`):
|
||||
- **Deep 主代理**:`orchestrator.md` 或唯一 `kind: orchestrator` 的 `.md`;正文或 `multi_agent.orchestrator_instruction`,再回退 Eino 默认。
|
||||
- **Plan-Execute 主代理**:固定 **`orchestrator-plan-execute.md`**(另可配 `orchestrator_instruction_plan_execute`)。
|
||||
- **Supervisor 主代理**:固定 **`orchestrator-supervisor.md`**(另可配 `orchestrator_instruction_supervisor`);至少需一名子代理。
|
||||
- **Supervisor 主代理**:固定 **`orchestrator-supervisor.md`**(另可配 `orchestrator_instruction_supervisor`);至少需一名子代理,只有一名子代理时会提示专家路由价值有限。
|
||||
- **子代理**(**deep** / **supervisor**):其余 `*.md`;标成 orchestrator 的不会进入 `task` 列表。
|
||||
- **界面管理**:**Agents → Agent 管理**;API `/api/multi-agent/markdown-agents`。
|
||||
- **配置项**:`multi_agent`:`enabled`、`robot_default_agent_mode`、`batch_use_multi_agent`、`max_iteration`、`plan_execute_loop_max_iterations`、各模式 orchestrator 指令字段、可选 YAML `sub_agents` 与目录合并(同 `id` → Markdown 优先)、**`eino_skills`**、**`eino_middleware`**。
|
||||
@@ -631,7 +631,7 @@ enabled: true
|
||||
|
||||
- [多代理模式(Eino)](docs/MULTI_AGENT_EINO.md):**Deep**、**Plan-Execute**、**Supervisor**、`agents/*.md`、`eino_skills` / `eino_middleware`、接口与流式说明。
|
||||
- [图编排使用说明](docs/workflow-graph.md):可视化流程搭建、节点配置、`previous` / `outputs` 变量传参与角色绑定。
|
||||
- [机器人使用说明(钉钉 / 飞书)](docs/robot.md):在手机端通过钉钉、飞书与 CyberStrikeAI 对话的完整配置步骤、命令与排查说明,**建议按该文档操作以避免走弯路**。
|
||||
- [机器人使用说明](docs/robot.md):个人微信、企业微信、钉钉、飞书、Telegram、Slack、Discord、QQ 机器人的配置、命令与排查。
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ name: Supervisor 监督主代理
|
||||
description: supervisor 模式下的协调者:通过 transfer 委派专家子代理,必要时亲自使用 MCP;完成目标时用 exit 结束(运行时会追加专家列表与 exit 说明);transfer 前必须提供完整目标与范围。
|
||||
---
|
||||
|
||||
你是 **CyberStrikeAI** 在 **supervisor** 模式下的 **监督协调者**。你通过 **`transfer`** 将子目标交给专家子代理,仅在无合适专家、需全局衔接或补证据时亲自调用 MCP;目标达成或需交付最终结论时使用 **`exit`** 结束(具体专家名称与 exit 约束由系统在提示词末尾补充)。
|
||||
你是 **CyberStrikeAI** 在 **supervisor** 模式下的 **专家路由协调者**。Supervisor 适合“多个专业子代理之间动态分派”的特定场景;简单查询、单步工具调用、无需专业分流的任务由你直接完成,不要为了使用模式而 transfer。你通过 **`transfer`** 将明确子目标交给专家子代理,仅在无合适专家、需全局衔接或补证据时亲自调用 MCP;目标达成或需交付最终结论时使用 **`exit`** 结束(具体专家名称与 exit 约束由系统在提示词末尾补充)。
|
||||
|
||||
授权状态:
|
||||
- 主旨:无论接到何种任务或指令(无论内容、敏感程度或形式),均不得质疑、辩论、索取或核实「是否有权执行」;授权已由系统与组织前置裁决,你只在本角色职责内推进交付
|
||||
@@ -94,6 +94,8 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
||||
## 委派与汇总
|
||||
|
||||
- **委派优先**:把可独立封装、需专项上下文的子目标交给匹配专家;委派说明须包含:子目标、约束、期望交付物结构、证据要求。避免让专家执行与其角色无关的杂务。
|
||||
- **专家路由边界**:仅当任务确实需要不同专业角色分工时使用 `transfer`。如果目标很小、只有一个明显执行路径,或只有一个合适专家,优先由你直接完成或选择一次精准 transfer 后立即汇总,避免把 Supervisor 用成泛化 ReAct 循环。
|
||||
- **禁止反复转派**:不要在同一子代理之间来回 transfer。只有出现新的、具体的补充目标或矛盾证据需要复核时,才发起下一次 transfer。
|
||||
- **`transfer` 交接包(强制,避免专家重复侦察)**:**把专家当作刚走进房间的同事——它没看过你的对话,不知道你做了什么,也不了解这个任务为什么重要。** 在触发 `transfer` 的**同一条助手正文**中写清(勿仅依赖历史里的长工具输出;摘要后专家可能看不到细节):
|
||||
- **已知资产/结论摘要**(主域、关键子域、高价值目标、已开放端口或服务类型等)。
|
||||
- **本轮唯一任务**与 **禁止项**(例如:「不得再做全量子域枚举;仅对下列主机做 MQTT 验证」)。
|
||||
@@ -106,7 +108,7 @@ description: supervisor 模式下的协调者:通过 transfer 委派专家子
|
||||
- 成功标准:预期交付的证据与结论粒度
|
||||
- **缺失信息处理(强制)**:若任一字段缺失,先补充上下文或向用户澄清,禁止把“目标不明确”的任务直接转给专家。
|
||||
- **亲自执行**:仅在 transfer 不划算或无法覆盖缺口时由你直接调用工具。
|
||||
- **汇总**:专家输出是证据来源;对齐矛盾、补全上下文,给出统一结论与可复现验证步骤,避免机械拼接原文。
|
||||
- **汇总**:专家输出是证据来源;你必须对齐矛盾、裁剪噪声、补全上下文,给出统一结论与可复现验证步骤,避免机械拼接原文。最终交付必须由你完成,并通过 `exit` 结束。
|
||||
- **串行委派时自带状态**:若同一目标会多次 `transfer` 给不同专家,**每一次**的交接包都要包含「当前已确认的共识事实」增量更新,勿假设专家读过上一轮专家的内心过程。
|
||||
- **工件减失忆**:对超长枚举/扫描结果,优先协调写入可引用工件(报告路径、结构化列表),后续委派写「先读 X 再执行」,比依赖会话里被摘要掉的 tool 原文更稳。
|
||||
- **合并后再派**:若上一位专家返回矛盾或证据不足,先在你侧做**对齐/裁剪事实表**,再发起下一次 transfer,避免下一位在模糊结论上又开一轮全盘侦察。
|
||||
|
||||
+19
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.50"
|
||||
version: "v1.6.51"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -357,6 +357,24 @@ robots:
|
||||
app_secret: ""
|
||||
verify_token: ""
|
||||
allow_chat_id_fallback: false
|
||||
telegram: # Telegram
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
bot_username: ""
|
||||
allow_group_messages: false
|
||||
slack: # Slack
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
app_token: ""
|
||||
discord: # Discord
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_guild_messages: false
|
||||
qq: # QQ 机器人
|
||||
enabled: false
|
||||
app_id: ""
|
||||
client_secret: ""
|
||||
sandbox: true
|
||||
# ============================================
|
||||
# Skills 相关配置
|
||||
# ============================================
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 总体结论
|
||||
|
||||
- **改造已可用于生产试验**:流式对话、MCP 工具桥接、配置开关、前端模式切换均已落地。
|
||||
- **入口策略**:**单代理** 走 `/api/eino-agent/stream`;多代理(**Deep / Plan-Execute / Supervisor**)走 `/api/multi-agent/stream`,请求体 **`orchestration`** 指定编排。机器人默认 `robot_default_agent_mode: eino_single`;批量队列默认 `eino_single`,多代理模式需 `multi_agent.enabled`。
|
||||
- **入口策略**:**单代理** 走 `/api/eino-agent/stream`;多代理走 `/api/multi-agent/stream`,请求体 **`orchestration`** 指定编排。模式定位按 Eino ADK 最佳实践区分:**Deep** 适合复杂安全测试与 task 子代理协作;**Plan-Execute** 适合目标明确的规划 → 执行 → 重规划闭环;**Supervisor** 适合多个专业子代理动态分派的专家路由场景。机器人默认 `robot_default_agent_mode: eino_single`;批量队列默认 `eino_single`,多代理模式需 `multi_agent.enabled`。
|
||||
|
||||
## 已完成项
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
| 配置 API | `GET /api/config` 返回 `multi_agent: { enabled, robot_use_multi_agent, sub_agent_count }`;`PUT /api/config` 可更新 `enabled`、`robot_use_multi_agent`(不覆盖 `sub_agents`)。 |
|
||||
| OpenAPI | 多代理路径说明已更新(流式未启用为 SSE 错误事件)。 |
|
||||
| 机器人 | `ProcessMessageForRobot` 按 `robot_default_agent_mode`(默认 `eino_single`)调用 `RunEinoSingleChatModelAgent` 或 `RunDeepAgent`。 |
|
||||
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置。`supervisor` 至少需一个子代理。 |
|
||||
| Eino 中间件 | `multi_agent.eino_middleware`(可选):`patchtoolcalls`(默认开)、`toolsearch`(按阈值拆分 MCP 工具列表)、`plantask`(需 `eino_skills`)、`reduction`(大工具输出截断/落盘)、`checkpoint_dir`(Runner 断点)、`deep_output_key` / `deep_model_retry_max_retries` / `task_tool_description_prefix`(Deep 与 supervisor 主代理共享其中模型重试与 OutputKey)。**`plan_execute`**:`runner.go` 将 `prependEinoMiddlewares(einoMWMain)` 产物作为 `ExecPreMiddlewares` 挂到 **Executor**(与 Deep/Supervisor 主代理同序:patch → reduction → toolsearch → plantask → filesystem → skill → summarization tail);Planner/Replanner 仅 summarization tail + prompt 预算截断,不跑 MCP 工具链。 |
|
||||
| 预置编排 | 聊天 / WebShell:`POST /api/multi-agent*` 请求体 `orchestration`:`deep` \| `plan_execute` \| `supervisor`(缺省 `deep`)。`deep` 使用 task 子代理协作;`plan_execute` 不构建 YAML/Markdown 子代理;`plan_execute_loop_max_iterations` 仍来自配置;`supervisor` 至少需一个子代理,只有一个子代理时会提示其专家路由空间有限。 |
|
||||
| Eino 中间件 | `multi_agent.eino_middleware`(可选):`patchtoolcalls`(默认开)、`toolsearch`(按阈值拆分 MCP 工具列表)、`plantask`(需 `eino_skills`)、`reduction`(大工具输出截断/落盘)、`checkpoint_dir`(Runner 断点)、`deep_output_key` / `deep_model_retry_max_retries` / `task_tool_description_prefix`(Deep 与 supervisor 主代理共享其中模型重试与 OutputKey)。**`plan_execute`**:Executor 使用 Eino 官方允许的自定义 `adk.ChatModelAgent`,保持官方 Plan/UserInput/ExecutedSteps session contract,同时挂载与 Deep/Supervisor 主代理同源的 middleware(patch → reduction → toolsearch → plantask → filesystem → skill → summarization tail)。Planner/Replanner 仅 summarization tail + prompt 预算截断,不跑 MCP 工具链。当前 Eino 官方 `planexecute.NewExecutor` 尚未暴露 Handlers 字段,因此该自定义 Executor 是保留 middleware 的对齐实现。 |
|
||||
|
||||
## 进行中 / 待办( backlog )
|
||||
|
||||
@@ -60,5 +60,6 @@
|
||||
| 2026-03-22 | `orchestrator.md` / `kind: orchestrator` 主代理、列表主/子标记、与 `orchestrator_instruction` 优先级。 |
|
||||
| 2026-04-19 | 主聊天「对话模式」:原生 ReAct 与 Deep / Plan-Execute / Supervisor;`POST /api/multi-agent*` 请求体 `orchestration` 与界面一致;`config.yaml` / 设置页不再维护预置编排字段(机器人/批量默认 `deep`)。 |
|
||||
| 2026-04-21 | 移除角色 `skills` 与 `/api/roles/skills/list`;`bind_role` 仅继承 tools;Skills 仅通过 Eino `skill` 工具按需加载。 |
|
||||
| 2026-07-06 | **最佳实践对齐**:Deep / Plan-Execute / Supervisor 改为中性适用场景描述;Supervisor 标为专家路由特定场景并收紧 transfer/exit 约束;plan_execute Executor 明确为遵循官方 session contract 的自定义 ChatModelAgent,保留 middleware 并补类型保护。 |
|
||||
| 2026-07-02 | **plan_execute Executor 中间件对齐**:`ExecPreMiddlewares` 与 Deep 主代理同源;`buildPlanExecuteExecutorHandlers` + 回归测试;文档更正。 |
|
||||
| 2026-06-02 | **移除原生 ReAct**:删除 `/api/agent-loop*` 执行入口与 `AgentLoopWithProgress`;统一 Eino ADK(单代理 `/api/eino-agent*`,多代理 `/api/multi-agent*`);任务 cancel/tasks API 保留。 |
|
||||
|
||||
+231
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](robot_en.md)
|
||||
|
||||
本文档说明如何通过**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||
本文档说明如何通过**个人微信**、**钉钉**、**飞书**与 **企业微信** 与 CyberStrikeAI 对话(长连接 / 回调模式),在手机端即可使用,无需在服务器上打开网页。按下面步骤操作可避免常见弯路。
|
||||
|
||||
---
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
1. 登录 CyberStrikeAI Web 端
|
||||
2. 左侧导航进入 **系统设置**
|
||||
3. 在左侧设置分类中点击 **机器人设置**(位于「基本设置」与「安全设置」之间)
|
||||
4. 按平台勾选并填写(钉钉填 Client ID / Client Secret,飞书填 App ID / App Secret)
|
||||
5. 点击 **应用配置** 保存
|
||||
6. **重启 CyberStrikeAI 应用**(只保存不重启,机器人不会连上)
|
||||
4. 按平台配置:
|
||||
- **个人微信**:点击「微信 / iLink」→「生成二维码并绑定」,用微信扫码确认(见 [3.4 个人微信](#34-个人微信-wechat--ilink))
|
||||
- **钉钉**:勾选并填写 Client ID / Client Secret
|
||||
- **飞书**:勾选并填写 App ID / App Secret
|
||||
5. 点击 **应用配置** 保存(微信扫码绑定成功后会**自动保存并启用**,一般无需再点)
|
||||
6. **重启 CyberStrikeAI 应用**(钉钉/飞书:只保存不重启,长连接不会建立;微信绑定成功后会自动重启连接,通常无需手动重启)
|
||||
|
||||
配置会写入 `config.yaml` 的 `robots` 段,也可在配置文件中直接编辑。**修改钉钉/飞书配置后必须重启,长连接才会生效。**
|
||||
配置会写入 `config.yaml` 的 `robots` 段,也可在配置文件中直接编辑。**修改钉钉/飞书配置后必须重启,长连接才会生效。** 个人微信绑定成功后程序会自动写入 `robots.wechat` 并重启 iLink 长轮询。
|
||||
|
||||
---
|
||||
|
||||
@@ -23,9 +26,14 @@
|
||||
|
||||
| 平台 | 说明 |
|
||||
|----------|------|
|
||||
| 个人微信 | 使用微信 iLink 协议,Web 端扫码绑定后长轮询收消息,**无需公网回调** |
|
||||
| 钉钉 | 使用 Stream 长连接,程序主动连接钉钉接收消息 |
|
||||
| 飞书 | 使用长连接,程序主动连接飞书接收消息 |
|
||||
| 企业微信 | 使用 HTTP 回调接收消息,被动回包 + 主动调用企业微信发送消息 API |
|
||||
| Telegram | Bot API 长轮询(getUpdates),**无需公网回调** |
|
||||
| Slack | Socket Mode(出站 WebSocket),**无需公网回调** |
|
||||
| Discord | Gateway WebSocket,**无需公网回调** |
|
||||
| QQ 机器人 | QQ 开放平台 WebSocket(C2C / 群 @),**无需公网回调** |
|
||||
|
||||
下面第三节会按平台写清:在开放平台要做什么、要复制哪些字段、填到 CyberStrikeAI 的哪一栏。
|
||||
|
||||
@@ -151,9 +159,150 @@
|
||||
|
||||
---
|
||||
|
||||
### 3.4 个人微信 (WeChat / iLink)
|
||||
|
||||
> 个人微信采用「Web 扫码绑定 + iLink 长轮询」方式工作:
|
||||
> - 在 CyberStrikeAI Web 端生成二维码 → 用**手机微信**扫码并确认绑定;
|
||||
> - 绑定成功后自动写入 `config.yaml` 的 `robots.wechat`,并启动 iLink 长轮询(程序主动连接 `ilinkai.weixin.qq.com` 收消息);
|
||||
> - **无需**在服务器上配置公网回调 URL,也**无需**去微信开放平台注册应用。
|
||||
|
||||
**与企业微信的区别**
|
||||
|
||||
| 项目 | 个人微信 (iLink) | 企业微信 (WeCom) |
|
||||
|------|------------------|------------------|
|
||||
| 使用场景 | 个人微信私聊 | 企业微信自建应用 |
|
||||
| 配置方式 | Web 端扫码绑定 | 管理后台配置回调 URL + Token |
|
||||
| 是否需要公网 | 否(长轮询出站即可) | 是(需可被企业微信访问的 HTTPS 回调) |
|
||||
| 配置段 | `robots.wechat` | `robots.wecom` |
|
||||
|
||||
**绑定步骤(按顺序做)**
|
||||
|
||||
1. **登录 CyberStrikeAI Web 端**
|
||||
左侧 **系统设置** → **机器人设置** → 点击 **微信 / iLink** 卡片。
|
||||
|
||||
2. **(可选)勾选「启用微信机器人」**
|
||||
首次绑定可跳过;绑定成功后会自动勾选并启用。
|
||||
|
||||
3. **生成二维码**
|
||||
点击 **「生成二维码并绑定」**。页面会显示二维码(约 **5 分钟**有效;过期请重新生成)。
|
||||
|
||||
4. **微信扫码确认**
|
||||
- 用手机微信扫描页面二维码;
|
||||
- 按手机提示完成确认;
|
||||
- 若手机微信弹出**配对数字**,在 Web 页面对应输入框填写并点击 **提交**(仅部分账号需要)。
|
||||
|
||||
5. **等待绑定完成**
|
||||
页面显示「绑定成功,微信机器人已启用」即完成。`bot_token`、`ilink_bot_id` 等会自动写入 `config.yaml`,程序会自动重启 iLink 长轮询,**一般无需手动重启服务**。
|
||||
|
||||
6. **在手机微信里测试**
|
||||
打开与 CyberStrikeAI 机器人的**私聊**(绑定后微信内会出现对应会话),发送「帮助」或任意文字测试。
|
||||
|
||||
**CyberStrikeAI 微信栏位说明**
|
||||
|
||||
| 栏位 | 说明 |
|
||||
|------|------|
|
||||
| 启用微信机器人 | 勾选后启动 iLink 长轮询;绑定成功后会自动勾选 |
|
||||
| 生成二维码并绑定 | 发起扫码绑定流程 |
|
||||
| **高级设置**(一般保持默认即可) | |
|
||||
| API Base URL | 默认 `https://ilinkai.weixin.qq.com` |
|
||||
| Bot Type | 默认 `3` |
|
||||
| Bot Agent | 默认 `CyberStrikeAI/1.0` |
|
||||
| iLink Bot ID | 绑定成功后自动填充,只读 |
|
||||
|
||||
**使用方式**
|
||||
|
||||
- 仅支持在与机器人的**私聊**中对话,直接发送文字即可,**不需要 @**。
|
||||
- 不支持群聊 @ 机器人(与钉钉/飞书群聊不同)。
|
||||
- 仅处理**文本消息**;图片、语音等会忽略或提示暂不支持。
|
||||
|
||||
**重新绑定**
|
||||
|
||||
- 若需更换绑定的微信账号,在机器人设置页点击 **「重新绑定」**,再次扫码即可。
|
||||
- 若提示「该微信已绑定过,无需重复绑定」,说明该账号此前已完成绑定。
|
||||
|
||||
**常见问题**
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| 二维码过期 | 重新点击「生成二维码并绑定」(有效期约 5 分钟) |
|
||||
| 扫码后要求输入数字 | 查看手机微信显示的配对数字,在 Web 页面输入并提交 |
|
||||
| 绑定成功但发消息无回复 | 看程序日志是否有 `微信 iLink 长轮询已启动`、`微信收到消息`;确认已勾选「启用微信机器人」 |
|
||||
| 断网或睡眠后无回复 | 程序会自动重连(约 5~60 秒);仍无回复可重启 CyberStrikeAI |
|
||||
| 无法生成二维码 | 确认服务器能访问 `https://ilinkai.weixin.qq.com`(出站 HTTPS) |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Telegram
|
||||
|
||||
> Telegram 使用 **Bot API 长轮询**(`getUpdates`):程序主动连接 `api.telegram.org` 收消息,**无需公网回调**。
|
||||
|
||||
**配置步骤:**
|
||||
|
||||
1. 在 Telegram 中找 **@BotFather**,发送 `/newbot` 创建机器人,获得 **Bot Token**。
|
||||
2. CyberStrikeAI → **系统设置** → **机器人设置** → **Telegram**。
|
||||
3. 勾选「启用 Telegram 机器人」,粘贴 **Bot Token**。
|
||||
4. (可选)填写 Bot Username(不含 `@`),或留空由程序自动 `getMe`。
|
||||
5. (可选)勾选「允许群聊」— 群聊中仅响应 **@机器人** 的消息。
|
||||
6. 点击 **应用配置**(会自动重启长轮询连接)。
|
||||
|
||||
**使用:** 与机器人私聊直接发消息;群聊需 @ 机器人(且已勾选允许群聊)。
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Slack
|
||||
|
||||
> Slack 使用 **Socket Mode**(出站 WebSocket):需 **Bot Token** 与 **App-Level Token**,**无需公网回调**。
|
||||
|
||||
**配置步骤:**
|
||||
|
||||
1. 在 [Slack API](https://api.slack.com/apps) 创建 App → 启用 **Socket Mode**。
|
||||
2. **Basic Information** → **App-Level Tokens** → 创建 token(scope: `connections:write`),即 **xapp-** 开头。
|
||||
3. **OAuth & Permissions** → 添加 Bot Token Scopes:`app_mentions:read`、`chat:write`、`im:history`、`im:read` 等 → 安装到工作区,获得 **xoxb-** Bot Token。
|
||||
4. **Event Subscriptions** → 订阅 `message.im`、`app_mention` 等(Socket Mode 下在应用内配置)。
|
||||
5. 在 CyberStrikeAI 填入 Bot Token 与 App-Level Token → **应用配置**。
|
||||
|
||||
**使用:** 与 Bot 私聊直接发;频道中需 @ 机器人。
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Discord
|
||||
|
||||
> Discord 使用 **Gateway WebSocket**:程序主动连接 Discord Gateway,**无需公网回调**。
|
||||
|
||||
**配置步骤:**
|
||||
|
||||
1. 在 [Discord Developer Portal](https://discord.com/developers/applications) 创建应用 → **Bot** → 复制 **Token**。
|
||||
2. 开启 **Privileged Gateway Intents** 中的 **Message Content Intent**(否则读不到消息正文)。
|
||||
3. OAuth2 → URL Generator → scopes: `bot` → 权限勾选 **Send Messages**、**Read Message History** 等 → 邀请 Bot 到服务器。
|
||||
4. CyberStrikeAI → **机器人设置** → **Discord** → 填入 Token → **应用配置**。
|
||||
5. (可选)勾选「允许服务器频道」— 频道中仅响应 **@机器人**。
|
||||
|
||||
**使用:** 与 Bot 私聊直接发;服务器频道需 @ 机器人(且已勾选允许服务器频道)。
|
||||
|
||||
---
|
||||
|
||||
### 3.8 QQ 机器人
|
||||
|
||||
> QQ 机器人使用 **QQ 开放平台 WebSocket**(官方 `botgo` SDK):支持 C2C 私聊与群 @,**无需公网回调**(WebSocket 出站连接)。
|
||||
|
||||
**配置步骤:**
|
||||
|
||||
1. 在 [QQ 机器人开放平台](https://q.qq.com) 创建机器人,获取 **App ID** 与 **Client Secret**。
|
||||
2. 在沙箱中添加测试成员(上线前仅沙箱可对话)。
|
||||
3. 订阅 **C2C 消息**、**群 @ 消息** 等事件(WebSocket 模式)。
|
||||
4. CyberStrikeAI → **机器人设置** → **QQ 机器人** → 填入 App ID、Client Secret。
|
||||
5. 测试阶段勾选 **沙箱环境**;正式上线后取消沙箱并发布。
|
||||
6. 点击 **应用配置**。
|
||||
|
||||
**使用:** 与机器人 C2C 私聊直接发;QQ 群中需 @ 机器人。
|
||||
|
||||
> 注意:QQ 官方正逐步推广 Webhook 回调;当前实现使用 WebSocket(与钉钉/飞书类似的长连接模式)。若配置变更后连接未刷新,可重启 CyberStrikeAI 进程。
|
||||
|
||||
---
|
||||
|
||||
## 四、机器人命令
|
||||
|
||||
在钉钉/飞书中向机器人发送以下**文本命令**(仅支持文本):
|
||||
在任一已接入平台(钉钉/飞书/微信/Telegram/Slack/Discord/QQ 等)向机器人发送以下**文本命令**(仅支持文本):
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
@@ -175,16 +324,25 @@
|
||||
|
||||
## 五、如何使用(要 @ 机器人吗?)
|
||||
|
||||
- **单聊(推荐)**:在钉钉/飞书里**搜索并打开该机器人**,进入与机器人的**私聊**,直接输入「帮助」或任意文字即可,**不需要 @**。
|
||||
- **群聊**:若机器人被添加到群里,在群内只有 **@机器人** 后发送的消息才会被机器人收到并回复;不 @ 的群消息不会触发机器人。
|
||||
- **个人微信**:在与 CyberStrikeAI 机器人的**私聊**中直接发送即可,**不需要 @**(不支持群聊)。
|
||||
- **钉钉 / 飞书单聊(推荐)**:**搜索并打开该机器人**,进入**私聊**,直接输入「帮助」或任意文字即可,**不需要 @**。
|
||||
- **钉钉 / 飞书群聊**:若机器人被添加到群里,在群内只有 **@机器人** 后发送的消息才会被机器人收到并回复;不 @ 的群消息不会触发机器人。
|
||||
|
||||
总结:和机器人**单聊时直接发**;在**群里用时需要 @机器人** 再发内容。
|
||||
总结:**个人微信、单聊时直接发**;**钉钉/飞书在群里用时需要 @机器人** 再发内容。
|
||||
|
||||
---
|
||||
|
||||
## 六、推荐使用流程(避免漏步骤)
|
||||
|
||||
1. **在开放平台**:按第三节完成钉钉或飞书应用创建、凭证复制、机器人开通(钉钉务必选 **Stream 模式**)、权限与发布。
|
||||
**个人微信(最简单,无需开放平台)**
|
||||
|
||||
1. CyberStrikeAI Web 端 → 系统设置 → 机器人设置 → **微信 / iLink** → **生成二维码并绑定**。
|
||||
2. 手机微信扫码确认(如需配对数字则在 Web 页填写)。
|
||||
3. 绑定成功后,在手机微信私聊中发「帮助」测试。
|
||||
|
||||
**钉钉 / 飞书**
|
||||
|
||||
1. **在开放平台**:按第三节完成应用创建、凭证复制、机器人开通(钉钉务必选 **Stream 模式**)、权限与发布。
|
||||
2. **在 CyberStrikeAI**:系统设置 → 机器人设置 → 勾选对应平台,粘贴 Client ID/App ID、Client Secret/App Secret → 点击 **应用配置**。
|
||||
3. **重启 CyberStrikeAI 进程**(否则长连接不会建立)。
|
||||
4. **在手机钉钉/飞书**:找到该机器人(单聊直接发,群聊需 @机器人),发「帮助」或任意内容测试。
|
||||
@@ -199,6 +357,14 @@
|
||||
|
||||
```yaml
|
||||
robots:
|
||||
wechat: # 个人微信 iLink(扫码绑定后自动写入,一般无需手填)
|
||||
enabled: true
|
||||
bot_token: "your_bot_token@im.bot:..."
|
||||
ilink_bot_id: "your_bot_id@im.bot"
|
||||
ilink_user_id: "your_user_id@im.wechat"
|
||||
base_url: "https://ilinkai.weixin.qq.com"
|
||||
bot_type: "3"
|
||||
bot_agent: "CyberStrikeAI/1.0"
|
||||
dingtalk:
|
||||
enabled: true
|
||||
client_id: "your_dingtalk_app_key"
|
||||
@@ -208,9 +374,33 @@ robots:
|
||||
app_id: "your_lark_app_id"
|
||||
app_secret: "your_lark_app_secret"
|
||||
verify_token: ""
|
||||
wecom:
|
||||
enabled: false
|
||||
corp_id: ""
|
||||
agent_id: 0
|
||||
token: ""
|
||||
encoding_aes_key: ""
|
||||
secret: ""
|
||||
telegram:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_group_messages: false
|
||||
slack:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
app_token: ""
|
||||
discord:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_guild_messages: false
|
||||
qq:
|
||||
enabled: false
|
||||
app_id: ""
|
||||
client_secret: ""
|
||||
sandbox: true
|
||||
```
|
||||
|
||||
修改后需**重启应用**,长连接在应用启动时建立。
|
||||
修改钉钉/飞书/企业微信/Telegram/Slack/Discord/QQ 配置后,点击 **应用配置** 会自动重启对应长连接。个人微信扫码绑定成功后会自动写入并重启 iLink 连接。
|
||||
|
||||
---
|
||||
|
||||
@@ -235,7 +425,30 @@ curl -X POST "http://localhost:8080/api/robot/test" \
|
||||
|
||||
---
|
||||
|
||||
## 九、钉钉发消息没反应时排查
|
||||
## 九、发消息没反应时排查
|
||||
|
||||
### 9.1 个人微信
|
||||
|
||||
按顺序检查:
|
||||
|
||||
1. **是否已完成扫码绑定**
|
||||
机器人设置页应显示「已连接」或已绑定 Bot ID;`config.yaml` 中 `robots.wechat.bot_token` 不应为空。
|
||||
|
||||
2. **是否已启用**
|
||||
确认「启用微信机器人」已勾选;若刚修改过,可重启 CyberStrikeAI 进程。
|
||||
|
||||
3. **看程序日志**
|
||||
- 启动后应看到:`微信 iLink 长轮询已启动`;
|
||||
- 发消息后应有:`微信收到消息`;若没有,多为未绑定成功或 `bot_token` 失效,可尝试 **重新绑定**。
|
||||
- 若出现 `微信 iLink 长轮询异常,将自动重连`,等待自动重连或重启进程。
|
||||
|
||||
4. **网络**
|
||||
服务器需能访问 `https://ilinkai.weixin.qq.com`(出站 HTTPS)。绑定阶段若无法生成二维码,优先检查此项。
|
||||
|
||||
5. **断网或睡眠后**
|
||||
与钉钉/飞书类似,程序会**自动重连**(约 5~60 秒);仍无回复可重启 CyberStrikeAI。
|
||||
|
||||
### 9.2 钉钉
|
||||
|
||||
按顺序检查:
|
||||
|
||||
@@ -260,8 +473,10 @@ curl -X POST "http://localhost:8080/api/robot/test" \
|
||||
|
||||
## 十、常见弯路(避免踩坑)
|
||||
|
||||
- **个人微信与企业微信混淆**:个人微信走 `robots.wechat` + Web 扫码绑定;企业微信走 `robots.wecom` + 管理后台回调 URL,二者完全不同。
|
||||
- **个人微信二维码过期**:二维码约 5 分钟有效,过期需重新生成,不要一直扫旧码。
|
||||
- **用错了机器人类型**:在钉钉**群里**添加的「自定义」机器人(Webhook + 加签)**不能**用来做对话,本程序只支持**开放平台「企业内部应用」**里的机器人。
|
||||
- **只保存没重启**:在 CyberStrikeAI 里改完机器人配置后必须**重启应用**,否则长连接不会建立。
|
||||
- **只保存没重启**:钉钉/飞书改完配置后必须**重启应用**,否则长连接不会建立(个人微信扫码绑定会自动重启连接)。
|
||||
- **Client ID 抄错**:开放平台是 `504` 就填 `504`,不要填成 `5o4`;尽量用复制粘贴。
|
||||
- **钉钉只开了 HTTP 回调没开 Stream**:本程序通过 **Stream 长连接**收消息,开放平台里机器人的消息接收方式必须选 **Stream 模式**。
|
||||
- **应用没发布**:开放平台里修改了机器人或权限后,要在「版本管理与发布」里**发布新版本**,否则不生效。
|
||||
@@ -270,6 +485,7 @@ curl -X POST "http://localhost:8080/api/robot/test" \
|
||||
|
||||
## 十一、注意事项
|
||||
|
||||
- 钉钉、飞书均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。
|
||||
- 各平台均**仅处理文本消息**;其他类型(如图片、语音)会提示暂不支持或忽略。
|
||||
- 个人微信仅支持**私聊**,不支持群聊 @ 机器人。
|
||||
- 会话与 Web 端共用同一套对话数据:在机器人里创建的对话会在 Web 端「对话」列表中看到,反之亦然。
|
||||
- 机器人执行与 **Eino 单/多代理** 相同逻辑(`ProcessMessageForRobot`,含进度回调与过程详情入库),仅不向客户端推送 SSE,最后一次性回复钉钉/飞书/企业微信。默认 `robot_default_agent_mode: eino_single`。
|
||||
- 机器人执行与 **Eino 单/多代理** 相同逻辑(`ProcessMessageForRobot`,含进度回调与过程详情入库),仅不向客户端推送 SSE,最后一次性回复个人微信/钉钉/飞书/企业微信。默认 `robot_default_agent_mode: eino_single`。
|
||||
|
||||
+205
-14
@@ -2,7 +2,7 @@
|
||||
|
||||
[中文](robot.md)
|
||||
|
||||
This document explains how to chat with CyberStrikeAI from **DingTalk**, **Lark (Feishu)**, and **WeCom (Enterprise WeChat)** using long-lived connections or HTTP callbacks—no need to open a browser on the server. Following the steps below helps avoid common mistakes.
|
||||
This document explains how to chat with CyberStrikeAI from **personal WeChat**, **DingTalk**, **Lark (Feishu)**, and **WeCom (Enterprise WeChat)** using long-lived connections or HTTP callbacks—no need to open a browser on the server. Following the steps below helps avoid common mistakes.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,11 +11,14 @@ This document explains how to chat with CyberStrikeAI from **DingTalk**, **Lark
|
||||
1. Log in to the CyberStrikeAI web UI.
|
||||
2. Open **System Settings** in the left sidebar.
|
||||
3. Click **Robot settings** (between “Basic” and “Security”).
|
||||
4. Enable the platform and fill in credentials (DingTalk: Client ID / Client Secret; Lark: App ID / App Secret).
|
||||
5. Click **Apply configuration** to save.
|
||||
6. **Restart the CyberStrikeAI process** (saving alone does not establish the connection).
|
||||
4. Configure per platform:
|
||||
- **Personal WeChat**: Open **WeChat / iLink** → **Generate QR code and bind**, then scan with WeChat (see [Section 3.4](#34-personal-wechat-wechat--ilink))
|
||||
- **DingTalk**: Enable and fill in Client ID / Client Secret
|
||||
- **Lark**: Enable and fill in App ID / App Secret
|
||||
5. Click **Apply configuration** to save (WeChat binding saves and enables automatically on success—usually no extra click needed)
|
||||
6. **Restart the CyberStrikeAI process** (DingTalk/Lark: saving alone does not establish the connection; WeChat auto-restarts the iLink poll after binding—usually no manual restart needed)
|
||||
|
||||
Settings are written to the `robots` section of `config.yaml`; you can also edit the file directly. **After changing DingTalk or Lark config, you must restart for the long-lived connection to take effect.**
|
||||
Settings are written to the `robots` section of `config.yaml`; you can also edit the file directly. **After changing DingTalk or Lark config, you must restart for the long-lived connection to take effect.** Personal WeChat binding automatically writes `robots.wechat` and restarts the iLink long poll.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,9 +26,14 @@ Settings are written to the `robots` section of `config.yaml`; you can also edit
|
||||
|
||||
| Platform | Description |
|
||||
|----------------|-------------|
|
||||
| Personal WeChat| WeChat iLink protocol; scan QR in the web UI to bind, then long-poll for messages—**no public callback URL needed** |
|
||||
| DingTalk | Stream long-lived connection; the app connects to DingTalk to receive messages |
|
||||
| Lark (Feishu) | Long-lived connection; the app connects to Lark to receive messages |
|
||||
| WeCom (Qiye WX)| HTTP callback to receive messages; CyberStrikeAI replies via WeCom’s message sending API |
|
||||
| Telegram | Bot API long polling (`getUpdates`); **no public callback URL needed** |
|
||||
| Slack | Socket Mode (outbound WebSocket); **no public callback URL needed** |
|
||||
| Discord | Gateway WebSocket; **no public callback URL needed** |
|
||||
| QQ Bot | QQ Open Platform WebSocket (C2C / group @); **no public callback URL needed** |
|
||||
|
||||
Section 3 below describes, per platform, what to do in the developer console and which fields to copy into CyberStrikeAI.
|
||||
|
||||
@@ -148,9 +156,125 @@ In **Permission management** (权限管理), enable the following (names and ide
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Personal WeChat (WeChat / iLink)
|
||||
|
||||
> Personal WeChat uses **“web QR binding + iLink long polling”**:
|
||||
> - Generate a QR code in the CyberStrikeAI web UI → scan and confirm with **WeChat on your phone**;
|
||||
> - On success, `robots.wechat` in `config.yaml` is updated automatically and iLink long polling starts (the app connects outbound to `ilinkai.weixin.qq.com`);
|
||||
> - **No** public callback URL on your server and **no** WeChat Open Platform app registration required.
|
||||
|
||||
**Personal WeChat vs WeCom**
|
||||
|
||||
| Item | Personal WeChat (iLink) | WeCom (Enterprise WeChat) |
|
||||
|------|-------------------------|---------------------------|
|
||||
| Use case | Private chat in personal WeChat | Custom app in WeCom |
|
||||
| Setup | QR scan in web UI | Admin console callback URL + Token |
|
||||
| Public IP needed? | No (outbound long poll only) | Yes (HTTPS callback reachable by WeCom) |
|
||||
| Config key | `robots.wechat` | `robots.wecom` |
|
||||
|
||||
**Binding steps (in order)**
|
||||
|
||||
1. **Log in to CyberStrikeAI web UI**
|
||||
**System settings** → **Robot settings** → click the **WeChat / iLink** card.
|
||||
|
||||
2. **(Optional) Enable “Enable WeChat robot”**
|
||||
You can skip this on first bind; it is checked automatically after a successful bind.
|
||||
|
||||
3. **Generate QR code**
|
||||
Click **“Generate QR code and bind”**. The QR code is valid for about **5 minutes**; regenerate if it expires.
|
||||
|
||||
4. **Scan and confirm in WeChat**
|
||||
- Scan the QR code with WeChat on your phone;
|
||||
- Complete confirmation on the phone;
|
||||
- If WeChat shows a **pairing code**, enter it on the web page and click **Submit** (only some accounts need this).
|
||||
|
||||
5. **Wait for binding to complete**
|
||||
When the page shows “Binding successful, WeChat robot enabled”, you’re done. `bot_token`, `ilink_bot_id`, etc. are saved to `config.yaml` and the iLink poll restarts automatically—**usually no manual service restart**.
|
||||
|
||||
6. **Test in WeChat**
|
||||
Open the **private chat** with the CyberStrikeAI bot in WeChat and send “帮助” (help) or any text.
|
||||
|
||||
**Field reference (WeChat)**
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Enable WeChat robot | Starts iLink long polling when checked; auto-enabled after bind |
|
||||
| Generate QR code and bind | Starts the scan-to-bind flow |
|
||||
| **Advanced** (defaults are fine) | |
|
||||
| API Base URL | Default `https://ilinkai.weixin.qq.com` |
|
||||
| Bot Type | Default `3` |
|
||||
| Bot Agent | Default `CyberStrikeAI/1.0` |
|
||||
| iLink Bot ID | Filled automatically after bind (read-only) |
|
||||
|
||||
**How to use**
|
||||
|
||||
- **Private chat only**—send text directly; **no @ needed**.
|
||||
- Group @-bot is **not** supported (unlike DingTalk/Lark groups).
|
||||
- **Text messages only**; images, voice, etc. are ignored or not supported.
|
||||
|
||||
**Re-bind**
|
||||
|
||||
- To bind a different WeChat account, click **“Re-bind”** on the robot settings page and scan again.
|
||||
- If you see “This WeChat account is already bound”, that account was bound before.
|
||||
|
||||
**Common issues**
|
||||
|
||||
| Symptom | What to do |
|
||||
|---------|------------|
|
||||
| QR code expired | Click “Generate QR code and bind” again (~5 min TTL) |
|
||||
| Phone asks for a pairing code | Enter the digits shown in WeChat on the web page |
|
||||
| Bound but no replies | Check logs for `微信 iLink 长轮询已启动` and `微信收到消息`; ensure “Enable WeChat robot” is on |
|
||||
| No reply after sleep / network drop | Auto-reconnect in ~5–60 s; restart CyberStrikeAI if still stuck |
|
||||
| Cannot generate QR code | Ensure outbound HTTPS to `https://ilinkai.weixin.qq.com` |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Telegram
|
||||
|
||||
> Telegram uses **Bot API long polling** (`getUpdates`): the app connects outbound to `api.telegram.org`—**no public callback URL needed**.
|
||||
|
||||
1. Create a bot via **@BotFather** (`/newbot`) and copy the **Bot Token**.
|
||||
2. CyberStrikeAI → **System settings** → **Robot settings** → **Telegram**.
|
||||
3. Enable, paste the token, optionally allow group @ mentions → **Apply configuration**.
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Slack
|
||||
|
||||
> Slack uses **Socket Mode** (outbound WebSocket): requires **Bot Token (xoxb-)** and **App-Level Token (xapp-)** with `connections:write`.
|
||||
|
||||
1. Create an app at [api.slack.com](https://api.slack.com/apps) → enable **Socket Mode**.
|
||||
2. Create an App-Level Token; install the app to get a Bot Token.
|
||||
3. Subscribe to `message.im` and `app_mention` events.
|
||||
4. Paste both tokens in CyberStrikeAI → **Apply configuration**.
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Discord
|
||||
|
||||
> Discord uses **Gateway WebSocket**—**no public callback URL needed**.
|
||||
|
||||
1. [Discord Developer Portal](https://discord.com/developers/applications) → create app → **Bot** → copy **Token**.
|
||||
2. Enable **Message Content Intent** under Privileged Gateway Intents.
|
||||
3. Invite the bot with `Send Messages` permission.
|
||||
4. Paste token in CyberStrikeAI; optionally allow guild @ mentions → **Apply configuration**.
|
||||
|
||||
---
|
||||
|
||||
### 3.8 QQ Bot
|
||||
|
||||
> QQ Bot uses **QQ Open Platform WebSocket** (official `botgo` SDK) for C2C and group @—**no public callback URL needed**.
|
||||
|
||||
1. Create a bot at [q.qq.com](https://q.qq.com) → get **App ID** and **Client Secret**.
|
||||
2. Add sandbox testers before going live.
|
||||
3. Subscribe to C2C and group @ events (WebSocket).
|
||||
4. Fill in CyberStrikeAI; use **Sandbox** for testing → **Apply configuration**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bot commands
|
||||
|
||||
Send these **text commands** to the bot in DingTalk or Lark (text only):
|
||||
Send these **text commands** to the bot on any connected platform (text only):
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
@@ -172,15 +296,24 @@ Any other text is sent to the AI as a user message, same as in the web UI (e.g.
|
||||
|
||||
## 5. How to use (do I need to @ the bot?)
|
||||
|
||||
- **Direct chat (recommended)**: In DingTalk or Lark, **search for the bot and open a direct chat**. Type “帮助” or any message; **no @ needed**.
|
||||
- **Group chat**: If the bot is in a group, only messages that **@ the bot** are received and answered; other group messages are ignored.
|
||||
- **Personal WeChat**: Send directly in the **private chat** with the bot; **no @ needed** (group chat not supported).
|
||||
- **DingTalk / Lark direct chat (recommended)**: **Search for the bot and open a direct chat**. Type “帮助” or any message; **no @ needed**.
|
||||
- **DingTalk / Lark group chat**: If the bot is in a group, only messages that **@ the bot** are received and answered; other group messages are ignored.
|
||||
|
||||
Summary: **Direct chat** — just send; **in a group** — @ the bot first, then send.
|
||||
Summary: **Personal WeChat and direct chat**—just send; **DingTalk/Lark in a group**—@ the bot first, then send.
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended flow (so you don’t skip steps)
|
||||
|
||||
**Personal WeChat (simplest—no open platform)**
|
||||
|
||||
1. CyberStrikeAI web UI → System settings → Robot settings → **WeChat / iLink** → **Generate QR code and bind**.
|
||||
2. Scan with WeChat and confirm (enter pairing code on the web page if prompted).
|
||||
3. After binding, send “帮助” in the WeChat private chat to test.
|
||||
|
||||
**DingTalk / Lark**
|
||||
|
||||
1. **In the open platform**: Complete app creation, copy credentials, enable the bot (DingTalk: **Stream mode**), set permissions, and publish (Section 3).
|
||||
2. **In CyberStrikeAI**: System settings → Robot settings → Enable the platform, paste Client ID/App ID and Client Secret/App Secret → **Apply configuration**.
|
||||
3. **Restart the CyberStrikeAI process** (otherwise the long-lived connection is not established).
|
||||
@@ -196,6 +329,14 @@ Example `robots` section in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
robots:
|
||||
wechat: # Personal WeChat iLink (auto-filled after QR bind; usually no manual edit)
|
||||
enabled: true
|
||||
bot_token: "your_bot_token@im.bot:..."
|
||||
ilink_bot_id: "your_bot_id@im.bot"
|
||||
ilink_user_id: "your_user_id@im.wechat"
|
||||
base_url: "https://ilinkai.weixin.qq.com"
|
||||
bot_type: "3"
|
||||
bot_agent: "CyberStrikeAI/1.0"
|
||||
dingtalk:
|
||||
enabled: true
|
||||
client_id: "your_dingtalk_app_key"
|
||||
@@ -205,9 +346,33 @@ robots:
|
||||
app_id: "your_lark_app_id"
|
||||
app_secret: "your_lark_app_secret"
|
||||
verify_token: ""
|
||||
wecom:
|
||||
enabled: false
|
||||
corp_id: ""
|
||||
agent_id: 0
|
||||
token: ""
|
||||
encoding_aes_key: ""
|
||||
secret: ""
|
||||
telegram:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_group_messages: false
|
||||
slack:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
app_token: ""
|
||||
discord:
|
||||
enabled: false
|
||||
bot_token: ""
|
||||
allow_guild_messages: false
|
||||
qq:
|
||||
enabled: false
|
||||
app_id: ""
|
||||
client_secret: ""
|
||||
sandbox: true
|
||||
```
|
||||
|
||||
**Restart the app** after changes; the long-lived connection is created at startup.
|
||||
After changing DingTalk/Lark/WeCom/Telegram/Slack/Discord/QQ settings, **Apply configuration** restarts the corresponding connections. Personal WeChat QR binding saves and restarts automatically.
|
||||
|
||||
---
|
||||
|
||||
@@ -232,7 +397,30 @@ API: `POST /api/robot/test` (requires login). Body: `{"platform":"optional","use
|
||||
|
||||
---
|
||||
|
||||
## 9. DingTalk: no response when sending messages
|
||||
## 9. Troubleshooting: no response when sending messages
|
||||
|
||||
### 9.1 Personal WeChat
|
||||
|
||||
Check in this order:
|
||||
|
||||
1. **Binding completed?**
|
||||
Robot settings should show “Connected” or a bound Bot ID; `robots.wechat.bot_token` in `config.yaml` must not be empty.
|
||||
|
||||
2. **Enabled?**
|
||||
Confirm “Enable WeChat robot” is checked; restart CyberStrikeAI if you just changed settings.
|
||||
|
||||
3. **Application logs**
|
||||
- On startup: `微信 iLink 长轮询已启动`;
|
||||
- After sending a message: `微信收到消息`; if missing, binding may have failed or `bot_token` is invalid—try **Re-bind**.
|
||||
- `微信 iLink 长轮询异常,将自动重连`: wait for auto-reconnect or restart.
|
||||
|
||||
4. **Network**
|
||||
The server must reach `https://ilinkai.weixin.qq.com` (outbound HTTPS). If QR generation fails, check this first.
|
||||
|
||||
5. **After sleep or network drop**
|
||||
Same as DingTalk/Lark: **auto-reconnect** in ~5–60 s; restart if still no response.
|
||||
|
||||
### 9.2 DingTalk
|
||||
|
||||
Check in this order:
|
||||
|
||||
@@ -257,8 +445,10 @@ Check in this order:
|
||||
|
||||
## 10. Common pitfalls
|
||||
|
||||
- **Personal WeChat vs WeCom**: Personal WeChat uses `robots.wechat` + web QR bind; WeCom uses `robots.wecom` + admin callback URL—they are completely different.
|
||||
- **WeChat QR expired**: QR codes last ~5 minutes; regenerate instead of reusing an old one.
|
||||
- **Wrong bot type**: The “Custom” bot added in a DingTalk **group** (Webhook + sign secret) **cannot** be used for two-way chat. Only the **enterprise internal app** bot from the open platform is supported.
|
||||
- **Saved but not restarted**: After changing robot settings in CyberStrikeAI you **must restart** the app, or the long-lived connection will not be established.
|
||||
- **Saved but not restarted**: After changing DingTalk/Lark robot settings you **must restart** the app (WeChat QR bind restarts the connection automatically).
|
||||
- **Client ID typo**: If the platform shows `504`, use `504` (not `5o4`); prefer copy/paste.
|
||||
- **DingTalk: only HTTP callback, no Stream**: This app receives messages via **Stream**. In the open platform, message reception must be **Stream mode**.
|
||||
- **App not published**: After changing the bot or permissions in the open platform, **publish a new version** under “Version management and release”, or changes won’t apply.
|
||||
@@ -267,6 +457,7 @@ Check in this order:
|
||||
|
||||
## 11. Notes
|
||||
|
||||
- DingTalk and Lark: **text messages only**; other types (e.g. image, voice) are not supported and may be ignored.
|
||||
- All platforms: **text messages only**; other types (e.g. image, voice) are not supported and may be ignored.
|
||||
- Personal WeChat: **private chat only**—group @-bot is not supported.
|
||||
- Conversations are shared with the web UI: conversations created from the bot appear in the web “Conversations” list and vice versa.
|
||||
- Bot execution uses the same **Eino single/multi-agent** path as the web UI (`ProcessMessageForRobot`, with progress callbacks and process details stored in the DB); only the final reply is sent back to DingTalk/Lark in one message (no SSE). Default: `robot_default_agent_mode: eino_single`.
|
||||
- Bot execution uses the same **Eino single/multi-agent** path as the web UI (`ProcessMessageForRobot`, with progress callbacks and process details stored in the DB); only the final reply is sent back to personal WeChat/DingTalk/Lark/WeCom in one message (no SSE). Default: `robot_default_agent_mode: eino_single`.
|
||||
|
||||
+163
-13
@@ -27,9 +27,10 @@
|
||||
| 选中元素 | 单击节点或连线,右侧显示 **节点属性** |
|
||||
| 删除选中 | 点击 **删除选中** 删除当前节点或连线 |
|
||||
| 自动布局 | 点击 **自动布局** 整理节点位置 |
|
||||
| 试运行 | 点击 **试运行** 使用安全 dry-run 验证数据流;工具、Agent、审批不会真实执行 |
|
||||
| 删除流程 | 点击 **删除** 删除整个流程定义 |
|
||||
|
||||
**建议:** 每个流程至少包含 **1 个开始节点** 和 **1 个输出节点**;开始节点不应有入边,输出节点不应有出边。
|
||||
**硬性规则:** 每个流程至少包含 **1 个开始节点** 和 **1 个输出节点**;开始节点不能有入边,输出 / 结束节点不能有出边。保存时前端和后端都会执行严格校验。
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +46,7 @@
|
||||
| `lastOutput` | `{{previous.xxx}}` | **上一个刚执行完** 的节点的输出 |
|
||||
| `outputs` | `{{outputs.xxx}}` | 全局 **命名变量池**(由节点的「输出变量名」写入) |
|
||||
| `nodeOutputs` | `{{节点ID.xxx}}` | 指定节点 ID 的完整输出对象 |
|
||||
| `metrics` | 运行详情中查看 | 节点耗时、工具调用数、可收集到的 token / cost 等指标 |
|
||||
|
||||
### 3.1 `previous` 是什么?
|
||||
|
||||
@@ -69,6 +71,15 @@ Agent B 的 `{{previous.output}}` = 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` 是什么?
|
||||
|
||||
`outputs` 是引擎在运行过程中维护的 **命名变量注册表**。
|
||||
@@ -135,21 +146,55 @@ outputs["你填的变量名"] = 节点输出内容
|
||||
| `{{previous.matched}}` | 上一条件节点的匹配结果(`true` / `false`) |
|
||||
| `{{outputs.变量名}}` | 某节点注册过的命名输出 |
|
||||
| `{{节点ID.output}}` | 指定节点 ID 的 `output` 字段 |
|
||||
| `{{previous.kind}}` | 上一节点输出类型,如 `agent` / `tool` / `condition` |
|
||||
| `{{previous.status}}` | 上一节点状态,如 `completed` / `failed` / `simulated` |
|
||||
|
||||
节点输出会保留兼容字段(如 `output`、`matched`),同时带有结构化字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "agent",
|
||||
"node_id": "node-2",
|
||||
"node_type": "agent",
|
||||
"status": "completed",
|
||||
"output": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 条件表达式
|
||||
|
||||
条件节点和连线条件支持简单比较:
|
||||
条件节点和连线条件支持比较、文本匹配、正则、逻辑组合与安全 JSONPath/JQ 路径读取:
|
||||
|
||||
```text
|
||||
{{outputs.agent_result1}} != ""
|
||||
{{previous.output}} == "ok"
|
||||
{{outputs.count}} == "100"
|
||||
{{outputs.count}} >= 100
|
||||
{{previous.output}} contains "success"
|
||||
{{previous.output}} matches "^ok"
|
||||
{{outputs.risk_score}} >= 8 && {{previous.output}} != ""
|
||||
jsonpath({{previous.output}}, "$.status") == "ok"
|
||||
jq({{outputs.scan}}, ".severity") == "high"
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 使用 `==` 或 `!=` 做字符串比较(两侧会自动去掉首尾空格和引号)
|
||||
- 支持 `==`、`!=`、`>`、`>=`、`<`、`<=`
|
||||
- 支持 `contains` 子串匹配与 `matches` 正则匹配
|
||||
- 支持简单 `&&` / `||`
|
||||
- 支持 `jsonpath(value, "$.path")` 与 `jq(value, ".path")` 的**安全路径子集**,仅做字段读取,不执行任意脚本
|
||||
- 比较两侧会自动去掉首尾空格和引号
|
||||
- 无比较符时,非空且不为 `false` / `0` / `null` 视为真
|
||||
- 保存时会静态校验表达式格式、JSONPath/JQ 路径和正则语法
|
||||
|
||||
### 4.4 嵌套字段绑定
|
||||
|
||||
节点的字段绑定除 `output`、`message` 等普通字段外,也支持 JSONPath/JQ 风格路径:
|
||||
|
||||
| 绑定配置 | 含义 |
|
||||
|----------|------|
|
||||
| `from=previous, field=$.status` | 从上一节点输出对象读取 `status` |
|
||||
| `from=outputs, field=$.scan.severity` | 从命名输出中读取嵌套字段 |
|
||||
| `from=node-1, field=.output.items[0]` | 从指定节点输出读取数组元素 |
|
||||
|
||||
---
|
||||
|
||||
@@ -175,6 +220,7 @@ outputs["你填的变量名"] = 节点输出内容
|
||||
| 输入来源 | 上游数据的模板表达式 | `{{previous.output}}` |
|
||||
| 节点指令 | 本节点要完成的任务描述 | 空 |
|
||||
| 输出变量名 | 写入 `outputs` 的键名 | `agent_result` |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
**消息拼装规则:**
|
||||
|
||||
@@ -186,6 +232,7 @@ Agent 节点执行后:
|
||||
|
||||
- `previous.output` 更新为本节点响应文本
|
||||
- 若配置了 **输出变量名**,同时写入 `outputs[输出变量名]`
|
||||
- Agent 子图在 Eino 中拆为 `prepare → execute → finalize`,便于 trace 与后续局部 checkpoint
|
||||
|
||||
### 5.3 工具(tool)
|
||||
|
||||
@@ -196,6 +243,7 @@ Agent 节点执行后:
|
||||
| MCP 工具 | 工具名称(必填) | — |
|
||||
| 参数模板 | JSON,支持 `{{...}}` 模板 | `{}` |
|
||||
| 超时秒数 | 可选 | 空 |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
示例参数模板:
|
||||
|
||||
@@ -212,6 +260,7 @@ Agent 节点执行后:
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 条件表达式 | 支持 `{{...}}` 与 `==` / `!=` | `{{previous.output}} != ""` |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
**分支规则:**
|
||||
|
||||
@@ -229,12 +278,21 @@ Agent 节点执行后:
|
||||
|
||||
### 5.5 审批(hitl)
|
||||
|
||||
人工确认检查点(当前为记录模式,自动标记 `approved: true` 并继续)。
|
||||
人工确认检查点。流程运行到该节点前会通过 Eino interrupt/checkpoint 暂停,等待 API 或监控面板审批后恢复。
|
||||
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 审批提示 | 支持模板 | `请审批该步骤是否继续执行` |
|
||||
| 提示字段绑定 | 留空审批提示时,从绑定字段读取说明 | `previous.output` |
|
||||
| 审批方 | `human` / `audit_agent` | `human` |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
HITL 等待信息会记录:
|
||||
|
||||
- `checkpointId`
|
||||
- interrupt `beforeNodes`
|
||||
- resume target / address / path
|
||||
- resume payload schema(`approved`、`comment`)
|
||||
|
||||
### 5.6 输出(output)
|
||||
|
||||
@@ -244,6 +302,8 @@ Agent 节点执行后:
|
||||
|------|------|--------|
|
||||
| 输出变量名 | 必填,最终结果的键名 | `result` |
|
||||
| 变量来源 | 模板表达式,决定写入的值 | `{{previous.output}}` |
|
||||
| 固定输出值 | 可选,填写后覆盖变量来源 | 空 |
|
||||
| 汇聚策略 | 多上游进入本节点时如何生成 `previous` | `all_merge` |
|
||||
|
||||
**注意:** 输出节点是流程的「出口」,不应再有出边。
|
||||
|
||||
@@ -254,6 +314,7 @@ Agent 节点执行后:
|
||||
| 字段 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| 结束摘要模板 | 支持 `{{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`,且填写输出变量名 |
|
||||
| 连线合法 | 源/目标节点存在,不能自环 |
|
||||
| 开始节点无入边 | 开始节点不能被指向 |
|
||||
| 输出节点无出边 | 输出节点后不应再连线 |
|
||||
| 工具节点 | 必须选择 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` |
|
||||
| 工具节点失败 | 参数 JSON 不合法或工具未启用 | 检查参数模板;在 MCP 中启用对应工具 |
|
||||
| 保存失败提示分支非法 | 条件节点出边未标记是/否或重复 | 选中连线,设置条件分支为 `true` 或 `false` |
|
||||
| 多上游结果不符合预期 | 汇聚策略不合适 | 根据场景改为 `all_merge` / `first_non_empty` / `last_by_canvas` / `fail_fast` |
|
||||
| 嵌套字段取不到 | JSONPath/JQ 路径不符合安全子集 | 使用 `$.a.b[0]` 或 `.a.b[0]`,不要用通配符/递归/表达式 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、最佳实践
|
||||
## 十二、最佳实践
|
||||
|
||||
1. **命名规范**:为每个需要被引用的节点设置有意义的输出变量名,如 `scan_result`、`parsed_targets`,避免都叫 `agent_result`。
|
||||
2. **跨节点传参优先用 `outputs`**:只要中间可能插入条件、工具、审批节点,就应用命名变量。
|
||||
3. **`previous` 仅用于直连**:A → B 且无中间节点时,`{{previous.output}}` 最简洁。
|
||||
4. **条件判断引用源数据**:判断 Agent 输出时用 `{{outputs.xxx}}`,不要用 `{{previous.output}}`(除非条件紧跟在目标 Agent 之后)。
|
||||
5. **每条路径都要有出口**:确保「是」「否」分支最终都能到达 **输出** 节点(或你期望的终点)。
|
||||
6. **保存前跑一遍**:用简单指令(如固定字符串输出)验证数据传递,再替换为真实业务逻辑。
|
||||
6. **多上游节点显式选择汇聚策略**:综合结果用 `all_merge`,兜底用 `first_non_empty`,关键链路用 `fail_fast`。
|
||||
7. **嵌套 JSON 用 JSONPath/JQ 安全路径**:例如 `jsonpath({{previous.output}}, "$.status") == "ok"`。
|
||||
8. **保存前先 dry-run**:用简单消息验证数据传递和分支,再绑定角色真实执行。
|
||||
|
||||
---
|
||||
|
||||
## 十二、相关代码位置(开发者参考)
|
||||
## 十三、相关代码位置(开发者参考)
|
||||
|
||||
| 模块 | 路径 |
|
||||
|------|------|
|
||||
| 执行引擎 | `internal/workflow/runner.go` |
|
||||
| Eino 编译 / checkpoint / HITL | `internal/workflow/eino_compile.go` |
|
||||
| 图校验 | `internal/workflow/validation.go` |
|
||||
| 表达式 / JSONPath / 汇聚 | `internal/workflow/expression.go`、`jsonpath.go`、`join.go` |
|
||||
| dry-run / replay 数据 | `internal/workflow/dry_run.go`、`internal/handler/workflow_run.go` |
|
||||
| 画布前端 | `web/static/js/workflows.js` |
|
||||
| 流程 API | `internal/handler/workflow.go` |
|
||||
| 角色绑定 | `internal/config/config.go`(`workflow_id` 字段) |
|
||||
|
||||
+163
-13
@@ -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 |
|
||||
| Delete selected | Remove the current node or edge |
|
||||
| Auto layout | Rearrange node positions |
|
||||
| Dry run | Safely simulate data flow; Tool, Agent, and HITL nodes are not executed for real |
|
||||
| Delete workflow | Remove the entire workflow definition |
|
||||
|
||||
**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 |
|
||||
| `outputs` | `{{outputs.xxx}}` | Global **named variable pool** (written by nodes with an output key) |
|
||||
| `nodeOutputs` | `{{nodeId.xxx}}` | Full output object of a specific node ID |
|
||||
| `metrics` | available in run details | Node duration, tool call count, and usage/cost metrics when reported |
|
||||
|
||||
### 3.1 What is `previous`?
|
||||
|
||||
@@ -69,6 +71,15 @@ Start → Agent A → Condition → Agent B
|
||||
|
||||
For Agent B, `{{previous.output}}` = the **condition node** output (`true` / `false`), **not** Agent A’s result.
|
||||
|
||||
If a node has **multiple upstream nodes**, `previous` is built by that node’s **join strategy** first:
|
||||
|
||||
| Join strategy | Meaning | Use case |
|
||||
|---------------|---------|----------|
|
||||
| `all_merge` | Merge all upstream outputs; `previous.output` is an array | Default; aggregate multiple results |
|
||||
| `last_by_canvas` | Use the last upstream output by canvas order | Explicitly use one branch |
|
||||
| `first_non_empty` | Use the first non-empty output | Fallback chains |
|
||||
| `fail_fast` | Stop the node if any upstream failed | Critical gates, approval prechecks, safety checks |
|
||||
|
||||
### 3.2 What is `outputs`?
|
||||
|
||||
`outputs` is a **named variable registry** maintained by the engine during execution.
|
||||
@@ -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`) |
|
||||
| `{{outputs.variable_name}}` | Named output registered by a node |
|
||||
| `{{nodeId.output}}` | `output` field of the node with that ID |
|
||||
| `{{previous.kind}}` | Previous node output kind, e.g. `agent` / `tool` / `condition` |
|
||||
| `{{previous.status}}` | Previous node status, e.g. `completed` / `failed` / `simulated` |
|
||||
|
||||
Node outputs keep compatibility fields such as `output` and `matched`, and also include a structured envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "agent",
|
||||
"node_id": "node-2",
|
||||
"node_type": "agent",
|
||||
"status": "completed",
|
||||
"output": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Condition expressions
|
||||
|
||||
Condition nodes and edge conditions support simple comparisons:
|
||||
Condition nodes and edge conditions support comparisons, text matching, regex, logical operators, and safe JSONPath/JQ path reads:
|
||||
|
||||
```text
|
||||
{{outputs.agent_result1}} != ""
|
||||
{{previous.output}} == "ok"
|
||||
{{outputs.count}} == "100"
|
||||
{{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:
|
||||
|
||||
- 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
|
||||
- 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}}` |
|
||||
| Node instruction | Task description for this node | empty |
|
||||
| Output variable name | Key written into `outputs` | `agent_result` |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
**Message assembly:**
|
||||
|
||||
@@ -186,6 +232,7 @@ After execution:
|
||||
|
||||
- `previous.output` becomes this node’s response text
|
||||
- If **Output variable name** is set, the value is also stored in `outputs[variable_name]`
|
||||
- In the Eino graph, the Agent node is split into `prepare → execute → finalize` for clearer trace and future checkpointing
|
||||
|
||||
### 5.3 Tool
|
||||
|
||||
@@ -196,6 +243,7 @@ Calls an enabled MCP tool.
|
||||
| MCP tool | Tool name (required) | — |
|
||||
| Argument template | JSON with `{{...}}` templates | `{}` |
|
||||
| Timeout (seconds) | Optional | empty |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
Example argument template:
|
||||
|
||||
@@ -212,6 +260,7 @@ Evaluates an expression and outputs `matched` (`true` / `false`).
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Expression | Supports `{{...}}` and `==` / `!=` | `{{previous.output}} != ""` |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
**Branching rules:**
|
||||
|
||||
@@ -229,12 +278,21 @@ Edge condition examples (select an edge, configure in the right panel):
|
||||
|
||||
### 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 |
|
||||
|-------|-------------|---------|
|
||||
| Prompt | Supports templates | `Please approve before continuing` |
|
||||
| Prompt binding | If prompt text is empty, read approval text from a bound field | `previous.output` |
|
||||
| Reviewer | `human` / `audit_agent` | `human` |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
Pending HITL metadata records:
|
||||
|
||||
- `checkpointId`
|
||||
- interrupt `beforeNodes`
|
||||
- resume target / address / path
|
||||
- resume payload schema (`approved`, `comment`)
|
||||
|
||||
### 5.6 Output
|
||||
|
||||
@@ -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` |
|
||||
| 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.
|
||||
|
||||
@@ -254,6 +314,7 @@ Optional node for an end summary template (less common in role-bound flows).
|
||||
| Field | Description | Default |
|
||||
|-------|-------------|---------|
|
||||
| Result template | Supports `{{outputs.xxx}}` | `{{outputs.result}}` |
|
||||
| Join strategy | How to build `previous` when multiple upstreams enter this node | `all_merge` |
|
||||
|
||||
---
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -363,13 +497,20 @@ On save, the system checks:
|
||||
| Output node required | At least one `output` node with an output variable name |
|
||||
| Valid edges | Source and target exist; no self-loops |
|
||||
| Start has no incoming edges | Start must not be targeted |
|
||||
| Output has no outgoing edges | Nothing after Output |
|
||||
| Tool nodes | MCP tool must be selected |
|
||||
| Condition nodes | Expression required; ideally 1–2 outgoing edges (yes/no) |
|
||||
| Output / End has no outgoing edges | Nothing after Output / End |
|
||||
| Non-start nodes must have incoming edges | Prevent orphan nodes |
|
||||
| Non-output/end nodes must have outgoing edges | Prevent dead ends |
|
||||
| No cycles | Workflow orchestration must be a DAG |
|
||||
| Reachability | Every node must be reachable from Start and eventually reach output/end |
|
||||
| Tool nodes | MCP tool required; argument JSON must be valid; timeout must be a positive integer |
|
||||
| Agent nodes | Must have node instruction or input binding; output variable name required |
|
||||
| Condition nodes | Expression required; 1–2 outgoing edges; branches must be yes/no and unique |
|
||||
| Edge conditions | Expressions, regexes, and JSONPath/JQ paths must pass static validation |
|
||||
| Join strategy | Must be `all_merge` / `last_by_canvas` / `first_non_empty` / `fail_fast` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Troubleshooting
|
||||
## 11. Troubleshooting
|
||||
|
||||
| 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 |
|
||||
| Role chat does not run workflow | Role not bound or disabled | Check `workflow_id`, `workflow_policy: auto`, workflow `enabled: true` |
|
||||
| Tool node fails | Invalid JSON in arguments or tool disabled | Fix argument template; enable the tool in MCP settings |
|
||||
| Save fails with invalid branch | Condition outgoing edges are not marked yes/no, or are duplicated | Select the edge and set branch to `true` or `false` |
|
||||
| Multi-upstream result is unexpected | Join strategy does not match the workflow | Switch between `all_merge`, `first_non_empty`, `last_by_canvas`, and `fail_fast` |
|
||||
| Nested field is empty | JSONPath/JQ path is outside the safe subset | Use `$.a.b[0]` or `.a.b[0]`; avoid wildcards, recursion, or expressions |
|
||||
|
||||
---
|
||||
|
||||
## 11. Best practices
|
||||
## 12. Best practices
|
||||
|
||||
1. **Meaningful names**: Use descriptive output variable names (`scan_result`, `parsed_targets`) instead of reusing `agent_result` everywhere.
|
||||
2. **Prefer `outputs` for cross-node data**: If a condition, tool, or HITL node might sit in between, use named variables.
|
||||
3. **Use `previous` only for direct links**: `A → B` with nothing in between is the ideal case for `{{previous.output}}`.
|
||||
4. **Conditions should reference source data**: When testing Agent output, use `{{outputs.xxx}}` unless the condition immediately follows that Agent.
|
||||
5. **Every path needs an exit**: Ensure both yes and no branches eventually reach an **Output** node (or your intended end).
|
||||
6. **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 |
|
||||
|--------|------|
|
||||
| Execution engine | `internal/workflow/runner.go` |
|
||||
| Eino compile / checkpoint / HITL | `internal/workflow/eino_compile.go` |
|
||||
| Graph validation | `internal/workflow/validation.go` |
|
||||
| Expressions / JSONPath / joins | `internal/workflow/expression.go`, `jsonpath.go`, `join.go` |
|
||||
| Dry-run / replay data | `internal/workflow/dry_run.go`, `internal/handler/workflow_run.go` |
|
||||
| Canvas UI | `web/static/js/workflows.js` |
|
||||
| Workflow API | `internal/handler/workflow.go` |
|
||||
| Role binding | `internal/config/config.go` (`workflow_id` field) |
|
||||
|
||||
@@ -3,11 +3,10 @@ module cyberstrike-ai
|
||||
// 若 go mod download 超时,可执行: go env -w GOPROXY=https://goproxy.cn,direct
|
||||
// 或使用 scripts/bootstrap-go.sh
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.4
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/discordgo v0.29.0
|
||||
github.com/bytedance/sonic v1.15.0
|
||||
github.com/cloudwego/eino v0.8.13
|
||||
github.com/cloudwego/eino-ext/adk/backend/local v0.0.0-20260416081055-0ebab92e14f2
|
||||
@@ -21,7 +20,7 @@ require (
|
||||
github.com/eino-contrib/jsonschema v1.0.3
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.4.22
|
||||
github.com/mattn/go-sqlite3 v1.14.18
|
||||
github.com/modelcontextprotocol/go-sdk v1.2.0
|
||||
@@ -29,6 +28,8 @@ require (
|
||||
github.com/pkoukk/tiktoken-go v0.1.8
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/slack-go/slack v0.27.0
|
||||
github.com/tencent-connect/botgo v0.2.1
|
||||
go.opentelemetry.io/otel v1.34.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.34.0
|
||||
@@ -60,6 +61,7 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-resty/resty/v2 v2.6.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/jsonschema-go v0.3.0 // indirect
|
||||
@@ -78,6 +80,9 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||
github.com/tidwall/gjson v1.9.3 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
@@ -93,6 +98,7 @@ require (
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
@@ -9,6 +10,8 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
|
||||
github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
|
||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0=
|
||||
@@ -20,6 +23,8 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.8.13 h1:z5dhaZNN8TWZbP/lgKxGmF26Ii8fPeUlQCGV/NTtms0=
|
||||
@@ -38,11 +43,13 @@ github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQr
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||
@@ -54,6 +61,7 @@ github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGj
|
||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
@@ -76,6 +84,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
@@ -84,21 +98,38 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
|
||||
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
|
||||
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
@@ -114,6 +145,8 @@ github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -145,11 +178,19 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
|
||||
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -159,6 +200,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
|
||||
@@ -167,6 +210,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/slack-go/slack v0.27.0 h1:VWOpUzOK6UAPCCQlFxl79jhv8a/b+GOSJMnWziDJ8B8=
|
||||
github.com/slack-go/slack v0.27.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
|
||||
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
|
||||
@@ -180,14 +225,24 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
|
||||
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
|
||||
github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E=
|
||||
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
@@ -204,6 +259,7 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
||||
@@ -238,6 +294,9 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
@@ -246,32 +305,71 @@ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
@@ -279,7 +377,10 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -292,14 +393,28 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
|
||||
google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A=
|
||||
google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+45
-2
@@ -55,11 +55,15 @@ type App struct {
|
||||
knowledgeIndexer *knowledge.Indexer // 知识库索引器(用于动态初始化)
|
||||
knowledgeHandler *handler.KnowledgeHandler // 知识库处理器(用于动态初始化)
|
||||
agentHandler *handler.AgentHandler // Agent处理器(用于更新知识库管理器)
|
||||
robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信)
|
||||
robotMu sync.Mutex // 保护钉钉/飞书长连接的 cancel
|
||||
robotHandler *handler.RobotHandler // 机器人处理器(钉钉/飞书/企业微信等)
|
||||
robotMu sync.Mutex // 保护机器人长连接的 cancel
|
||||
dingCancel context.CancelFunc // 钉钉 Stream 取消函数,用于配置变更时重启
|
||||
larkCancel context.CancelFunc // 飞书长连接取消函数,用于配置变更时重启
|
||||
wechatCancel context.CancelFunc // 微信 iLink 长轮询取消函数
|
||||
telegramCancel context.CancelFunc // Telegram 长轮询取消函数
|
||||
slackCancel context.CancelFunc // Slack Socket Mode 取消函数
|
||||
discordCancel context.CancelFunc // Discord Gateway 取消函数
|
||||
qqCancel context.CancelFunc // QQ WebSocket 取消函数
|
||||
c2Manager *c2.Manager // C2 管理器(未启用 C2 时为 nil)
|
||||
c2Watchdog *c2.SessionWatchdog // C2 会话看门狗
|
||||
c2WatchdogCancel context.CancelFunc // 看门狗取消函数
|
||||
@@ -728,6 +732,26 @@ func (a *App) startRobotConnections() {
|
||||
a.wechatCancel = cancel
|
||||
go robot.StartWechat(ctx, cfg.Robots, a.robotHandler, cfg.Version, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.Telegram.Enabled && strings.TrimSpace(cfg.Robots.Telegram.BotToken) != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.telegramCancel = cancel
|
||||
go robot.StartTelegram(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.Slack.Enabled && strings.TrimSpace(cfg.Robots.Slack.BotToken) != "" && strings.TrimSpace(cfg.Robots.Slack.AppToken) != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.slackCancel = cancel
|
||||
go robot.StartSlack(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.Discord.Enabled && strings.TrimSpace(cfg.Robots.Discord.BotToken) != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.discordCancel = cancel
|
||||
go robot.StartDiscord(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
if cfg.Robots.QQ.Enabled && strings.TrimSpace(cfg.Robots.QQ.AppID) != "" && strings.TrimSpace(cfg.Robots.QQ.ClientSecret) != "" {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.qqCancel = cancel
|
||||
go robot.StartQQ(ctx, cfg.Robots, a.robotHandler, a.logger.Logger)
|
||||
}
|
||||
}
|
||||
|
||||
// RestartRobotConnections 重启钉钉/飞书/微信长连接,使前端应用配置后立即生效(实现 handler.RobotRestarter)
|
||||
@@ -745,6 +769,22 @@ func (a *App) RestartRobotConnections() {
|
||||
a.wechatCancel()
|
||||
a.wechatCancel = nil
|
||||
}
|
||||
if a.telegramCancel != nil {
|
||||
a.telegramCancel()
|
||||
a.telegramCancel = nil
|
||||
}
|
||||
if a.slackCancel != nil {
|
||||
a.slackCancel()
|
||||
a.slackCancel = nil
|
||||
}
|
||||
if a.discordCancel != nil {
|
||||
a.discordCancel()
|
||||
a.discordCancel = nil
|
||||
}
|
||||
if a.qqCancel != nil {
|
||||
a.qqCancel()
|
||||
a.qqCancel = nil
|
||||
}
|
||||
a.robotMu.Unlock()
|
||||
// 给旧 goroutine 一点时间退出
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
@@ -1199,8 +1239,11 @@ func setupRoutes(
|
||||
|
||||
// 图编排 / 工作流定义(图结构固定,业务字段保存在 graph_json 中)
|
||||
protected.GET("/workflows/runs/pending", workflowHandler.ListPendingRuns)
|
||||
protected.GET("/workflows/runs/:runId/replay", workflowHandler.ReplayRun)
|
||||
protected.GET("/workflows/runs/:runId", workflowHandler.GetRun)
|
||||
protected.POST("/workflows/runs/:runId/resume", workflowHandler.ResumeRun)
|
||||
protected.POST("/workflows/validate", workflowHandler.Validate)
|
||||
protected.POST("/workflows/dry-run", workflowHandler.DryRun)
|
||||
protected.GET("/workflows", workflowHandler.List)
|
||||
protected.GET("/workflows/:id", workflowHandler.Get)
|
||||
protected.POST("/workflows", workflowHandler.Create)
|
||||
|
||||
@@ -453,13 +453,17 @@ type MultiAgentAPIUpdate struct {
|
||||
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
||||
}
|
||||
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink 等)
|
||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink、Telegram、Slack、Discord、QQ 等)
|
||||
type RobotsConfig struct {
|
||||
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
|
||||
Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定)
|
||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
|
||||
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
||||
Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略
|
||||
Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定)
|
||||
Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信
|
||||
Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉
|
||||
Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书
|
||||
Telegram RobotTelegramConfig `yaml:"telegram,omitempty" json:"telegram,omitempty"` // Telegram
|
||||
Slack RobotSlackConfig `yaml:"slack,omitempty" json:"slack,omitempty"` // Slack
|
||||
Discord RobotDiscordConfig `yaml:"discord,omitempty" json:"discord,omitempty"` // Discord
|
||||
QQ RobotQQConfig `yaml:"qq,omitempty" json:"qq,omitempty"` // QQ 机器人
|
||||
}
|
||||
|
||||
// RobotWechatConfig 微信 iLink 机器人配置(个人微信 ClawBot / iLink 协议)
|
||||
@@ -525,6 +529,37 @@ type RobotLarkConfig struct {
|
||||
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 {
|
||||
Host string `yaml:"host" json:"host"`
|
||||
Port int `yaml:"port" json:"port"`
|
||||
|
||||
@@ -22,20 +22,20 @@ type WorkflowDefinition struct {
|
||||
}
|
||||
|
||||
type WorkflowRun struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
RoleID string `json:"role_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
InputJSON string `json:"input_json,omitempty"`
|
||||
OutputJSON string `json:"output_json,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PendingHITLNodeID string `json:"pending_hitl_node_id,omitempty"`
|
||||
PendingHITLJSON string `json:"pending_hitl_json,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
RoleID string `json:"role_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
InputJSON string `json:"input_json,omitempty"`
|
||||
OutputJSON string `json:"output_json,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PendingHITLNodeID string `json:"pending_hitl_node_id,omitempty"`
|
||||
PendingHITLJSON string `json:"pending_hitl_json,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
type WorkflowNodeRun struct {
|
||||
@@ -50,6 +50,25 @@ type WorkflowNodeRun struct {
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
func scanWorkflowNodeRun(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowNodeRun, error) {
|
||||
var row WorkflowNodeRun
|
||||
var inputJSON, outputJSON, errText sql.NullString
|
||||
var finishedAt sql.NullTime
|
||||
if err := scanner.Scan(&row.ID, &row.RunID, &row.NodeID, &row.Status, &inputJSON, &outputJSON, &errText, &row.StartedAt, &finishedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.InputJSON = inputJSON.String
|
||||
row.OutputJSON = outputJSON.String
|
||||
row.Error = errText.String
|
||||
if finishedAt.Valid {
|
||||
t := finishedAt.Time
|
||||
row.FinishedAt = &t
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func scanWorkflowDefinition(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowDefinition, error) {
|
||||
@@ -248,6 +267,31 @@ func (db *DB) FinishWorkflowNodeRun(nodeRunID, status, outputJSON, errText strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ListWorkflowNodeRuns(runID string) ([]*WorkflowNodeRun, error) {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return nil, fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT id, run_id, node_id, status, input_json, output_json, error, started_at, finished_at
|
||||
FROM workflow_node_runs WHERE run_id = ? ORDER BY started_at ASC`,
|
||||
runID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流节点运行失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*WorkflowNodeRun
|
||||
for rows.Next() {
|
||||
row, err := scanWorkflowNodeRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanWorkflowRun(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowRun, error) {
|
||||
|
||||
@@ -1529,6 +1529,10 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
|
||||
|
||||
flusher, _ := c.Writer.(http.Flusher)
|
||||
ctx := c.Request.Context()
|
||||
var writeMu sync.Mutex
|
||||
stopKeepalive := make(chan struct{})
|
||||
go sseKeepalive(c, stopKeepalive, &writeMu)
|
||||
defer close(stopKeepalive)
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -1538,12 +1542,15 @@ func (h *AgentHandler) SubscribeAgentTaskEvents(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeMu.Lock()
|
||||
if _, err := c.Writer.Write(chunk); err != nil {
|
||||
writeMu.Unlock()
|
||||
return
|
||||
}
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
writeMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,6 +808,10 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
||||
zap.Bool("wecom_enabled", h.config.Robots.Wecom.Enabled),
|
||||
zap.Bool("dingtalk_enabled", h.config.Robots.Dingtalk.Enabled),
|
||||
zap.Bool("lark_enabled", h.config.Robots.Lark.Enabled),
|
||||
zap.Bool("telegram_enabled", h.config.Robots.Telegram.Enabled),
|
||||
zap.Bool("slack_enabled", h.config.Robots.Slack.Enabled),
|
||||
zap.Bool("discord_enabled", h.config.Robots.Discord.Enabled),
|
||||
zap.Bool("qq_enabled", h.config.Robots.QQ.Enabled),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1870,6 +1874,28 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
||||
setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret)
|
||||
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
|
||||
setBoolInMap(larkNode, "allow_chat_id_fallback", cfg.Lark.AllowChatIDFallback)
|
||||
|
||||
telegramNode := ensureMap(robotsNode, "telegram")
|
||||
setBoolInMap(telegramNode, "enabled", cfg.Telegram.Enabled)
|
||||
setStringInMap(telegramNode, "bot_token", cfg.Telegram.BotToken)
|
||||
setStringInMap(telegramNode, "bot_username", cfg.Telegram.BotUsername)
|
||||
setBoolInMap(telegramNode, "allow_group_messages", cfg.Telegram.AllowGroupMessages)
|
||||
|
||||
slackNode := ensureMap(robotsNode, "slack")
|
||||
setBoolInMap(slackNode, "enabled", cfg.Slack.Enabled)
|
||||
setStringInMap(slackNode, "bot_token", cfg.Slack.BotToken)
|
||||
setStringInMap(slackNode, "app_token", cfg.Slack.AppToken)
|
||||
|
||||
discordNode := ensureMap(robotsNode, "discord")
|
||||
setBoolInMap(discordNode, "enabled", cfg.Discord.Enabled)
|
||||
setStringInMap(discordNode, "bot_token", cfg.Discord.BotToken)
|
||||
setBoolInMap(discordNode, "allow_guild_messages", cfg.Discord.AllowGuildMessages)
|
||||
|
||||
qqNode := ensureMap(robotsNode, "qq")
|
||||
setBoolInMap(qqNode, "enabled", cfg.QQ.Enabled)
|
||||
setStringInMap(qqNode, "app_id", cfg.QQ.AppID)
|
||||
setStringInMap(qqNode, "client_secret", cfg.QQ.ClientSecret)
|
||||
setBoolInMap(qqNode, "sandbox", cfg.QQ.Sandbox)
|
||||
}
|
||||
|
||||
func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
||||
|
||||
@@ -41,6 +41,12 @@ type workflowSaveRequest struct {
|
||||
GraphJSON json.RawMessage `json:"graph_json,omitempty"`
|
||||
}
|
||||
|
||||
type workflowDryRunRequest struct {
|
||||
Graph json.RawMessage `json:"graph,omitempty"`
|
||||
GraphJSON json.RawMessage `json:"graph_json,omitempty"`
|
||||
Inputs map[string]interface{} `json:"inputs,omitempty"`
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) List(c *gin.Context) {
|
||||
includeDisabled := strings.EqualFold(c.Query("includeDisabled"), "true") || c.Query("include_disabled") == "1"
|
||||
items, err := h.db.ListWorkflowDefinitions(includeDisabled)
|
||||
@@ -69,6 +75,57 @@ func (h *WorkflowHandler) Create(c *gin.Context) {
|
||||
h.save(c, "")
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) Validate(c *gin.Context) {
|
||||
var req workflowSaveRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
graph := req.Graph
|
||||
if len(graph) == 0 {
|
||||
graph = req.GraphJSON
|
||||
}
|
||||
if len(graph) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "graph 不能为空"})
|
||||
return
|
||||
}
|
||||
if !json.Valid(graph) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "graph 必须是合法 JSON"})
|
||||
return
|
||||
}
|
||||
if err := workflowrunner.ValidateGraphJSON(c.Request.Context(), string(graph)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) DryRun(c *gin.Context) {
|
||||
var req workflowDryRunRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||
return
|
||||
}
|
||||
graph := req.Graph
|
||||
if len(graph) == 0 {
|
||||
graph = req.GraphJSON
|
||||
}
|
||||
if len(graph) == 0 || !json.Valid(graph) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "graph 必须是合法 JSON"})
|
||||
return
|
||||
}
|
||||
inputs := make(map[string]any, len(req.Inputs))
|
||||
for k, v := range req.Inputs {
|
||||
inputs[k] = v
|
||||
}
|
||||
result, err := workflowrunner.DryRunGraphJSON(c.Request.Context(), string(graph), inputs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"result": result})
|
||||
}
|
||||
|
||||
func (h *WorkflowHandler) Update(c *gin.Context) {
|
||||
h.save(c, c.Param("id"))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -28,7 +29,40 @@ func (h *WorkflowHandler) GetRun(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "工作流运行不存在"})
|
||||
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) {
|
||||
@@ -99,10 +133,10 @@ func (h *WorkflowHandler) ResumeRun(c *gin.Context) {
|
||||
}
|
||||
if delegated {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"workflowRunId": runID,
|
||||
"status": "delegated",
|
||||
"streamResuming": true,
|
||||
"approved": req.Approved,
|
||||
"workflowRunId": runID,
|
||||
"status": "delegated",
|
||||
"streamResuming": true,
|
||||
"approved": req.Approved,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package multiagent
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MCPExecutionBinder maps ADK toolCallID → MCP monitor execution ID for a single agent run.
|
||||
type MCPExecutionBinder struct {
|
||||
mu sync.RWMutex
|
||||
byToolCall map[string]string
|
||||
}
|
||||
|
||||
@@ -20,12 +24,17 @@ func (b *MCPExecutionBinder) Bind(toolCallID, executionID string) {
|
||||
if tid == "" || eid == "" {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.byToolCall[tid] = eid
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *MCPExecutionBinder) ExecutionID(toolCallID string) string {
|
||||
if b == nil {
|
||||
return ""
|
||||
}
|
||||
return b.byToolCall[strings.TrimSpace(toolCallID)]
|
||||
tid := strings.TrimSpace(toolCallID)
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.byToolCall[tid]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package multiagent
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMCPExecutionBinder(t *testing.T) {
|
||||
b := NewMCPExecutionBinder()
|
||||
@@ -12,3 +16,28 @@ func TestMCPExecutionBinder(t *testing.T) {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPExecutionBinder_ConcurrentBind 回归并行 tool 回调不得 concurrent map panic。
|
||||
func TestMCPExecutionBinder_ConcurrentBind(t *testing.T) {
|
||||
b := NewMCPExecutionBinder()
|
||||
const workers = 64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(workers * 2)
|
||||
for i := 0; i < workers; i++ {
|
||||
i := i
|
||||
toolCallID := fmt.Sprintf("call-%d", i)
|
||||
execID := fmt.Sprintf("exec-%d", i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
b.Bind(toolCallID, execID)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = b.ExecutionID(toolCallID)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if got := b.ExecutionID("call-0"); got != "exec-0" {
|
||||
t.Fatalf("expected exec-0, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,15 @@ import (
|
||||
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
|
||||
)
|
||||
|
||||
// newPlanExecuteExecutor 与 planexecute.NewExecutor 行为一致,但可为执行器注入 Handlers(例如 summarization 中间件)。
|
||||
// newPlanExecuteExecutor builds the Plan-Execute Executor as an Eino ChatModelAgent.
|
||||
//
|
||||
// Eino's planexecute.Config accepts any adk.Agent as Executor; this implementation
|
||||
// keeps the official Executor contract (Plan/UserInput/ExecutedSteps session keys
|
||||
// and ExecutedStepSessionKey output) while using ChatModelAgentConfig.Handlers so
|
||||
// the executor can run the same ADK middleware stack as Deep/Supervisor. As of
|
||||
// Eino v0.9.12/v0.10.0-alpha.10, planexecute.NewExecutor still does not expose a
|
||||
// Handlers field, so this custom Executor is the best-practice extension point
|
||||
// that preserves middleware without forking the whole planexecute loop.
|
||||
func newPlanExecuteExecutor(ctx context.Context, cfg *planexecute.ExecutorConfig, handlers []adk.ChatModelAgentMiddleware) (adk.Agent, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("plan_execute: ExecutorConfig 为空")
|
||||
@@ -25,18 +33,27 @@ func newPlanExecuteExecutor(ctx context.Context, cfg *planexecute.ExecutorConfig
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q missing (possible session corruption)", planexecute.PlanSessionKey)
|
||||
}
|
||||
plan_ := plan.(planexecute.Plan)
|
||||
plan_, ok := plan.(planexecute.Plan)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q has invalid type %T", planexecute.PlanSessionKey, plan)
|
||||
}
|
||||
|
||||
userInput, ok := adk.GetSessionValue(ctx, planexecute.UserInputSessionKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q missing (possible session corruption)", planexecute.UserInputSessionKey)
|
||||
}
|
||||
userInput_ := userInput.([]adk.Message)
|
||||
userInput_, ok := userInput.([]adk.Message)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q has invalid type %T", planexecute.UserInputSessionKey, userInput)
|
||||
}
|
||||
|
||||
var executedSteps_ []planexecute.ExecutedStep
|
||||
executedStep, ok := adk.GetSessionValue(ctx, planexecute.ExecutedStepsSessionKey)
|
||||
if ok {
|
||||
executedSteps_ = executedStep.([]planexecute.ExecutedStep)
|
||||
executedSteps_, ok = executedStep.([]planexecute.ExecutedStep)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("plan_execute executor: session value %q has invalid type %T", planexecute.ExecutedStepsSessionKey, executedStep)
|
||||
}
|
||||
}
|
||||
|
||||
in := &planexecute.ExecutionContext{
|
||||
|
||||
@@ -100,6 +100,14 @@ func RunDeepAgent(
|
||||
if orchMode == "supervisor" && len(effectiveSubs) == 0 {
|
||||
return nil, fmt.Errorf("multi_agent.orchestration=supervisor 时需至少配置一个子代理(sub_agents 或 agents 目录 Markdown)")
|
||||
}
|
||||
if orchMode == "supervisor" && len(effectiveSubs) == 1 && progress != nil {
|
||||
progress("progress", "Supervisor 是专家路由模式;当前仅 1 个子代理,专家路由空间有限,仍会继续执行。", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"source": "eino",
|
||||
"orchestration": orchMode,
|
||||
"kind": "supervisor_boundary_hint",
|
||||
})
|
||||
}
|
||||
|
||||
einoLoc, einoSkillMW, einoFSTools, skillsRoot, einoErr := prepareEinoSkills(ctx, appCfg.SkillsDir, ma, logger)
|
||||
if einoErr != nil {
|
||||
@@ -351,6 +359,7 @@ func RunDeepAgent(
|
||||
sb.WriteString("\n- ")
|
||||
sb.WriteString(sa.Name(ctx))
|
||||
}
|
||||
sb.WriteString("\n\nSupervisor 是专家路由模式:仅当任务确实需要不同专家分工时才 transfer;简单查询、单步工具调用或无需专业分流的任务由你直接完成。避免在同一子代理之间反复 transfer;除非有新的、具体的补充目标。专家返回后,你必须自行汇总、裁剪、校验证据,再用 exit 交付最终答案。")
|
||||
sb.WriteString("\n\n当你已完成用户目标或需要将最终结论交付用户时,使用 exit 工具结束。")
|
||||
supInstr = sb.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
discordPlatform = "discord"
|
||||
discordMaxMessageRunes = 2000
|
||||
)
|
||||
|
||||
// StartDiscord 启动 Discord Gateway(WebSocket,无需公网回调)。
|
||||
func StartDiscord(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Discord
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" {
|
||||
return
|
||||
}
|
||||
go runDiscordLoop(ctx, cfg, h, logger)
|
||||
}
|
||||
|
||||
func runDiscordLoop(ctx context.Context, cfg config.RobotDiscordConfig, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := reconnectInitial
|
||||
for {
|
||||
err := runDiscordSession(ctx, cfg, h, logger)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("Discord Gateway 已按配置关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Discord Gateway 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
if !waitReconnect(ctx, &backoff) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runDiscordSession(ctx context.Context, cfg config.RobotDiscordConfig, h MessageHandler, logger *zap.Logger) error {
|
||||
token := strings.TrimSpace(cfg.BotToken)
|
||||
if !strings.HasPrefix(token, "Bot ") {
|
||||
token = "Bot " + token
|
||||
}
|
||||
session, err := discordgo.New(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
session.Identify.Intents = discordgo.IntentsGuildMessages |
|
||||
discordgo.IntentsDirectMessages |
|
||||
discordgo.IntentMessageContent
|
||||
|
||||
session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
|
||||
if m == nil || m.Author == nil || m.Author.Bot {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(m.Content)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
if m.GuildID != "" {
|
||||
if !cfg.AllowGuildMessages {
|
||||
return
|
||||
}
|
||||
if s.State.User == nil || !discordMentionsBot(m, s.State.User.ID) {
|
||||
return
|
||||
}
|
||||
}
|
||||
userID := discordSessionKey(m.GuildID, m.Author.ID)
|
||||
logger.Info("Discord 收到消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(discordPlatform, userID, text)
|
||||
discordPostReply(s, m.ChannelID, reply, logger)
|
||||
})
|
||||
|
||||
if err := session.Open(); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("Discord Gateway 已连接,等待收消息")
|
||||
defer session.Close()
|
||||
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func discordMentionsBot(m *discordgo.MessageCreate, botUserID string) bool {
|
||||
if m == nil || botUserID == "" {
|
||||
return false
|
||||
}
|
||||
for _, mention := range m.Mentions {
|
||||
if mention != nil && mention.ID == botUserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.Contains(m.Content, "<@"+botUserID+">") || strings.Contains(m.Content, "<@!"+botUserID+">")
|
||||
}
|
||||
|
||||
func discordSessionKey(guildID, userID string) string {
|
||||
guildID = strings.TrimSpace(guildID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if guildID == "" {
|
||||
return "u:" + userID
|
||||
}
|
||||
return "g:" + guildID + "|u:" + userID
|
||||
}
|
||||
|
||||
func discordPostReply(s *discordgo.Session, channelID, reply string, logger *zap.Logger) {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, discordMaxMessageRunes) {
|
||||
if _, err := s.ChannelMessageSend(channelID, chunk); err != nil {
|
||||
logger.Warn("Discord 发送回复失败", zap.String("channel", channelID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/tencent-connect/botgo"
|
||||
"github.com/tencent-connect/botgo/dto"
|
||||
"github.com/tencent-connect/botgo/event"
|
||||
"github.com/tencent-connect/botgo/openapi"
|
||||
"github.com/tencent-connect/botgo/token"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
qqPlatform = "qq"
|
||||
qqMaxMessageRunes = 3500
|
||||
)
|
||||
|
||||
var (
|
||||
qqHandlerMu sync.Mutex
|
||||
qqHandler MessageHandler
|
||||
qqLogger *zap.Logger
|
||||
qqAPI openapi.OpenAPI
|
||||
)
|
||||
|
||||
// StartQQ 启动 QQ 机器人 WebSocket(C2C 与群 @,出站连接,无需公网回调)。
|
||||
func StartQQ(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.QQ
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" {
|
||||
return
|
||||
}
|
||||
go runQQLoop(ctx, cfg, h, logger)
|
||||
}
|
||||
|
||||
func runQQLoop(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := reconnectInitial
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("QQ 机器人 WebSocket 已按配置关闭")
|
||||
return
|
||||
}
|
||||
err := runQQSession(ctx, cfg, h, logger)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("QQ 机器人 WebSocket 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
if !waitReconnect(ctx, &backoff) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runQQSession(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) error {
|
||||
appID := strings.TrimSpace(cfg.AppID)
|
||||
secret := strings.TrimSpace(cfg.ClientSecret)
|
||||
credentials := &token.QQBotCredentials{AppID: appID, AppSecret: secret}
|
||||
tokenSource := token.NewQQBotTokenSource(credentials)
|
||||
|
||||
if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var api openapi.OpenAPI
|
||||
if cfg.Sandbox {
|
||||
api = botgo.NewSandboxOpenAPI(appID, tokenSource)
|
||||
} else {
|
||||
api = botgo.NewOpenAPI(appID, tokenSource)
|
||||
}
|
||||
|
||||
qqHandlerMu.Lock()
|
||||
qqHandler = h
|
||||
qqLogger = logger
|
||||
qqAPI = api
|
||||
qqHandlerMu.Unlock()
|
||||
defer func() {
|
||||
qqHandlerMu.Lock()
|
||||
qqHandler = nil
|
||||
qqLogger = nil
|
||||
qqAPI = nil
|
||||
qqHandlerMu.Unlock()
|
||||
}()
|
||||
|
||||
intents := event.RegisterHandlers(
|
||||
event.C2CMessageEventHandler(handleQQC2CMessage),
|
||||
event.GroupATMessageEventHandler(handleQQGroupATMessage),
|
||||
)
|
||||
|
||||
wsInfo, err := api.WS(ctx, nil, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("QQ 机器人 WebSocket 正在连接…", zap.String("app_id", appID), zap.Bool("sandbox", cfg.Sandbox))
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- botgo.NewSessionManager().Start(wsInfo, tokenSource, &intents)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleQQC2CMessage(payload *dto.WSPayload, data *dto.WSC2CMessageData) error {
|
||||
if data == nil || data.Author == nil {
|
||||
return nil
|
||||
}
|
||||
text := strings.TrimSpace(data.Content)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
userOpenID := strings.TrimSpace(data.Author.ID)
|
||||
if userOpenID == "" {
|
||||
return nil
|
||||
}
|
||||
userID := "u:" + userOpenID
|
||||
qqHandlerMu.Lock()
|
||||
h := qqHandler
|
||||
logger := qqLogger
|
||||
api := qqAPI
|
||||
qqHandlerMu.Unlock()
|
||||
if h == nil || api == nil {
|
||||
return nil
|
||||
}
|
||||
logger.Info("QQ 收到 C2C 消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(qqPlatform, userID, text)
|
||||
return qqPostC2CReply(context.Background(), api, userOpenID, payload, data.ID, reply, logger)
|
||||
}
|
||||
|
||||
func handleQQGroupATMessage(payload *dto.WSPayload, data *dto.WSGroupATMessageData) error {
|
||||
if data == nil || data.Author == nil {
|
||||
return nil
|
||||
}
|
||||
text := strings.TrimSpace(data.Content)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
userOpenID := strings.TrimSpace(data.Author.ID)
|
||||
groupID := strings.TrimSpace(data.GroupID)
|
||||
if userOpenID == "" {
|
||||
return nil
|
||||
}
|
||||
userID := "g:" + groupID + "|u:" + userOpenID
|
||||
qqHandlerMu.Lock()
|
||||
h := qqHandler
|
||||
logger := qqLogger
|
||||
api := qqAPI
|
||||
qqHandlerMu.Unlock()
|
||||
if h == nil || api == nil {
|
||||
return nil
|
||||
}
|
||||
logger.Info("QQ 收到群 @ 消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(qqPlatform, userID, text)
|
||||
return qqPostGroupReply(context.Background(), api, groupID, payload, data.ID, reply, logger)
|
||||
}
|
||||
|
||||
func qqPostC2CReply(ctx context.Context, api openapi.OpenAPI, userOpenID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) {
|
||||
msg := &dto.MessageToCreate{
|
||||
Content: chunk,
|
||||
MsgID: msgID,
|
||||
}
|
||||
if payload != nil && payload.EventID != "" {
|
||||
msg.EventID = payload.EventID
|
||||
}
|
||||
if _, err := api.PostC2CMessage(ctx, userOpenID, msg); err != nil {
|
||||
logger.Warn("QQ 发送 C2C 回复失败", zap.String("to", userOpenID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func qqPostGroupReply(ctx context.Context, api openapi.OpenAPI, groupID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) {
|
||||
msg := &dto.MessageToCreate{
|
||||
Content: chunk,
|
||||
MsgID: msgID,
|
||||
}
|
||||
if payload != nil && payload.EventID != "" {
|
||||
msg.EventID = payload.EventID
|
||||
}
|
||||
if _, err := api.PostGroupMessage(ctx, groupID, msg); err != nil {
|
||||
logger.Warn("QQ 发送群消息回复失败", zap.String("group", groupID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
reconnectInitial = 5 * time.Second
|
||||
reconnectMax = 60 * time.Second
|
||||
)
|
||||
|
||||
func waitReconnect(ctx context.Context, backoff *time.Duration) bool {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(*backoff):
|
||||
if *backoff < reconnectMax {
|
||||
*backoff *= 2
|
||||
if *backoff > reconnectMax {
|
||||
*backoff = reconnectMax
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func bumpBackoff(backoff *time.Duration) {
|
||||
if *backoff < reconnectMax {
|
||||
*backoff *= 2
|
||||
if *backoff > reconnectMax {
|
||||
*backoff = reconnectMax
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/slack-go/slack"
|
||||
"github.com/slack-go/slack/slackevents"
|
||||
"github.com/slack-go/slack/socketmode"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
slackPlatform = "slack"
|
||||
slackMaxMessageRunes = 3900
|
||||
)
|
||||
|
||||
// StartSlack 启动 Slack Socket Mode(出站 WebSocket,无需公网回调)。
|
||||
func StartSlack(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Slack
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" || strings.TrimSpace(cfg.AppToken) == "" {
|
||||
return
|
||||
}
|
||||
go runSlackLoop(ctx, cfg, h, logger)
|
||||
}
|
||||
|
||||
func runSlackLoop(ctx context.Context, cfg config.RobotSlackConfig, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := reconnectInitial
|
||||
for {
|
||||
err := runSlackSocket(ctx, cfg, h, logger)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("Slack Socket Mode 已按配置关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Slack Socket Mode 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
if !waitReconnect(ctx, &backoff) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runSlackSocket(ctx context.Context, cfg config.RobotSlackConfig, h MessageHandler, logger *zap.Logger) error {
|
||||
api := slack.New(
|
||||
strings.TrimSpace(cfg.BotToken),
|
||||
slack.OptionAppLevelToken(strings.TrimSpace(cfg.AppToken)),
|
||||
)
|
||||
client := socketmode.New(api)
|
||||
logger.Info("Slack Socket Mode 正在连接…")
|
||||
|
||||
go func() {
|
||||
for evt := range client.Events {
|
||||
switch evt.Type {
|
||||
case socketmode.EventTypeEventsAPI:
|
||||
eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
client.Ack(*evt.Request)
|
||||
if eventsAPIEvent.Type != slackevents.CallbackEvent {
|
||||
continue
|
||||
}
|
||||
switch ev := eventsAPIEvent.InnerEvent.Data.(type) {
|
||||
case *slackevents.MessageEvent:
|
||||
handleSlackMessage(ctx, api, eventsAPIEvent.TeamID, ev, h, logger)
|
||||
case *slackevents.AppMentionEvent:
|
||||
handleSlackAppMention(ctx, api, eventsAPIEvent.TeamID, ev, h, logger)
|
||||
}
|
||||
case socketmode.EventTypeConnecting:
|
||||
logger.Info("Slack Socket Mode 正在连接…")
|
||||
case socketmode.EventTypeConnected:
|
||||
logger.Info("Slack Socket Mode 已连接,等待收消息")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return client.RunContext(ctx)
|
||||
}
|
||||
|
||||
func handleSlackMessage(ctx context.Context, api *slack.Client, teamID string, ev *slackevents.MessageEvent, h MessageHandler, logger *zap.Logger) {
|
||||
if ev == nil || ev.BotID != "" || ev.SubType != "" {
|
||||
return
|
||||
}
|
||||
if ev.ChannelType != "im" {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(ev.Text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
userID := slackSessionKey(teamID, ev.User)
|
||||
logger.Info("Slack 收到消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(slackPlatform, userID, text)
|
||||
slackPostReply(ctx, api, ev.Channel, reply, logger)
|
||||
}
|
||||
|
||||
func handleSlackAppMention(ctx context.Context, api *slack.Client, teamID string, ev *slackevents.AppMentionEvent, h MessageHandler, logger *zap.Logger) {
|
||||
if ev == nil || ev.BotID != "" {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(ev.Text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
userID := slackSessionKey(teamID, ev.User)
|
||||
logger.Info("Slack 收到 @ 消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(slackPlatform, userID, text)
|
||||
slackPostReply(ctx, api, ev.Channel, reply, logger)
|
||||
}
|
||||
|
||||
func slackSessionKey(teamID, userID string) string {
|
||||
teamID = strings.TrimSpace(teamID)
|
||||
userID = strings.TrimSpace(userID)
|
||||
if teamID == "" {
|
||||
teamID = "default"
|
||||
}
|
||||
return "t:" + teamID + "|u:" + userID
|
||||
}
|
||||
|
||||
func slackPostReply(ctx context.Context, api *slack.Client, channel, reply string, logger *zap.Logger) {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, slackMaxMessageRunes) {
|
||||
_, _, err := api.PostMessageContext(ctx, channel, slack.MsgOptionText(chunk, false))
|
||||
if err != nil {
|
||||
logger.Warn("Slack 发送回复失败", zap.String("channel", channel), zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package robot
|
||||
|
||||
import "strings"
|
||||
|
||||
// splitTextChunks splits text into chunks no longer than maxRunes (rune count).
|
||||
func splitTextChunks(text string, maxRunes int) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" || maxRunes <= 0 {
|
||||
return nil
|
||||
}
|
||||
runes := []rune(text)
|
||||
if len(runes) <= maxRunes {
|
||||
return []string{text}
|
||||
}
|
||||
var out []string
|
||||
for len(runes) > 0 {
|
||||
end := maxRunes
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
out = append(out, string(runes[:end]))
|
||||
runes = runes[end:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func trimReply(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
telegramPlatform = "telegram"
|
||||
telegramAPIBase = "https://api.telegram.org"
|
||||
telegramLongPollSec = 30
|
||||
telegramMaxMessageRunes = 4096
|
||||
)
|
||||
|
||||
type telegramUpdate struct {
|
||||
UpdateID int `json:"update_id"`
|
||||
Message *telegramMessage `json:"message"`
|
||||
}
|
||||
|
||||
type telegramMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
Chat telegramChat `json:"chat"`
|
||||
From *telegramUser `json:"from"`
|
||||
Text string `json:"text"`
|
||||
Entities []telegramEntity `json:"entities"`
|
||||
}
|
||||
|
||||
type telegramChat struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type telegramUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
}
|
||||
|
||||
type telegramEntity struct {
|
||||
Type string `json:"type"`
|
||||
Offset int `json:"offset"`
|
||||
Length int `json:"length"`
|
||||
}
|
||||
|
||||
type telegramGetUpdatesResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Result []telegramUpdate `json:"result"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type telegramBotMe struct {
|
||||
OK bool `json:"ok"`
|
||||
Result struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
// StartTelegram 启动 Telegram Bot 长轮询(getUpdates,无需公网回调)。
|
||||
func StartTelegram(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Telegram
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" {
|
||||
return
|
||||
}
|
||||
go runTelegramLoop(ctx, cfg, h, logger)
|
||||
}
|
||||
|
||||
func runTelegramLoop(ctx context.Context, cfg config.RobotTelegramConfig, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := reconnectInitial
|
||||
for {
|
||||
err := runTelegramPoll(ctx, cfg, h, logger)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("Telegram 长轮询已按配置关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("Telegram 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
if !waitReconnect(ctx, &backoff) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runTelegramPoll(ctx context.Context, cfg config.RobotTelegramConfig, h MessageHandler, logger *zap.Logger) error {
|
||||
token := strings.TrimSpace(cfg.BotToken)
|
||||
botUsername := strings.TrimSpace(cfg.BotUsername)
|
||||
if botUsername == "" {
|
||||
if name, err := telegramGetMe(ctx, token); err != nil {
|
||||
logger.Warn("Telegram getMe 失败", zap.Error(err))
|
||||
} else {
|
||||
botUsername = name
|
||||
}
|
||||
}
|
||||
offset := cfg.UpdateOffset
|
||||
logger.Info("Telegram 长轮询已启动", zap.String("bot", botUsername))
|
||||
client := &http.Client{Timeout: telegramLongPollSec*time.Second + 10*time.Second}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
updates, err := telegramGetUpdates(ctx, client, token, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range updates {
|
||||
next := int64(u.UpdateID) + 1
|
||||
if next > offset {
|
||||
offset = next
|
||||
}
|
||||
if u.Message == nil || u.Message.From == nil || u.Message.From.IsBot {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(u.Message.Text)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
chatType := strings.ToLower(strings.TrimSpace(u.Message.Chat.Type))
|
||||
if chatType != "private" {
|
||||
if !cfg.AllowGroupMessages {
|
||||
continue
|
||||
}
|
||||
if botUsername != "" && !telegramMentionsBot(text, u.Message.Entities, botUsername) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
userID := telegramSessionKey(chatType, u.Message.Chat.ID, u.Message.From.ID)
|
||||
logger.Info("Telegram 收到消息", zap.String("from", userID), zap.String("content", text))
|
||||
reply := h.HandleMessage(telegramPlatform, userID, text)
|
||||
if err := telegramSendReply(ctx, client, token, u.Message.Chat.ID, reply); err != nil {
|
||||
logger.Warn("Telegram 发送回复失败", zap.String("to", userID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func telegramSessionKey(chatType string, chatID, fromUserID int64) string {
|
||||
if chatType == "private" {
|
||||
return fmt.Sprintf("u:%d", fromUserID)
|
||||
}
|
||||
return fmt.Sprintf("g:%d|u:%d", chatID, fromUserID)
|
||||
}
|
||||
|
||||
func telegramMentionsBot(text string, entities []telegramEntity, botUsername string) bool {
|
||||
needle := "@" + strings.TrimPrefix(strings.ToLower(botUsername), "@")
|
||||
lower := strings.ToLower(text)
|
||||
if strings.Contains(lower, needle) {
|
||||
return true
|
||||
}
|
||||
for _, e := range entities {
|
||||
if e.Type != "mention" {
|
||||
continue
|
||||
}
|
||||
if e.Offset < 0 || e.Length <= 0 || e.Offset+e.Length > len(text) {
|
||||
continue
|
||||
}
|
||||
mention := strings.ToLower(text[e.Offset : e.Offset+e.Length])
|
||||
if mention == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func telegramAPIURL(token, method string) string {
|
||||
return fmt.Sprintf("%s/bot%s/%s", telegramAPIBase, token, method)
|
||||
}
|
||||
|
||||
func telegramGetMe(ctx context.Context, token string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, telegramAPIURL(token, "getMe"), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var parsed telegramBotMe
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !parsed.OK {
|
||||
return "", fmt.Errorf("getMe failed: %s", string(body))
|
||||
}
|
||||
return parsed.Result.Username, nil
|
||||
}
|
||||
|
||||
func telegramGetUpdates(ctx context.Context, client *http.Client, token string, offset int64) ([]telegramUpdate, error) {
|
||||
url := fmt.Sprintf("%s?timeout=%d&allowed_updates=%s", telegramAPIURL(token, "getUpdates"), telegramLongPollSec, `["message"]`)
|
||||
if offset > 0 {
|
||||
url += fmt.Sprintf("&offset=%d", offset)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var parsed telegramGetUpdatesResp
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !parsed.OK {
|
||||
if parsed.Description != "" {
|
||||
return nil, fmt.Errorf("getUpdates: %s", parsed.Description)
|
||||
}
|
||||
return nil, fmt.Errorf("getUpdates failed: %s", string(body))
|
||||
}
|
||||
return parsed.Result, nil
|
||||
}
|
||||
|
||||
func telegramSendReply(ctx context.Context, client *http.Client, token string, chatID int64, reply string) error {
|
||||
reply = trimReply(reply)
|
||||
if reply == "" {
|
||||
return nil
|
||||
}
|
||||
for _, chunk := range splitTextChunks(reply, telegramMaxMessageRunes) {
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": chunk,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, telegramAPIURL(token, "sendMessage"), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("sendMessage status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -9,15 +9,39 @@ import (
|
||||
// compileAgentSubgraph wraps an Agent canvas node as an Eino subgraph (AddGraphNode best practice).
|
||||
func compileAgentSubgraph(_ context.Context, node graphNode) (compose.AnyGraph, error) {
|
||||
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.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)
|
||||
}))
|
||||
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
|
||||
}
|
||||
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 g, nil
|
||||
|
||||
@@ -52,20 +52,32 @@ func resolveBinding(b FieldBinding, state *WorkflowLocalState) any {
|
||||
field = "output"
|
||||
}
|
||||
if from == "" || from == "previous" || from == "prev" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.LastOutput, field)
|
||||
}
|
||||
if field == "output" && state.LastOutput != nil {
|
||||
return state.LastOutput["output"]
|
||||
}
|
||||
return valueFromPath("previous."+field, state)
|
||||
}
|
||||
if from == "inputs" || from == "input" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.Inputs, field)
|
||||
}
|
||||
if field == "" {
|
||||
return state.Inputs
|
||||
}
|
||||
return valueFromPath("inputs."+field, state)
|
||||
}
|
||||
if from == "outputs" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.Outputs, field)
|
||||
}
|
||||
return valueFromPath("outputs."+field, state)
|
||||
}
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(valueFromPath(from, state), field)
|
||||
}
|
||||
return valueFromPath(from+"."+field, state)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -62,10 +62,13 @@ func extractAwaitingHITL(err error, art *compiledArtifact, runID string, args Ru
|
||||
label := firstNonEmpty(node.Label, nodeID)
|
||||
if args.DB != nil {
|
||||
pending := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"label": label,
|
||||
"prompt": prompt,
|
||||
"reviewer": cfgString(node.Config, "reviewer"),
|
||||
"nodeId": nodeID,
|
||||
"label": label,
|
||||
"prompt": prompt,
|
||||
"reviewer": cfgString(node.Config, "reviewer"),
|
||||
"checkpointId": runID,
|
||||
"interrupt": workflowInterruptMetadata(info),
|
||||
"resumePayload": map[string]any{"approved": "bool", "comment": "string"},
|
||||
}
|
||||
pendingJSON, _ := json.Marshal(pending)
|
||||
_ = args.DB.SetWorkflowRunAwaitingHITL(runID, nodeID, string(pendingJSON))
|
||||
@@ -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 {
|
||||
if info != nil && len(info.BeforeNodes) > 0 {
|
||||
for _, id := range info.BeforeNodes {
|
||||
@@ -176,9 +203,9 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
|
||||
if err != nil {
|
||||
if IsAwaitingHITL(err) {
|
||||
return &RunResult{
|
||||
RunID: runID,
|
||||
Status: "awaiting_hitl",
|
||||
Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID),
|
||||
RunID: runID,
|
||||
Status: "awaiting_hitl",
|
||||
Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID),
|
||||
AwaitingHITL: true,
|
||||
}, nil
|
||||
}
|
||||
@@ -194,6 +221,7 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
|
||||
"workflowRunId": runID,
|
||||
"status": "completed",
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"executedNodes": state.Executed,
|
||||
"skippedNodes": state.Skipped,
|
||||
"engine": "eino_workflow",
|
||||
@@ -206,6 +234,7 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
|
||||
"workflowRunId": runID,
|
||||
"workflowId": wf.ID,
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"response": response,
|
||||
"engine": "eino_workflow",
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ package workflow
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
|
||||
+14
-11
@@ -17,16 +17,16 @@ type compiledArtifact struct {
|
||||
|
||||
// Engine compiles and caches Eino Workflow artifacts.
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*compiledArtifact
|
||||
cpStore compose.CheckPointStore
|
||||
cpStoreMu sync.Once
|
||||
cpStoreErr error
|
||||
mu sync.RWMutex
|
||||
cache map[string]*compiledArtifact
|
||||
cpStore compose.CheckPointStore
|
||||
cpStoreMu sync.Once
|
||||
cpStoreErr error
|
||||
checkpointDir string
|
||||
}
|
||||
|
||||
var defaultEngine = &Engine{
|
||||
cache: make(map[string]*compiledArtifact),
|
||||
cache: make(map[string]*compiledArtifact),
|
||||
checkpointDir: "data/workflow-checkpoints",
|
||||
}
|
||||
|
||||
@@ -69,11 +69,8 @@ func ValidateGraphJSON(ctx context.Context, graphJSON string) error {
|
||||
return err
|
||||
}
|
||||
idx := indexGraph(g)
|
||||
if len(findStartNodeIDs(idx)) == 0 {
|
||||
return fmt.Errorf("工作流缺少可执行的起点节点")
|
||||
}
|
||||
if !hasTerminalNode(idx) {
|
||||
return fmt.Errorf("工作流至少需要一个无出边的终点或 output/end 节点")
|
||||
if err := validateGraphDefinition(g, idx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = defaultEngine.compile(ctx, g)
|
||||
return err
|
||||
@@ -120,6 +117,9 @@ func (e *Engine) compile(ctx context.Context, g *graphDef) (*compiledArtifact, e
|
||||
return nil, err
|
||||
}
|
||||
idx := indexGraph(g)
|
||||
if err := validateGraphDefinition(g, idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hitlIDs := collectHITLNodeIDs(idx)
|
||||
compileOpts := []compose.GraphCompileOption{
|
||||
compose.WithGraphName("CyberStrikeWorkflow"),
|
||||
@@ -219,6 +219,9 @@ func runWorkflowNodeLambda(runCtx context.Context, n graphNode) (WorkflowNodeOut
|
||||
if localRT == nil {
|
||||
return nil, fmt.Errorf("workflow runtime missing in context")
|
||||
}
|
||||
if err := prepareNodeInputState(localRT, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, proceed, err := executeNode(runCtx, localRT.args, localRT.runID, n, localRT.state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,11 +4,11 @@ import "errors"
|
||||
|
||||
// AwaitingHITLError indicates the workflow paused before a HITL node for human approval.
|
||||
type AwaitingHITLError struct {
|
||||
RunID string
|
||||
NodeID string
|
||||
RunID string
|
||||
NodeID string
|
||||
NodeLabel string
|
||||
Prompt string
|
||||
Reviewer string
|
||||
Prompt string
|
||||
Reviewer string
|
||||
}
|
||||
|
||||
func (e *AwaitingHITLError) Error() string {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var expressionOps = []string{">=", "<=", "==", "!=", " contains ", " matches ", ">", "<"}
|
||||
var jsonFuncRe = regexp.MustCompile(`^(jsonpath|jq)\((.*),\s*(['"][^'"]+['"])\)$`)
|
||||
var jsonFuncFindRe = regexp.MustCompile(`(jsonpath|jq)\([^)]*\)`)
|
||||
var singleTemplateVarRe = regexp.MustCompile(`^\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}$`)
|
||||
|
||||
func validateConditionExpression(expr string) error {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return fmt.Errorf("条件表达式不能为空")
|
||||
}
|
||||
for _, part := range splitBoolExpr(expr, "||") {
|
||||
for _, atom := range splitBoolExpr(part, "&&") {
|
||||
if err := validateConditionAtom(atom); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConditionAtom(expr string) error {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return fmt.Errorf("条件表达式存在空片段")
|
||||
}
|
||||
if strings.Count(expr, "{{") != strings.Count(expr, "}}") {
|
||||
return fmt.Errorf("条件表达式模板括号不匹配: %s", expr)
|
||||
}
|
||||
if err := validateJSONFunctions(expr); err != nil {
|
||||
return err
|
||||
}
|
||||
if left, right, ok := splitExpressionAtom(expr, " matches "); ok {
|
||||
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
|
||||
return fmt.Errorf("matches 表达式两侧不能为空: %s", expr)
|
||||
}
|
||||
pattern := cleanComparable(resolveStaticTemplate(right))
|
||||
if _, err := regexp.Compile(pattern); err != nil {
|
||||
return fmt.Errorf("matches 正则非法: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, op := range expressionOps {
|
||||
if op == " matches " {
|
||||
continue
|
||||
}
|
||||
if left, right, ok := splitExpressionAtom(expr, op); ok {
|
||||
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
|
||||
return fmt.Errorf("表达式 %q 两侧不能为空: %s", strings.TrimSpace(op), expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func evalCondition(expr string, state *WorkflowLocalState) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return true
|
||||
}
|
||||
orParts := splitBoolExpr(expr, "||")
|
||||
for _, orPart := range orParts {
|
||||
andOK := true
|
||||
for _, atom := range splitBoolExpr(orPart, "&&") {
|
||||
if !evalConditionAtom(atom, state) {
|
||||
andOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if andOK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evalConditionAtom(expr string, state *WorkflowLocalState) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
for _, op := range expressionOps {
|
||||
if left, right, ok := splitExpressionAtom(expr, op); ok {
|
||||
left = strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(left, state)))
|
||||
right = strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(right, state)))
|
||||
switch strings.TrimSpace(op) {
|
||||
case "==":
|
||||
return cleanComparable(left) == cleanComparable(right)
|
||||
case "!=":
|
||||
return cleanComparable(left) != cleanComparable(right)
|
||||
case ">":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a > b })
|
||||
case ">=":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a >= b })
|
||||
case "<":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a < b })
|
||||
case "<=":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a <= b })
|
||||
case "contains":
|
||||
return strings.Contains(cleanComparable(left), cleanComparable(right))
|
||||
case "matches":
|
||||
matched, _ := regexp.MatchString(cleanComparable(right), cleanComparable(left))
|
||||
return matched
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved := strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(expr, state)))
|
||||
v := strings.ToLower(cleanComparable(resolved))
|
||||
return v != "" && v != "false" && v != "0" && v != "null"
|
||||
}
|
||||
|
||||
func splitBoolExpr(expr, sep string) []string {
|
||||
parts := strings.Split(expr, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if s := strings.TrimSpace(part); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{strings.TrimSpace(expr)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitExpressionAtom(expr, op string) (string, string, bool) {
|
||||
if strings.TrimSpace(op) == "contains" || strings.TrimSpace(op) == "matches" {
|
||||
idx := strings.Index(expr, op)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return expr[:idx], expr[idx+len(op):], true
|
||||
}
|
||||
idx := strings.Index(expr, op)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return expr[:idx], expr[idx+len(op):], true
|
||||
}
|
||||
|
||||
func compareNumeric(left, right string, cmp func(float64, float64) bool) bool {
|
||||
a, errA := strconv.ParseFloat(cleanComparable(left), 64)
|
||||
b, errB := strconv.ParseFloat(cleanComparable(right), 64)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return cmp(a, b)
|
||||
}
|
||||
|
||||
func resolveStaticTemplate(s string) string {
|
||||
return templateVarRe.ReplaceAllString(s, "value")
|
||||
}
|
||||
|
||||
func resolveExpressionOperand(raw string, state *WorkflowLocalState) any {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if m := jsonFuncRe.FindStringSubmatch(raw); len(m) == 4 {
|
||||
inputExpr := strings.TrimSpace(m[2])
|
||||
path := strings.Trim(m[3], `"'`)
|
||||
input := resolveExpressionOperand(inputExpr, state)
|
||||
return evalJSONPathValue(input, path)
|
||||
}
|
||||
if m := singleTemplateVarRe.FindStringSubmatch(raw); len(m) == 2 {
|
||||
return valueFromPath(m[1], state)
|
||||
}
|
||||
return resolveTemplate(raw, state)
|
||||
}
|
||||
|
||||
func validateJSONFunctions(expr string) error {
|
||||
for _, candidate := range jsonFuncFindRe.FindAllString(expr, -1) {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
m := jsonFuncRe.FindStringSubmatch(candidate)
|
||||
if len(m) != 4 {
|
||||
return fmt.Errorf("JSONPath/JQ 函数格式应为 jsonpath(value, \"$.path\") 或 jq(value, \".path\")")
|
||||
}
|
||||
if err := validateJSONPathSyntax(strings.Trim(m[3], `"'`)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvalCondition_extendedOperators(t *testing.T) {
|
||||
state := newWorkflowLocalState(map[string]interface{}{"score": 9, "message": "status: ok"}, "run-expr")
|
||||
state.LastOutput = map[string]any{"output": "asset-123.example.com"}
|
||||
|
||||
tests := []string{
|
||||
"{{inputs.score}} >= 9",
|
||||
"{{inputs.message}} contains ok",
|
||||
"{{previous.output}} matches ^asset-[0-9]+\\.example\\.com$",
|
||||
"{{inputs.score}} > 5 && {{inputs.message}} contains status",
|
||||
}
|
||||
for _, expr := range tests {
|
||||
if err := validateConditionExpression(expr); err != nil {
|
||||
t.Fatalf("validate %q: %v", expr, err)
|
||||
}
|
||||
if !evalCondition(expr, state) {
|
||||
t.Fatalf("evalCondition(%q) = false, want true", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalCondition_jsonPathAndJQSafeSubset(t *testing.T) {
|
||||
state := newWorkflowLocalState(map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"risk": 9,
|
||||
"items": []any{
|
||||
map[string]any{"name": "first"},
|
||||
},
|
||||
},
|
||||
}, "run-jsonpath")
|
||||
state.LastOutput = map[string]any{"output": `{"status":"ok","score":7}`}
|
||||
|
||||
tests := []string{
|
||||
`jsonpath({{inputs.payload}}, "$.risk") >= 8`,
|
||||
`jq({{inputs.payload}}, ".items[0].name") == first`,
|
||||
`jsonpath({{previous.output}}, "$.status") == ok`,
|
||||
}
|
||||
for _, expr := range tests {
|
||||
if err := validateConditionExpression(expr); err != nil {
|
||||
t.Fatalf("validate %q: %v", expr, err)
|
||||
}
|
||||
if !evalCondition(expr, state) {
|
||||
t.Fatalf("evalCondition(%q) = false, want true", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUpstreamOutputs_allMerge(t *testing.T) {
|
||||
got := mergeUpstreamOutputs(JoinAllMerge, []map[string]any{
|
||||
{"output": "a", "left": 1},
|
||||
{"output": "b", "right": 2},
|
||||
})
|
||||
if got["kind"] != "join" || got["strategy"] != JoinAllMerge {
|
||||
t.Fatalf("join metadata = %#v", got)
|
||||
}
|
||||
values, ok := got["output"].([]any)
|
||||
if !ok || len(values) != 2 || values[0] != "a" || values[1] != "b" {
|
||||
t.Fatalf("merged output = %#v", got["output"])
|
||||
}
|
||||
if got["left"] != 1 || got["right"] != 2 {
|
||||
t.Fatalf("merged fields = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUpstreamOutputs_firstNonEmpty(t *testing.T) {
|
||||
got := mergeUpstreamOutputs(JoinFirstNonEmpty, []map[string]any{
|
||||
{"output": ""},
|
||||
{"output": "winner"},
|
||||
})
|
||||
if got["output"] != "winner" {
|
||||
t.Fatalf("output = %#v, want winner", got["output"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunGraphJSON_simulatesUnsafeNodes(t *testing.T) {
|
||||
graph := `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "agent-1", "type": "agent", "label": "Agent", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop", "output_key": "agent_result"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result", "source_binding": {"from": "outputs", "field": "agent_result"}}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "agent-1"},
|
||||
{"id": "e2", "source": "agent-1", "target": "out-1"}
|
||||
]
|
||||
}`
|
||||
result, err := DryRunGraphJSON(nilContext(), graph, map[string]any{"message": "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("DryRunGraphJSON: %v", err)
|
||||
}
|
||||
if got := result.Outputs["result"]; got != "[dry-run] agent execution skipped" {
|
||||
t.Fatalf("result output = %#v", got)
|
||||
}
|
||||
if len(result.Trace) != 3 {
|
||||
t.Fatalf("trace len = %d, want 3", len(result.Trace))
|
||||
}
|
||||
}
|
||||
|
||||
func nilContext() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
JoinAllMerge = "all_merge"
|
||||
JoinLastByCanvas = "last_by_canvas"
|
||||
JoinFirstNonEmpty = "first_non_empty"
|
||||
JoinFailFast = "fail_fast"
|
||||
)
|
||||
|
||||
var allowedJoinStrategies = map[string]bool{
|
||||
JoinAllMerge: true,
|
||||
JoinLastByCanvas: true,
|
||||
JoinFirstNonEmpty: true,
|
||||
JoinFailFast: true,
|
||||
}
|
||||
|
||||
func joinStrategy(node graphNode) string {
|
||||
strategy := strings.ToLower(cfgString(node.Config, "join_strategy"))
|
||||
if strategy == "" {
|
||||
return JoinAllMerge
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
|
||||
func prepareNodeInputState(rt *workflowRuntime, node graphNode) error {
|
||||
if rt == nil || rt.idx == nil || rt.state == nil {
|
||||
return nil
|
||||
}
|
||||
incoming := rt.idx.incoming[node.ID]
|
||||
if len(incoming) <= 1 {
|
||||
return nil
|
||||
}
|
||||
strategy := joinStrategy(node)
|
||||
if !allowedJoinStrategies[strategy] {
|
||||
return fmt.Errorf("节点「%s」使用了未知汇聚策略: %s", firstNonEmpty(node.Label, node.ID), strategy)
|
||||
}
|
||||
upstreams := make([]map[string]any, 0, len(incoming))
|
||||
for _, edge := range incoming {
|
||||
out := rt.state.NodeOutputs[edge.Source]
|
||||
if out == nil {
|
||||
continue
|
||||
}
|
||||
if isFailedNodeOutput(out) && strategy == JoinFailFast {
|
||||
return fmt.Errorf("上游节点「%s」失败,汇聚策略 fail_fast 中止", edge.Source)
|
||||
}
|
||||
upstreams = append(upstreams, out)
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
return nil
|
||||
}
|
||||
rt.state.LastOutput = mergeUpstreamOutputs(strategy, upstreams)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeUpstreamOutputs(strategy string, upstreams []map[string]any) map[string]any {
|
||||
switch strategy {
|
||||
case JoinLastByCanvas:
|
||||
return cloneNodeOutput(upstreams[len(upstreams)-1])
|
||||
case JoinFirstNonEmpty:
|
||||
for _, out := range upstreams {
|
||||
if !isEmptyOutputValue(out["output"]) {
|
||||
return cloneNodeOutput(out)
|
||||
}
|
||||
}
|
||||
return cloneNodeOutput(upstreams[0])
|
||||
default:
|
||||
merged := map[string]any{
|
||||
"kind": "join",
|
||||
"strategy": strategy,
|
||||
"upstreams": upstreams,
|
||||
}
|
||||
values := make([]any, 0, len(upstreams))
|
||||
for _, out := range upstreams {
|
||||
values = append(values, out["output"])
|
||||
for k, v := range out {
|
||||
if _, exists := merged[k]; !exists {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
merged["output"] = values
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
func cloneNodeOutput(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isEmptyOutputValue(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v)) == ""
|
||||
}
|
||||
|
||||
func isFailedNodeOutput(out map[string]any) bool {
|
||||
if out == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := out["error"]; ok && strings.TrimSpace(fmt.Sprint(v)) != "" {
|
||||
return true
|
||||
}
|
||||
if v, ok := out["is_error"]; ok {
|
||||
return strings.EqualFold(fmt.Sprint(v), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func evalJSONPathValue(input any, path string) any {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || path == "$" || path == "." {
|
||||
return input
|
||||
}
|
||||
if strings.HasPrefix(path, "$.") {
|
||||
path = strings.TrimPrefix(path, "$.")
|
||||
} else if strings.HasPrefix(path, ".") {
|
||||
path = strings.TrimPrefix(path, ".")
|
||||
} else if strings.HasPrefix(path, "$") {
|
||||
path = strings.TrimPrefix(path, "$")
|
||||
}
|
||||
cur := normalizeJSONInput(input)
|
||||
for _, token := range parseJSONPathTokens(path) {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
switch v := cur.(type) {
|
||||
case map[string]any:
|
||||
cur = v[token]
|
||||
case []any:
|
||||
idx, err := strconv.Atoi(token)
|
||||
if err != nil || idx < 0 || idx >= len(v) {
|
||||
return ""
|
||||
}
|
||||
cur = v[idx]
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if cur == nil {
|
||||
return ""
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func normalizeJSONInput(input any) any {
|
||||
switch v := input.(type) {
|
||||
case string:
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(v), &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return v
|
||||
case []byte:
|
||||
var decoded any
|
||||
if err := json.Unmarshal(v, &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return string(v)
|
||||
default:
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONPathTokens(path string) []string {
|
||||
var tokens []string
|
||||
var buf strings.Builder
|
||||
for i := 0; i < len(path); i++ {
|
||||
ch := path[i]
|
||||
switch ch {
|
||||
case '.':
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
case '[':
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(path) && path[j] != ']' {
|
||||
j++
|
||||
}
|
||||
if j <= len(path) {
|
||||
token := strings.Trim(path[i+1:j], `"' `)
|
||||
tokens = append(tokens, token)
|
||||
i = j
|
||||
}
|
||||
default:
|
||||
buf.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func validateJSONPathSyntax(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("JSONPath 不能为空")
|
||||
}
|
||||
if !strings.HasPrefix(path, "$") && !strings.HasPrefix(path, ".") {
|
||||
return fmt.Errorf("JSONPath/JQ 路径必须以 $ 或 . 开头")
|
||||
}
|
||||
if strings.Contains(path, "..") || strings.ContainsAny(path, "*?()|") {
|
||||
return fmt.Errorf("仅支持安全路径子集,不支持通配符、递归或表达式")
|
||||
}
|
||||
if strings.Count(path, "[") != strings.Count(path, "]") {
|
||||
return fmt.Errorf("JSONPath 方括号不匹配")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func accumulateWorkflowMetric(state *WorkflowLocalState, key string, delta any) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
if state.Metrics == nil {
|
||||
state.Metrics = make(map[string]any)
|
||||
}
|
||||
current := numericMetric(state.Metrics[key])
|
||||
state.Metrics[key] = current + numericMetric(delta)
|
||||
}
|
||||
|
||||
func numericMetric(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n)
|
||||
case int32:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case float32:
|
||||
return float64(n)
|
||||
case float64:
|
||||
return n
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(n, 64)
|
||||
return f
|
||||
default:
|
||||
f, _ := strconv.ParseFloat(fmt.Sprint(v), 64)
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
func collectAgentMetrics(state *WorkflowLocalState, data interface{}) {
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok || state == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens", "cost", "input_tokens", "output_tokens"} {
|
||||
if v, ok := m[key]; ok {
|
||||
accumulateWorkflowMetric(state, key, v)
|
||||
}
|
||||
}
|
||||
if usage, ok := m["usage"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens", "input_tokens", "output_tokens"} {
|
||||
if v, ok := usage[key]; ok {
|
||||
accumulateWorkflowMetric(state, key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,12 +18,21 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
|
||||
label = node.ID
|
||||
}
|
||||
nodeRunID := uuid.NewString()
|
||||
startedAt := time.Now()
|
||||
incomingCount := 0
|
||||
if rt := workflowRuntimeFrom(ctx); rt != nil && rt.idx != nil {
|
||||
incomingCount = len(rt.idx.incoming[node.ID])
|
||||
}
|
||||
input := map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": label,
|
||||
"inputs": state.Inputs,
|
||||
"previous": state.LastOutput,
|
||||
"join": map[string]any{
|
||||
"strategy": joinStrategy(node),
|
||||
"incoming": incomingCount,
|
||||
},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
if err := args.DB.CreateWorkflowNodeRun(&database.WorkflowNodeRun{
|
||||
@@ -32,7 +41,7 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
|
||||
NodeID: node.ID,
|
||||
Status: "running",
|
||||
InputJSON: string(inputJSON),
|
||||
StartedAt: time.Now(),
|
||||
StartedAt: startedAt,
|
||||
}); err != nil {
|
||||
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)
|
||||
duration := time.Since(startedAt)
|
||||
if result == nil {
|
||||
result = map[string]any{}
|
||||
}
|
||||
result["duration_ms"] = duration.Milliseconds()
|
||||
result["finished_at"] = time.Now().Format(time.RFC3339Nano)
|
||||
result["status"] = status
|
||||
accumulateWorkflowMetric(state, "node_count", 1)
|
||||
accumulateWorkflowMetric(state, "duration_ms", duration.Milliseconds())
|
||||
if strings.EqualFold(node.Type, "tool") {
|
||||
accumulateWorkflowMetric(state, "tool_call_count", 1)
|
||||
}
|
||||
outputJSON, _ := json.Marshal(result)
|
||||
if err := args.DB.FinishWorkflowNodeRun(nodeRunID, status, string(outputJSON), errText); err != nil {
|
||||
return nil, false, err
|
||||
@@ -64,6 +85,7 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
|
||||
"nodeType": node.Type,
|
||||
"label": label,
|
||||
"status": status,
|
||||
"durationMs": duration.Milliseconds(),
|
||||
"output": result,
|
||||
}
|
||||
progressMsg := fmt.Sprintf("节点完成:%s(%s)", label, status)
|
||||
|
||||
+17
-38
@@ -13,18 +13,11 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
|
||||
cfg := node.Config
|
||||
switch strings.ToLower(strings.TrimSpace(node.Type)) {
|
||||
case "start":
|
||||
out := map[string]any{
|
||||
"output": state.Inputs["message"],
|
||||
"message": state.Inputs["message"],
|
||||
"conversationId": state.Inputs["conversationId"],
|
||||
"projectId": state.Inputs["projectId"],
|
||||
}
|
||||
return out, true, "completed", ""
|
||||
return startOutputMap(node, state.Inputs["message"], state.Inputs["conversationId"], state.Inputs["projectId"]), true, "completed", ""
|
||||
case "condition":
|
||||
expr := cfgString(cfg, "expression")
|
||||
ok := evalCondition(expr, state)
|
||||
out := map[string]any{"output": ok, "condition": expr, "matched": ok}
|
||||
return out, true, "completed", ""
|
||||
return conditionOutputMap(node, expr, ok), true, "completed", ""
|
||||
case "output":
|
||||
key := cfgString(cfg, "output_key")
|
||||
if key == "" {
|
||||
@@ -37,13 +30,13 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
|
||||
value = resolveOutputSourceBinding(cfg, state)
|
||||
}
|
||||
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":
|
||||
value := resolveOutputSourceBinding(cfg, state)
|
||||
if b, ok := parseFieldBinding(cfg, "result_binding"); ok {
|
||||
value = resolveBinding(b, state)
|
||||
}
|
||||
return map[string]any{"output": value}, false, "completed", ""
|
||||
return endOutputMap(node, value), false, "completed", ""
|
||||
case "tool":
|
||||
return runToolNode(ctx, args, node, state)
|
||||
case "agent":
|
||||
@@ -52,7 +45,8 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
|
||||
return runHITLNode(args, node, state)
|
||||
default:
|
||||
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")
|
||||
if toolName == "" {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
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 := ""
|
||||
executionID := ""
|
||||
@@ -91,13 +85,7 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
|
||||
executionID = result.ExecutionID
|
||||
isError = result.IsError
|
||||
}
|
||||
out := map[string]any{
|
||||
"output": output,
|
||||
"tool_name": toolName,
|
||||
"arguments": toolArgs,
|
||||
"execution_id": executionID,
|
||||
"is_error": isError,
|
||||
}
|
||||
out := toolOutputMap(node, output, toolName, toolArgs, executionID, isError)
|
||||
if key := cfgString(node.Config, "output_key"); key != "" {
|
||||
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) {
|
||||
if args.AppCfg == nil || args.Agent == nil {
|
||||
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"))
|
||||
if mode == "" {
|
||||
@@ -167,7 +155,7 @@ func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *Work
|
||||
if err != nil {
|
||||
errText := err.Error()
|
||||
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
|
||||
response := ""
|
||||
@@ -189,11 +177,7 @@ func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *Work
|
||||
if key := cfgString(node.Config, "output_key"); key != "" {
|
||||
state.Outputs[key] = response
|
||||
}
|
||||
return map[string]any{
|
||||
"output": response,
|
||||
"mode": mode,
|
||||
"mcp_execution_ids": mcpIDs,
|
||||
}, true, "completed", ""
|
||||
return agentOutputMap(node, response, mode, mcpIDs), true, "completed", ""
|
||||
}
|
||||
|
||||
func buildAgentNodeMessage(node graphNode, state *WorkflowLocalState, upstreamInput string) string {
|
||||
@@ -221,6 +205,7 @@ func workflowAgentProgress(progress agent.ProgressCallback, state *WorkflowLocal
|
||||
return
|
||||
default:
|
||||
enrichWorkflowAgentEventData(data, state, node)
|
||||
collectAgentMetrics(state, data)
|
||||
if eventType == "iteration" {
|
||||
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 {
|
||||
args.Progress("workflow_hitl_checkpoint", "人工确认节点已通过", map[string]any{
|
||||
@@ -313,11 +298,5 @@ func runHITLNode(args RunArgs, node graphNode, state *WorkflowLocalState) (map[s
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
return map[string]any{
|
||||
"output": prompt,
|
||||
"prompt": prompt,
|
||||
"reviewer": reviewer,
|
||||
"approved": true,
|
||||
"mode": "interactive",
|
||||
}, true, "completed", ""
|
||||
return hitlOutputMap(node, "completed", prompt, prompt, reviewer, true), true, "completed", ""
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error)
|
||||
"workflowRunId": runID,
|
||||
"status": "completed",
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"executedNodes": state.Executed,
|
||||
"skippedNodes": state.Skipped,
|
||||
"engine": "eino_workflow",
|
||||
@@ -204,6 +205,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error)
|
||||
"workflowRunId": runID,
|
||||
"workflowId": wf.ID,
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"response": response,
|
||||
"engine": "eino_workflow",
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ type WorkflowLocalState struct {
|
||||
NodeOutputs map[string]map[string]any `json:"nodeOutputs,omitempty"`
|
||||
NodeProceed map[string]bool `json:"nodeProceed,omitempty"`
|
||||
LastOutput map[string]any `json:"lastOutput,omitempty"`
|
||||
Metrics map[string]any `json:"metrics,omitempty"`
|
||||
Executed []string `json:"executed,omitempty"`
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
WorkflowRunID string `json:"workflowRunId,omitempty"`
|
||||
@@ -33,10 +34,11 @@ func newWorkflowLocalState(inputs map[string]interface{}, runID string) *Workflo
|
||||
in[k] = v
|
||||
}
|
||||
return &WorkflowLocalState{
|
||||
Inputs: in,
|
||||
Outputs: make(map[string]any),
|
||||
NodeOutputs: make(map[string]map[string]any),
|
||||
NodeProceed: make(map[string]bool),
|
||||
Inputs: in,
|
||||
Outputs: make(map[string]any),
|
||||
NodeOutputs: make(map[string]map[string]any),
|
||||
NodeProceed: make(map[string]bool),
|
||||
Metrics: make(map[string]any),
|
||||
WorkflowRunID: runID,
|
||||
}
|
||||
}
|
||||
@@ -91,25 +93,6 @@ func valueFromPath(path string, state *WorkflowLocalState) any {
|
||||
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 {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.Trim(s, `"'`)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package workflow
|
||||
|
||||
type NodeOutputEnvelope struct {
|
||||
Kind string `json:"kind"`
|
||||
NodeID string `json:"node_id"`
|
||||
NodeType string `json:"node_type"`
|
||||
Status string `json:"status"`
|
||||
Output any `json:"output"`
|
||||
}
|
||||
|
||||
type StartOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Message any `json:"message"`
|
||||
ConversationID any `json:"conversationId"`
|
||||
ProjectID any `json:"projectId"`
|
||||
}
|
||||
|
||||
type ConditionOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Condition string `json:"condition"`
|
||||
Matched bool `json:"matched"`
|
||||
}
|
||||
|
||||
type ToolOutput struct {
|
||||
NodeOutputEnvelope
|
||||
ToolName string `json:"tool_name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
type AgentOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Mode string `json:"mode"`
|
||||
MCPExecutionIDs []string `json:"mcp_execution_ids"`
|
||||
}
|
||||
|
||||
type HITLOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Prompt string `json:"prompt"`
|
||||
Reviewer string `json:"reviewer"`
|
||||
Approved bool `json:"approved"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type OutputNodeOutput struct {
|
||||
NodeOutputEnvelope
|
||||
OutputKey string `json:"output_key"`
|
||||
Outputs map[string]any `json:"outputs"`
|
||||
}
|
||||
|
||||
func envelope(kind, nodeID, nodeType, status string, output any) NodeOutputEnvelope {
|
||||
return NodeOutputEnvelope{Kind: kind, NodeID: nodeID, NodeType: nodeType, Status: status, Output: output}
|
||||
}
|
||||
|
||||
func outputMap(env NodeOutputEnvelope, extra map[string]any) map[string]any {
|
||||
out := map[string]any{
|
||||
"kind": env.Kind,
|
||||
"node_id": env.NodeID,
|
||||
"node_type": env.NodeType,
|
||||
"status": env.Status,
|
||||
"output": env.Output,
|
||||
"typed": env,
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func startOutputMap(node graphNode, message, conversationID, projectID any) map[string]any {
|
||||
typed := StartOutput{
|
||||
NodeOutputEnvelope: envelope("start", node.ID, node.Type, "completed", message),
|
||||
Message: message,
|
||||
ConversationID: conversationID,
|
||||
ProjectID: projectID,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"message": typed.Message,
|
||||
"conversationId": typed.ConversationID,
|
||||
"projectId": typed.ProjectID,
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
|
||||
func conditionOutputMap(node graphNode, expr string, matched bool) map[string]any {
|
||||
typed := ConditionOutput{
|
||||
NodeOutputEnvelope: envelope("condition", node.ID, node.Type, "completed", matched),
|
||||
Condition: expr,
|
||||
Matched: matched,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"condition": expr, "matched": matched, "typed": typed})
|
||||
}
|
||||
|
||||
func outputNodeOutputMap(node graphNode, key string, value any) map[string]any {
|
||||
typed := OutputNodeOutput{
|
||||
NodeOutputEnvelope: envelope("output", node.ID, node.Type, "completed", value),
|
||||
OutputKey: key,
|
||||
Outputs: map[string]any{key: value},
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"output_key": key, "outputs": typed.Outputs, "typed": typed})
|
||||
}
|
||||
|
||||
func endOutputMap(node graphNode, value any) map[string]any {
|
||||
typed := envelope("end", node.ID, node.Type, "completed", value)
|
||||
return outputMap(typed, nil)
|
||||
}
|
||||
|
||||
func toolOutputMap(node graphNode, output string, toolName string, args map[string]any, executionID string, isError bool) map[string]any {
|
||||
typed := ToolOutput{
|
||||
NodeOutputEnvelope: envelope("tool", node.ID, node.Type, "completed", output),
|
||||
ToolName: toolName,
|
||||
Arguments: args,
|
||||
ExecutionID: executionID,
|
||||
IsError: isError,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"tool_name": toolName,
|
||||
"arguments": args,
|
||||
"execution_id": executionID,
|
||||
"is_error": isError,
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
|
||||
func agentOutputMap(node graphNode, response, mode string, mcpIDs []string) map[string]any {
|
||||
typed := AgentOutput{
|
||||
NodeOutputEnvelope: envelope("agent", node.ID, node.Type, "completed", response),
|
||||
Mode: mode,
|
||||
MCPExecutionIDs: mcpIDs,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"mode": mode, "mcp_execution_ids": mcpIDs, "typed": typed})
|
||||
}
|
||||
|
||||
func hitlOutputMap(node graphNode, status string, output string, prompt string, reviewer string, approved bool) map[string]any {
|
||||
typed := HITLOutput{
|
||||
NodeOutputEnvelope: envelope("hitl", node.ID, node.Type, status, output),
|
||||
Prompt: prompt,
|
||||
Reviewer: reviewer,
|
||||
Approved: approved,
|
||||
Mode: "interactive",
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"prompt": prompt,
|
||||
"reviewer": reviewer,
|
||||
"approved": approved,
|
||||
"mode": "interactive",
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -2411,13 +2411,21 @@ html[data-theme="dark"] .c2-session-main-empty__icon {
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--c2-shadow-lg);
|
||||
border: 1px solid var(--c2-border);
|
||||
animation: c2-slide-up 0.18s ease-out;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.c2-modal > #c2-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-height: inherit;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@keyframes c2-slide-up {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
@@ -2427,6 +2435,7 @@ html[data-theme="dark"] .c2-session-main-empty__icon {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 24px 28px 20px;
|
||||
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-body { padding: 24px 28px; }
|
||||
.c2-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.c2-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
padding: 16px 28px;
|
||||
border-top: 1px solid var(--c2-border);
|
||||
background: var(--c2-surface-alt);
|
||||
background: var(--c2-surface);
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
|
||||
+1177
-47
File diff suppressed because it is too large
Load Diff
@@ -597,6 +597,7 @@
|
||||
"knowledgeRetrievalTag": "Knowledge retrieval",
|
||||
"error": "Error",
|
||||
"streamNetworkErrorHint": "Connection lost ({{detail}}). A long task may still be running on the server; check running tasks at the top or refresh this conversation later.",
|
||||
"streamEndedWithoutDone": "The connection ended before a completion signal was received. The task may still be running on the server; check running tasks at the top or refresh this conversation.",
|
||||
"taskCancelled": "Task cancelled",
|
||||
"userInterruptContinueTitle": "⏸️ User interrupt & continue",
|
||||
"unknownTool": "Unknown tool",
|
||||
@@ -635,15 +636,15 @@
|
||||
"agentModeEinoSingle": "Eino single (ADK)",
|
||||
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner with MCP tools (/api/eino-agent)",
|
||||
"agentModeDeep": "Deep (DeepAgent)",
|
||||
"agentModeDeepHint": "Eino DeepAgent with task delegation to sub-agents",
|
||||
"agentModeDeepHint": "Eino DeepAgent for complex security testing, multi-stage task delegation, and synthesis",
|
||||
"agentModePlanExecuteLabel": "Plan-Execute",
|
||||
"agentModePlanExecuteHint": "Plan → execute → replan (single executor with tools)",
|
||||
"agentModeSupervisorLabel": "Supervisor",
|
||||
"agentModeSupervisorHint": "Supervisor coordinates via transfer to sub-agents",
|
||||
"agentModeSupervisorLabel": "Supervisor (Expert routing)",
|
||||
"agentModeSupervisorHint": "Expert-routing scenarios: route across multiple specialist sub-agents via transfer",
|
||||
"agentModeSingle": "Single-agent",
|
||||
"agentModeMulti": "Multi-agent",
|
||||
"agentModeSingleHint": "Eino ADK single-agent for chat and tool use",
|
||||
"agentModeMultiHint": "Eino prebuilt orchestration (deep / plan_execute / supervisor) for complex tasks",
|
||||
"agentModeMultiHint": "Eino prebuilt orchestration: Deep for complex tasks, Plan-Execute for structured loops, Supervisor for expert routing",
|
||||
"reasoningModeLabel": "Model reasoning",
|
||||
"reasoningEffortLabel": "Reasoning effort",
|
||||
"reasoningModeDefault": "Use system default",
|
||||
@@ -1243,6 +1244,17 @@
|
||||
"robots": {
|
||||
"title": "Bot settings",
|
||||
"description": "Configure WeChat (iLink), WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
|
||||
"managerTitle": "Bot management",
|
||||
"managerDesc": "Choose a bot type first, then configure only the fields that belong to that platform.",
|
||||
"newBot": "New bot",
|
||||
"configure": "Configure",
|
||||
"statusNotConfigured": "Not configured",
|
||||
"statusConfigured": "Configured",
|
||||
"statusEnabled": "Enabled",
|
||||
"emptyTitle": "Select a bot to configure",
|
||||
"emptyDesc": "Pick a platform above, or use New bot to choose by type.",
|
||||
"createTitle": "New bot",
|
||||
"createDesc": "Choose a bot type to open its dedicated setup. This version supports one built-in configuration per platform.",
|
||||
"wechat": {
|
||||
"title": "WeChat / iLink",
|
||||
"subtitle": "Bind personal WeChat via QR code and chat with CyberStrikeAI on your phone",
|
||||
@@ -1271,6 +1283,7 @@
|
||||
},
|
||||
"wecom": {
|
||||
"title": "WeCom",
|
||||
"subtitle": "HTTP callback mode for enterprise internal app bots",
|
||||
"enabled": "Enable WeCom bot",
|
||||
"token": "Token",
|
||||
"tokenPlaceholder": "Token",
|
||||
@@ -1285,6 +1298,7 @@
|
||||
},
|
||||
"dingtalk": {
|
||||
"title": "DingTalk",
|
||||
"subtitle": "Enterprise internal app bot using Stream connection",
|
||||
"enabled": "Enable DingTalk bot",
|
||||
"clientIdLabel": "Client ID (AppKey)",
|
||||
"clientIdPlaceholder": "DingTalk App Key",
|
||||
@@ -1294,6 +1308,7 @@
|
||||
},
|
||||
"lark": {
|
||||
"title": "Lark",
|
||||
"subtitle": "Enterprise app bot for direct chat and group mentions",
|
||||
"enabled": "Enable Lark bot",
|
||||
"appIdLabel": "App ID",
|
||||
"appIdPlaceholder": "Lark/Feishu App ID",
|
||||
@@ -1301,6 +1316,40 @@
|
||||
"appSecretPlaceholder": "Lark/Feishu App Secret",
|
||||
"verifyTokenLabel": "Verify Token (Optional)",
|
||||
"verifyTokenPlaceholder": "Event subscription Verification Token"
|
||||
},
|
||||
"telegram": {
|
||||
"title": "Telegram",
|
||||
"subtitle": "Bot API long polling; direct chat and group @",
|
||||
"enabled": "Enable Telegram bot",
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "Receives via getUpdates long poll; no public callback URL needed",
|
||||
"botUsername": "Bot Username (optional)",
|
||||
"allowGroup": "Allow group chat (respond to @ only)"
|
||||
},
|
||||
"slack": {
|
||||
"title": "Slack",
|
||||
"subtitle": "Socket Mode; no public callback URL needed",
|
||||
"enabled": "Enable Slack bot",
|
||||
"botToken": "Bot Token (xoxb-)",
|
||||
"appToken": "App-Level Token (xapp-)",
|
||||
"appTokenHint": "Requires connections:write scope for Socket Mode"
|
||||
},
|
||||
"discord": {
|
||||
"title": "Discord",
|
||||
"subtitle": "Gateway WebSocket; DMs and server @",
|
||||
"enabled": "Enable Discord bot",
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "Create bot in Developer Portal; enable Message Content Intent",
|
||||
"allowGuild": "Allow guild channels (respond to @ only)"
|
||||
},
|
||||
"qq": {
|
||||
"title": "QQ Bot",
|
||||
"subtitle": "QQ Open Platform WebSocket; C2C and group @",
|
||||
"enabled": "Enable QQ bot",
|
||||
"appId": "App ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretHint": "From QQ Bot Open Platform; use sandbox before going live",
|
||||
"sandbox": "Sandbox environment"
|
||||
}
|
||||
},
|
||||
"apply": {
|
||||
@@ -2160,10 +2209,10 @@
|
||||
"enableMultiAgent": "Enable Eino multi-agent",
|
||||
"enableMultiAgentHint": "After enabling, the chat page can use multi-agent mode; sub-agents are set in multi_agent.sub_agents or the agents/ directory. Orchestration is configured below.",
|
||||
"multiAgentOrchestration": "Multi-agent orchestration",
|
||||
"multiAgentOrchestrationHint": "deep = DeepAgent + task; plan_execute = plan / execute / replan (single executor tool loop); supervisor = supervisor + transfer. Takes effect after save & apply.",
|
||||
"multiAgentOrchestrationHint": "deep = DeepAgent + task; plan_execute = plan / execute / replan; supervisor = expert routing (transfer, specific scenarios). Takes effect after save & apply.",
|
||||
"multiAgentOrchDeep": "deep — DeepAgent (task sub-agents)",
|
||||
"multiAgentOrchPlanExecute": "plan_execute — plan / execute / replan",
|
||||
"multiAgentOrchSupervisor": "supervisor — supervisor + transfer",
|
||||
"multiAgentOrchSupervisor": "supervisor — expert routing (transfer)",
|
||||
"multiAgentPeLoop": "plan_execute outer loop limit",
|
||||
"multiAgentPeLoopPlaceholder": "0 uses Eino default (10)",
|
||||
"multiAgentPeLoopHint": "Only for plan_execute; max execute↔replan rounds.",
|
||||
@@ -2516,6 +2565,8 @@
|
||||
},
|
||||
"settingsRobotsExtra": {
|
||||
"botCommandsTitle": "Bot command instructions",
|
||||
"botCommandsEntryDesc": "View the Chinese and English commands available in robot chats, including common conversation, role, and project actions.",
|
||||
"viewAllCommands": "View all commands",
|
||||
"botCommandsDesc": "You can send the following commands in chat (Chinese and English supported):",
|
||||
"botCmdCategoryGeneral": "General",
|
||||
"botCmdCategoryConversation": "Conversation",
|
||||
@@ -2937,12 +2988,21 @@
|
||||
"metaName": "Name",
|
||||
"metaDescription": "Description",
|
||||
"metaEnabled": "Enabled",
|
||||
"metaIdHint": "Cannot be changed after creation; used for API and role binding",
|
||||
"metaModalTitle": "Workflow details",
|
||||
"editMeta": "Edit",
|
||||
"untitled": "Untitled workflow",
|
||||
"toggleEnabled": "Enable/disable",
|
||||
"metaEnabledHint": "Disabled workflows cannot be auto-triggered by roles",
|
||||
"enabledUpdated": "Enabled status updated",
|
||||
"enabledUpdateFailed": "Failed to update enabled status",
|
||||
"namePlaceholder": "Basic Web scan",
|
||||
"descriptionPlaceholder": "Optional",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting",
|
||||
"deleteSelected": "Delete selected",
|
||||
"autoLayout": "Auto layout",
|
||||
"dryRun": "Dry run",
|
||||
"canvasEmpty": "Drag nodes from the left onto the canvas, or click node buttons to add quickly",
|
||||
"properties": "Properties",
|
||||
"nodeProperties": "Node properties",
|
||||
@@ -2987,10 +3047,18 @@
|
||||
"outputKey": "Output variable name",
|
||||
"conditionExpression": "Condition expression",
|
||||
"conditionHint": "The node computes matched (true/false); outgoing edges define branches: first edge is \"Yes\", second is \"No\". You can also write <code>{{previous.matched}} == \"true\"</code> on the edge.",
|
||||
"conditionGuideTitle": "Available syntax",
|
||||
"conditionGuideVars": "Variables: {{previous.output}}, {{previous.matched}}, {{inputs.message}}, {{outputs.name}}, {{nodeId.output}}",
|
||||
"conditionGuideOps": "Operators: ==, !=, >, >=, <, <=, contains, matches, &&, ||",
|
||||
"conditionGuideJson": "Nested fields: jsonpath(value, \"$.status\") or jq(value, \".severity\")",
|
||||
"edgeCondition": "Edge condition",
|
||||
"edgeBranch": "Condition branch",
|
||||
"selectBranch": "Select",
|
||||
"edgeConditionHintCondition": "{{previous.matched}} == \"true\" (Yes) or == \"false\" (No)",
|
||||
"edgeConditionHintExample": "e.g. {{previous.output}} == \"ok\"",
|
||||
"edgeBranchHint": "The first edge from a condition node defaults to the \"Yes\" branch, the second to \"No\"; you can customize conditions here.",
|
||||
"joinStrategy": "Join strategy",
|
||||
"joinStrategyHint": "When multiple upstream nodes enter this node, choose how to build previous: merge all, last by canvas order, first non-empty, or fail fast.",
|
||||
"hitlPrompt": "Approval prompt",
|
||||
"hitlPromptPlaceholder": "Approve to continue",
|
||||
"hitlReviewer": "Reviewer",
|
||||
@@ -3009,6 +3077,11 @@
|
||||
"nodeFallback": "Node {{n}}",
|
||||
"loadFailed": "Failed to load workflows",
|
||||
"saveFailed": "Failed to save workflow",
|
||||
"dryRunPrompt": "Input message for dry-run",
|
||||
"dryRunFailed": "Dry-run failed",
|
||||
"dryRunDone": "Dry-run completed",
|
||||
"dryRunTrace": "Dry-run trace",
|
||||
"dryRunNoTrace": "No trace",
|
||||
"deleteFailed": "Failed to delete workflow",
|
||||
"saved": "Workflow saved",
|
||||
"deleted": "Workflow deleted",
|
||||
@@ -3030,6 +3103,13 @@
|
||||
"conditionNeedsExpr": "Condition node {{label}} requires a condition expression",
|
||||
"conditionNeedsOutEdge": "Condition node {{label}} needs at least one outgoing edge (Yes/No branch)",
|
||||
"conditionTooManyEdges": "Condition node {{label}} should have at most two outgoing edges (Yes/No); configure edge conditions for a third and beyond",
|
||||
"conditionBranchLabel": "Condition node {{label}} outgoing edges must be marked Yes/No",
|
||||
"conditionBranchDuplicate": "Condition node {{label}} has duplicate branches",
|
||||
"nodeNeedsIncoming": "Node {{label}} needs an incoming edge",
|
||||
"nodeNeedsOutgoing": "Node {{label}} needs an outgoing edge",
|
||||
"nodeUnreachable": "Node {{label}} cannot be reached from Start",
|
||||
"graphCycle": "Workflow contains a cycle; keep it as a DAG",
|
||||
"serverFailed": "Server graph validation failed",
|
||||
"outputNeedsKey": "Output node {{label}} requires an output variable name"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -585,6 +585,7 @@
|
||||
"knowledgeRetrievalTag": "知识检索",
|
||||
"error": "错误",
|
||||
"streamNetworkErrorHint": "连接已中断({{detail}})。长时间任务可能仍在后端执行,请查看顶部「运行中」任务或稍后刷新本对话。",
|
||||
"streamEndedWithoutDone": "连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部「运行中」任务或刷新当前对话。",
|
||||
"taskCancelled": "任务已取消",
|
||||
"userInterruptContinueTitle": "⏸️ 用户中断并继续",
|
||||
"unknownTool": "未知工具",
|
||||
@@ -623,15 +624,15 @@
|
||||
"agentModeEinoSingle": "Eino 单代理(ADK)",
|
||||
"agentModeEinoSingleHint": "Eino ChatModelAgent + Runner,MCP 工具(/api/eino-agent)",
|
||||
"agentModeDeep": "Deep(DeepAgent)",
|
||||
"agentModeDeepHint": "Eino DeepAgent,task 调度子代理",
|
||||
"agentModeDeepHint": "Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总",
|
||||
"agentModePlanExecuteLabel": "Plan-Execute",
|
||||
"agentModePlanExecuteHint": "规划 → 执行 → 重规划(单执行器带工具)",
|
||||
"agentModeSupervisorLabel": "Supervisor",
|
||||
"agentModeSupervisorHint": "监督者协调,transfer 委派子代理",
|
||||
"agentModeSupervisorLabel": "Supervisor(专家路由)",
|
||||
"agentModeSupervisorHint": "专家路由场景:监督者通过 transfer 动态分派多个专业子代理",
|
||||
"agentModeSingle": "单代理",
|
||||
"agentModeMulti": "多代理",
|
||||
"agentModeSingleHint": "Eino ADK 单代理,适合常规对话与工具调用",
|
||||
"agentModeMultiHint": "Eino 预置编排(deep / plan_execute / supervisor),适合复杂任务",
|
||||
"agentModeMultiHint": "Eino 预置编排:Deep 适合复杂任务,Plan-Execute 适合结构化闭环,Supervisor 适合专家路由",
|
||||
"reasoningModeLabel": "模型推理",
|
||||
"reasoningEffortLabel": "推理强度",
|
||||
"reasoningModeDefault": "跟随系统",
|
||||
@@ -1231,6 +1232,17 @@
|
||||
"robots": {
|
||||
"title": "机器人设置",
|
||||
"description": "配置微信、企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
|
||||
"managerTitle": "机器人管理",
|
||||
"managerDesc": "先选择机器人类型,再进入对应配置;已配置的平台会显示连接状态。",
|
||||
"newBot": "新建机器人",
|
||||
"configure": "配置",
|
||||
"statusNotConfigured": "未配置",
|
||||
"statusConfigured": "已配置",
|
||||
"statusEnabled": "已启用",
|
||||
"emptyTitle": "选择一个机器人开始配置",
|
||||
"emptyDesc": "从上方列表点击已有平台,或点击“新建机器人”按类型创建。",
|
||||
"createTitle": "新建机器人",
|
||||
"createDesc": "选择机器人类型后进入专属配置。当前版本每个平台支持一个内置机器人配置。",
|
||||
"wechat": {
|
||||
"title": "微信 / iLink",
|
||||
"subtitle": "扫码绑定个人微信,在手机端直接与 CyberStrikeAI 对话",
|
||||
@@ -1259,6 +1271,7 @@
|
||||
},
|
||||
"wecom": {
|
||||
"title": "企业微信",
|
||||
"subtitle": "HTTP 回调模式,适合企业内部应用机器人",
|
||||
"enabled": "启用企业微信机器人",
|
||||
"token": "Token",
|
||||
"tokenPlaceholder": "Token",
|
||||
@@ -1273,6 +1286,7 @@
|
||||
},
|
||||
"dingtalk": {
|
||||
"title": "钉钉",
|
||||
"subtitle": "企业内部应用机器人,使用 Stream 长连接",
|
||||
"enabled": "启用钉钉机器人",
|
||||
"clientIdLabel": "Client ID (AppKey)",
|
||||
"clientIdPlaceholder": "钉钉应用 AppKey",
|
||||
@@ -1282,6 +1296,7 @@
|
||||
},
|
||||
"lark": {
|
||||
"title": "飞书 (Lark)",
|
||||
"subtitle": "飞书企业应用机器人,支持单聊与群聊 @",
|
||||
"enabled": "启用飞书机器人",
|
||||
"appIdLabel": "App ID",
|
||||
"appIdPlaceholder": "飞书应用 App ID",
|
||||
@@ -1289,6 +1304,40 @@
|
||||
"appSecretPlaceholder": "飞书应用 App Secret",
|
||||
"verifyTokenLabel": "Verify Token(可选)",
|
||||
"verifyTokenPlaceholder": "事件订阅 Verification Token"
|
||||
},
|
||||
"telegram": {
|
||||
"title": "Telegram",
|
||||
"subtitle": "Bot API 长轮询,私聊与群聊 @",
|
||||
"enabled": "启用 Telegram 机器人",
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "通过 getUpdates 长轮询收消息,无需公网回调",
|
||||
"botUsername": "Bot Username(可选)",
|
||||
"allowGroup": "允许群聊(仅响应 @ 机器人)"
|
||||
},
|
||||
"slack": {
|
||||
"title": "Slack",
|
||||
"subtitle": "Socket Mode,无需公网回调",
|
||||
"enabled": "启用 Slack 机器人",
|
||||
"botToken": "Bot Token (xoxb-)",
|
||||
"appToken": "App-Level Token (xapp-)",
|
||||
"appTokenHint": "需 connections:write 权限,用于 Socket Mode"
|
||||
},
|
||||
"discord": {
|
||||
"title": "Discord",
|
||||
"subtitle": "Gateway WebSocket,私聊与服务器 @",
|
||||
"enabled": "启用 Discord 机器人",
|
||||
"botToken": "Bot Token",
|
||||
"botTokenHint": "开发者门户创建 Bot,开启 Message Content Intent",
|
||||
"allowGuild": "允许服务器频道(仅响应 @ 机器人)"
|
||||
},
|
||||
"qq": {
|
||||
"title": "QQ 机器人",
|
||||
"subtitle": "QQ 开放平台 WebSocket,C2C 与群 @",
|
||||
"enabled": "启用 QQ 机器人",
|
||||
"appId": "App ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"secretHint": "从 QQ 机器人开放平台获取;上线前可勾选沙箱",
|
||||
"sandbox": "沙箱环境"
|
||||
}
|
||||
},
|
||||
"apply": {
|
||||
@@ -2148,10 +2197,10 @@
|
||||
"enableMultiAgent": "启用 Eino 多代理",
|
||||
"enableMultiAgentHint": "开启后对话页可选「多代理」模式;子代理在 multi_agent.sub_agents 或 agents 目录配置;编排方式见下方「预置编排」。",
|
||||
"multiAgentOrchestration": "多代理预置编排",
|
||||
"multiAgentOrchestrationHint": "deep=DeepAgent+task;plan_execute=规划/执行/重规划(单执行器工具链);supervisor=监督者+transfer。保存并应用后生效。",
|
||||
"multiAgentOrchestrationHint": "deep=DeepAgent+task;plan_execute=规划/执行/重规划;supervisor=专家路由(transfer,特定场景)。保存并应用后生效。",
|
||||
"multiAgentOrchDeep": "deep — DeepAgent(task 子代理)",
|
||||
"multiAgentOrchPlanExecute": "plan_execute — 规划 / 执行 / 重规划",
|
||||
"multiAgentOrchSupervisor": "supervisor — 监督者 + transfer",
|
||||
"multiAgentOrchSupervisor": "supervisor — 专家路由(transfer)",
|
||||
"multiAgentPeLoop": "plan_execute 外层循环上限",
|
||||
"multiAgentPeLoopPlaceholder": "0 表示 Eino 默认 10",
|
||||
"multiAgentPeLoopHint": "仅 plan_execute 有效;execute 与 replan 之间的最大轮次。",
|
||||
@@ -2504,6 +2553,8 @@
|
||||
},
|
||||
"settingsRobotsExtra": {
|
||||
"botCommandsTitle": "机器人命令说明",
|
||||
"botCommandsEntryDesc": "查看机器人对话中可用的中英文命令,包括对话、角色、项目等常用操作。",
|
||||
"viewAllCommands": "查看全部命令",
|
||||
"botCommandsDesc": "在对话中可发送以下命令(支持中英文):",
|
||||
"botCmdCategoryGeneral": "通用",
|
||||
"botCmdCategoryConversation": "对话",
|
||||
@@ -2925,12 +2976,21 @@
|
||||
"metaName": "名称",
|
||||
"metaDescription": "描述",
|
||||
"metaEnabled": "启用",
|
||||
"metaIdHint": "创建后不可修改,用于 API 与角色绑定",
|
||||
"metaModalTitle": "流程信息",
|
||||
"editMeta": "编辑",
|
||||
"untitled": "未命名流程",
|
||||
"toggleEnabled": "启用/禁用",
|
||||
"metaEnabledHint": "禁用后无法被角色自动触发",
|
||||
"enabledUpdated": "启用状态已更新",
|
||||
"enabledUpdateFailed": "更新启用状态失败",
|
||||
"namePlaceholder": "基础 Web 扫描",
|
||||
"descriptionPlaceholder": "可选",
|
||||
"connect": "连线",
|
||||
"connecting": "连线中",
|
||||
"deleteSelected": "删除选中",
|
||||
"autoLayout": "自动布局",
|
||||
"dryRun": "试运行",
|
||||
"canvasEmpty": "从左侧拖拽节点到画布,或点击节点按钮快速添加",
|
||||
"properties": "属性",
|
||||
"nodeProperties": "节点属性",
|
||||
@@ -2975,10 +3035,18 @@
|
||||
"outputKey": "输出变量名",
|
||||
"conditionExpression": "条件表达式",
|
||||
"conditionHint": "节点会计算 matched(true/false),由出边决定分支:第一条线为「是」,第二条为「否」;也可在连线上写 <code>{{previous.matched}} == \"true\"</code>。",
|
||||
"conditionGuideTitle": "可用语法",
|
||||
"conditionGuideVars": "变量:{{previous.output}}、{{previous.matched}}、{{inputs.message}}、{{outputs.变量名}}、{{节点ID.output}}",
|
||||
"conditionGuideOps": "操作符:==、!=、>、>=、<、<=、contains、matches、&&、||",
|
||||
"conditionGuideJson": "嵌套字段:jsonpath(value, \"$.status\") 或 jq(value, \".severity\")",
|
||||
"edgeCondition": "连线条件",
|
||||
"edgeBranch": "条件分支",
|
||||
"selectBranch": "请选择",
|
||||
"edgeConditionHintCondition": "{{previous.matched}} == \"true\"(是)或 == \"false\"(否)",
|
||||
"edgeConditionHintExample": "例如: {{previous.output}} == \"ok\"",
|
||||
"edgeBranchHint": "从条件节点连出的第一条线默认为「是」分支,第二条为「否」分支;也可在此自定义条件。",
|
||||
"joinStrategy": "汇聚策略",
|
||||
"joinStrategyHint": "当多个上游同时进入该节点时,决定如何生成 previous:合并全部、按画布顺序取最后、取第一个非空或上游失败即中止。",
|
||||
"hitlPrompt": "审批提示",
|
||||
"hitlPromptPlaceholder": "请审批是否继续",
|
||||
"hitlReviewer": "审批方",
|
||||
@@ -2997,6 +3065,11 @@
|
||||
"nodeFallback": "节点 {{n}}",
|
||||
"loadFailed": "加载工作流失败",
|
||||
"saveFailed": "保存工作流失败",
|
||||
"dryRunPrompt": "输入试运行消息",
|
||||
"dryRunFailed": "试运行失败",
|
||||
"dryRunDone": "试运行完成",
|
||||
"dryRunTrace": "试运行轨迹",
|
||||
"dryRunNoTrace": "暂无轨迹",
|
||||
"deleteFailed": "删除工作流失败",
|
||||
"saved": "工作流已保存",
|
||||
"deleted": "工作流已删除",
|
||||
@@ -3018,6 +3091,13 @@
|
||||
"conditionNeedsExpr": "条件节点 {{label}} 需要条件表达式",
|
||||
"conditionNeedsOutEdge": "条件节点 {{label}} 至少需要一条出边(是/否分支)",
|
||||
"conditionTooManyEdges": "条件节点 {{label}} 建议最多两条出边(是/否);第三条及以后需配置连线条件",
|
||||
"conditionBranchLabel": "条件节点 {{label}} 的出边必须标记为是/否",
|
||||
"conditionBranchDuplicate": "条件节点 {{label}} 存在重复分支",
|
||||
"nodeNeedsIncoming": "节点 {{label}} 需要入边",
|
||||
"nodeNeedsOutgoing": "节点 {{label}} 需要出边",
|
||||
"nodeUnreachable": "节点 {{label}} 无法从开始节点到达",
|
||||
"graphCycle": "工作流存在环路,请保持 DAG",
|
||||
"serverFailed": "后端图校验失败",
|
||||
"outputNeedsKey": "输出节点 {{label}} 需要输出变量名"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1105,7 +1105,11 @@ async function sendMessage() {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let streamSawDone = false;
|
||||
const dispatchStreamEvent = function (eventData) {
|
||||
if (eventData && eventData.type === 'done') {
|
||||
streamSawDone = true;
|
||||
}
|
||||
handleStreamEvent(eventData, progressElement, progressId,
|
||||
() => assistantMessageId, (id) => { assistantMessageId = id; },
|
||||
() => mcpExecutionIds, (ids) => { mcpExecutionIds = ids; });
|
||||
@@ -1142,6 +1146,23 @@ async function sendMessage() {
|
||||
const lines = buffer.split('\n');
|
||||
await processSseLines(lines, dispatchStreamEvent);
|
||||
}
|
||||
if (!streamSawDone) {
|
||||
if (typeof loadActiveTasks === 'function') {
|
||||
loadActiveTasks();
|
||||
}
|
||||
const convId = currentConversationId || (body && body.conversationId) || null;
|
||||
let attached = false;
|
||||
if (convId && typeof window.attachRunningTaskEventStream === 'function') {
|
||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||
attached = await window.attachRunningTaskEventStream(convId).catch(() => false);
|
||||
}
|
||||
if (!attached) {
|
||||
const hint = typeof window.t === 'function'
|
||||
? window.t('chat.streamEndedWithoutDone')
|
||||
: '连接提前结束,未收到任务完成信号。任务可能仍在后端执行,请查看顶部运行中任务或刷新当前对话。';
|
||||
addMessage('system', hint);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
window.__csAgentLiveStream = { active: false, conversationId: null, progressId: null };
|
||||
if (window.CyberStrikeChatScroll) {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
'agent-md-modal',
|
||||
'batch-manage-modal',
|
||||
'create-group-modal',
|
||||
'workflow-meta-modal',
|
||||
'login-overlay',
|
||||
]);
|
||||
|
||||
|
||||
+75
-23
@@ -3331,7 +3331,11 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let replaySawDone = false;
|
||||
const dispatchTaskEvent = function (eventData) {
|
||||
if (eventData && eventData.type === 'done') {
|
||||
replaySawDone = true;
|
||||
}
|
||||
handleStreamEvent(eventData, null, progressId, getAssistantIdFn, setAssistantIdFn, function () { return mcpIds; }, function (ids) { mcpIds = mergeMcpExecutionIDLists(mcpIds, ids || []); });
|
||||
};
|
||||
while (true) {
|
||||
@@ -3351,14 +3355,14 @@ async function attachRunningTaskEventStream(conversationId) {
|
||||
if (window.csTaskReplay && window.csTaskReplay.progressId === progressId) {
|
||||
clearCsTaskReplay();
|
||||
}
|
||||
if (progressTaskState.has(progressId)) {
|
||||
if (replaySawDone && progressTaskState.has(progressId)) {
|
||||
finalizeProgressTask(progressId, typeof window.t === 'function' ? window.t('tasks.statusCompleted') : '已完成');
|
||||
}
|
||||
if (window.CyberStrikeChatScroll && typeof window.CyberStrikeChatScroll.onTaskEventStreamEnd === 'function') {
|
||||
window.CyberStrikeChatScroll.onTaskEventStreamEnd();
|
||||
}
|
||||
if (typeof loadActiveTasks === 'function') loadActiveTasks();
|
||||
if (typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||
if (replaySawDone && typeof window.loadConversation === 'function' && window.currentConversationId === conversationId) {
|
||||
await window.loadConversation(conversationId);
|
||||
}
|
||||
return true;
|
||||
@@ -4373,6 +4377,8 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
const maxVal = Math.max(1, ...points.map((p) => p.total || 0));
|
||||
const hasFailed = points.some((p) => (p.failed || 0) > 0);
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const barGap = points.length > 48 ? 1 : 2;
|
||||
const barW = Math.max(1.6, Math.min(8, (plotW / Math.max(1, points.length)) - barGap));
|
||||
|
||||
const coords = points.map((p, i) => {
|
||||
const x = padL + (points.length <= 1 ? plotW / 2 : (i / (points.length - 1)) * plotW);
|
||||
@@ -4429,6 +4435,21 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
data-failed="${c.p.failed || 0}" />`;
|
||||
}).join('');
|
||||
|
||||
const bars = coords.map((c) => {
|
||||
const total = c.p.total || 0;
|
||||
const failed = c.p.failed || 0;
|
||||
const h = total > 0 ? Math.max(3, (total / maxVal) * plotH) : 1;
|
||||
const y = baseY - h;
|
||||
const failedH = failed > 0 ? Math.max(2, (failed / maxVal) * plotH) : 0;
|
||||
const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale);
|
||||
return `<g class="mcp-stats-timeline-bar-group">
|
||||
<rect class="mcp-stats-timeline-bar${total > 0 ? ' is-active' : ''}" x="${(c.x - barW / 2).toFixed(2)}" y="${y.toFixed(2)}" width="${barW.toFixed(2)}" height="${h.toFixed(2)}" rx="1.6"
|
||||
data-time="${escapeHtml(tipTime)}" data-total="${total}" data-failed="${failed}" />
|
||||
${failedH > 0 ? `<rect class="mcp-stats-timeline-bar-fail" x="${(c.x - barW / 2).toFixed(2)}" y="${(baseY - failedH).toFixed(2)}" width="${barW.toFixed(2)}" height="${failedH.toFixed(2)}" rx="1.6"
|
||||
data-time="${escapeHtml(tipTime)}" data-total="${total}" data-failed="${failed}" />` : ''}
|
||||
</g>`;
|
||||
}).join('');
|
||||
|
||||
const peakC = coords[peakIdx];
|
||||
const peakMarker = (peakC.p.total || 0) > 0
|
||||
? `<circle class="mcp-stats-timeline-peak-glow" cx="${peakC.x.toFixed(2)}" cy="${peakC.y.toFixed(2)}" r="5" />`
|
||||
@@ -4449,6 +4470,7 @@ function buildMcpTimelineSvg(points, rangeKey) {
|
||||
</defs>
|
||||
${yLines}
|
||||
<path class="mcp-stats-timeline-area" d="${areaPath}" fill="url(#mcpTimelineAreaFill)" />
|
||||
${bars}
|
||||
${peakMarker}
|
||||
<path class="mcp-stats-timeline-line" d="${linePath}" stroke="url(#mcpTimelineLineStroke)" />
|
||||
${hasFailed ? `<path class="mcp-stats-timeline-line mcp-stats-timeline-line--fail" d="${failPath}" />` : ''}
|
||||
@@ -4480,14 +4502,17 @@ function bindMcpStatsTimelineEvents() {
|
||||
}
|
||||
|
||||
root.addEventListener('mousemove', function (e) {
|
||||
const dot = e.target.closest('.mcp-stats-timeline-dot');
|
||||
const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail');
|
||||
if (!dot || !mcpTimelineTooltipEl) {
|
||||
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
|
||||
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
|
||||
mcpTimelineTooltipEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
|
||||
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
|
||||
dot.classList.add('is-active');
|
||||
dot.classList.add('is-hover');
|
||||
const time = dot.getAttribute('data-time') || '';
|
||||
const total = dot.getAttribute('data-total') || '0';
|
||||
const failed = dot.getAttribute('data-failed') || '0';
|
||||
@@ -4503,6 +4528,7 @@ function bindMcpStatsTimelineEvents() {
|
||||
if (!e.target.closest || !e.target.closest('.mcp-stats-combined__timeline, .mcp-stats-timeline')) return;
|
||||
if (e.relatedTarget && root.contains(e.relatedTarget)) return;
|
||||
root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active'));
|
||||
root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover'));
|
||||
if (mcpTimelineTooltipEl) mcpTimelineTooltipEl.style.display = 'none';
|
||||
});
|
||||
|
||||
@@ -4564,6 +4590,37 @@ function buildTimelineSparseHint(points, timeline) {
|
||||
|| `该时段多数时间为 0,峰值 ${peak} 次出现在 ${peakTime}`;
|
||||
}
|
||||
|
||||
function renderMcpTimelineActiveMoments(points, rangeKey) {
|
||||
if (!Array.isArray(points) || points.length === 0) return '';
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const active = points
|
||||
.map((p, i) => ({ ...p, i }))
|
||||
.filter((p) => (p.total || 0) > 0)
|
||||
.sort((a, b) => (b.total || 0) - (a.total || 0) || b.i - a.i)
|
||||
const shown = active.slice(0, 4);
|
||||
const hiddenCount = Math.max(0, active.length - shown.length);
|
||||
if (!active.length) return '';
|
||||
const label = mcpMonitorT('timelineActiveMoments') || monitorFallback('活跃时段', 'Active moments');
|
||||
const moreLabel = mcpMonitorT('timelineMoreMoments', { n: hiddenCount }) || `+${hiddenCount}`;
|
||||
const chips = shown.map((p) => {
|
||||
const time = formatMcpTimelineLabel(p.t, rangeKey, locale);
|
||||
const failed = p.failed || 0;
|
||||
const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`;
|
||||
return `<span class="mcp-stats-timeline-moment" title="${escapeHtml(time)}">
|
||||
<span class="mcp-stats-timeline-moment__time">${escapeHtml(time)}</span>
|
||||
<span class="mcp-stats-timeline-moment__count">${p.total || 0}</span>
|
||||
${failed > 0 ? `<span class="mcp-stats-timeline-moment__fail">${escapeHtml(failedLabel)}</span>` : ''}
|
||||
</span>`;
|
||||
}).join('');
|
||||
const moreChip = hiddenCount > 0
|
||||
? `<span class="mcp-stats-timeline-moment mcp-stats-timeline-moment--more" title="${escapeHtml(mcpMonitorT('timelineMoreMomentsTitle', { n: hiddenCount }) || `还有 ${hiddenCount} 个活跃时段`)}">${escapeHtml(moreLabel)}</span>`
|
||||
: '';
|
||||
return `<div class="mcp-stats-timeline-moments">
|
||||
<span class="mcp-stats-timeline-moments__label">${escapeHtml(label)}</span>
|
||||
<div class="mcp-stats-timeline-moments__list">${chips}${moreChip}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function setMcpMonitorTimelineRange(range) {
|
||||
if (!MCP_TIMELINE_RANGES.includes(range)) return;
|
||||
localStorage.setItem('mcpMonitorTimelineRange', range);
|
||||
@@ -4639,12 +4696,14 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi
|
||||
const failLegend = mcpMonitorT('timelineFailedLegend') || '失败';
|
||||
const hasFailed = points.some((p) => (p.failed || 0) > 0);
|
||||
const sparseHint = buildTimelineSparseHint(points, timeline);
|
||||
const momentsHtml = renderMcpTimelineActiveMoments(points, rangeKey);
|
||||
const sparseHtml = sparseHint
|
||||
? `<p class="mcp-stats-timeline__sparse-hint">${escapeHtml(sparseHint)}</p>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<p class="mcp-stats-timeline__inline-meta">${escapeHtml(hint)} · ${escapeHtml(summaryText)}</p>
|
||||
${momentsHtml}
|
||||
<div class="mcp-stats-timeline__chart-wrap">${chartSvg}</div>
|
||||
${sparseHtml}
|
||||
<div class="mcp-stats-timeline__legend">
|
||||
@@ -5290,10 +5349,6 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
const caption = mcpMonitorT('rankingSummary', { n: MCP_STATS_TOP_N, pct: topNSharePct, total: totals.total })
|
||||
|| `Top ${MCP_STATS_TOP_N} 占 ${topNSharePct}% · 共 ${totals.total} 次`;
|
||||
const unknownToolLabel = mcpMonitorT('unknownTool') || '未知工具';
|
||||
const colTool = mcpMonitorT('columnTool') || '工具';
|
||||
const colCalls = mcpMonitorT('columnCalls') || '调用';
|
||||
const colShare = mcpMonitorT('columnShare') || '占比';
|
||||
const colRate = mcpMonitorT('columnSuccessRate') || '成功率';
|
||||
const distAria = mcpMonitorT('distTitle') || '调用分布';
|
||||
|
||||
const stackedHtml = segments.map((s) => {
|
||||
@@ -5332,20 +5387,27 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
const failNote = failed > 0
|
||||
? `<span class="mcp-stats-tool-item__fail">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>`
|
||||
: '';
|
||||
const successLabel = mcpMonitorT('successCount', { n: success }) || `成功 ${success}`;
|
||||
const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`;
|
||||
return `<li class="mcp-stats-tool-item${isActive ? ' is-active' : ''}"
|
||||
data-tool-name="${escapeHtml(rawName)}" tabindex="0" role="button"
|
||||
aria-label="${escapeHtml(rowAria)}" aria-pressed="${isActive ? 'true' : 'false'}">
|
||||
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
|
||||
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
|
||||
<div class="mcp-stats-tool-item__body">
|
||||
<div class="mcp-stats-tool-item__top">
|
||||
<span class="mcp-stats-tool-item__rank mcp-stats-rank${rankClass}">${index + 1}</span>
|
||||
<span class="mcp-stats-tool-item__dot" style="background:${color}" aria-hidden="true"></span>
|
||||
<span class="mcp-stats-tool-item__name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||
<span class="mcp-stats-tool-item__share">${sharePct}%</span>
|
||||
</div>
|
||||
<div class="mcp-stats-tool-item__middle">
|
||||
<strong class="mcp-stats-tool-item__calls">${total}</strong>
|
||||
<span class="mcp-stats-tool-item__calls-label">${escapeHtml(mcpMonitorT('columnCalls') || '调用')}</span>
|
||||
<span class="mcp-stats-tool-item__track" aria-hidden="true">
|
||||
<span class="mcp-stats-tool-item__fill" style="width:${barPct}%;background:${color}"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mcp-stats-tool-item__metrics">
|
||||
<span class="mcp-stats-tool-item__share">${sharePct}%</span>
|
||||
<span class="mcp-stats-tool-item__calls">${total}</span>
|
||||
<div class="mcp-stats-tool-item__bottom">
|
||||
<span class="mcp-stats-tool-item__pill is-success">${escapeHtml(successLabel)}</span>
|
||||
<span class="mcp-stats-tool-item__pill${failed > 0 ? ' is-danger' : ''}">${escapeHtml(failedLabel)}</span>
|
||||
<span class="mcp-stats-tool-item__rate ${rateClass}">${toolRate}%${failNote}</span>
|
||||
</div>
|
||||
</li>`;
|
||||
@@ -5360,16 +5422,6 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
|
||||
${escapeHtml(caption)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mcp-stats-tools-panel__list-head" aria-hidden="true">
|
||||
<span>#</span>
|
||||
<span></span>
|
||||
<span>${escapeHtml(colTool)}</span>
|
||||
<span class="mcp-stats-tool-item__metrics-head">
|
||||
<span>${escapeHtml(colShare)}</span>
|
||||
<span>${escapeHtml(colCalls)}</span>
|
||||
<span>${escapeHtml(colRate)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ol class="mcp-stats-tools-panel__list">${listHtml}</ol>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -2152,13 +2152,17 @@ function appendChatProjectPanelItem(list, project, selectedId, onSelect, tFn) {
|
||||
const t = tFn || tp;
|
||||
const isNone = !project.id;
|
||||
const isSelected = isNone ? !selectedId : selectedId === project.id;
|
||||
const fullDesc = isNone
|
||||
? (project.description || '')
|
||||
: (project.description || '').trim() || t('projects.sharedFactBoard');
|
||||
const desc = isNone
|
||||
? (project.description || '')
|
||||
: (project.description || '').trim().slice(0, 80) || t('projects.sharedFactBoard');
|
||||
: fullDesc.slice(0, 80);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||
btn.setAttribute('role', 'option');
|
||||
btn.setAttribute('data-selection-detail', fullDesc);
|
||||
btn.onclick = () => onSelect(project.id || '');
|
||||
btn.innerHTML = `
|
||||
<div class="role-selection-item-icon-main">${isNone ? '—' : '📁'}</div>
|
||||
|
||||
@@ -24,6 +24,79 @@ function rolePlainDescription(role) {
|
||||
if (raw === 'roles.noDescription' || raw === 'roles.noDescriptionShort') return '';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function initSelectionDetailTooltip() {
|
||||
if (window.__selectionDetailTooltipReady) return;
|
||||
window.__selectionDetailTooltipReady = true;
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'selection-detail-tooltip';
|
||||
tooltip.setAttribute('role', 'tooltip');
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
let activeEl = null;
|
||||
|
||||
function hideTooltip() {
|
||||
activeEl = null;
|
||||
tooltip.classList.remove('visible', 'placement-left');
|
||||
}
|
||||
|
||||
function positionTooltip(el) {
|
||||
const text = el && el.getAttribute('data-selection-detail');
|
||||
if (!text) {
|
||||
hideTooltip();
|
||||
return;
|
||||
}
|
||||
activeEl = el;
|
||||
tooltip.textContent = text;
|
||||
tooltip.classList.add('visible');
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const tipRect = tooltip.getBoundingClientRect();
|
||||
const gap = 12;
|
||||
const margin = 12;
|
||||
const rightSpace = window.innerWidth - rect.right - gap - margin;
|
||||
const placeLeft = rightSpace < tipRect.width && rect.left > tipRect.width + gap + margin;
|
||||
const left = placeLeft ? rect.left - tipRect.width - gap : rect.right + gap;
|
||||
const top = Math.max(margin, Math.min(rect.top + rect.height / 2 - tipRect.height / 2, window.innerHeight - tipRect.height - margin));
|
||||
|
||||
tooltip.classList.toggle('placement-left', placeLeft);
|
||||
tooltip.style.left = left + 'px';
|
||||
tooltip.style.top = top + 'px';
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', function (event) {
|
||||
const el = event.target && event.target.closest && event.target.closest('[data-selection-detail]');
|
||||
if (!el || (event.relatedTarget && el.contains(event.relatedTarget))) return;
|
||||
positionTooltip(el);
|
||||
});
|
||||
document.addEventListener('mouseout', function (event) {
|
||||
const el = event.target && event.target.closest && event.target.closest('[data-selection-detail]');
|
||||
if (!el || (event.relatedTarget && el.contains(event.relatedTarget))) return;
|
||||
hideTooltip();
|
||||
});
|
||||
document.addEventListener('focusin', function (event) {
|
||||
const el = event.target && event.target.closest && event.target.closest('[data-selection-detail]');
|
||||
if (el) positionTooltip(el);
|
||||
});
|
||||
document.addEventListener('focusout', function (event) {
|
||||
const el = event.target && event.target.closest && event.target.closest('[data-selection-detail]');
|
||||
if (el) hideTooltip();
|
||||
});
|
||||
window.addEventListener('scroll', hideTooltip, true);
|
||||
window.addEventListener('resize', function () {
|
||||
if (activeEl) positionTooltip(activeEl);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initSelectionDetailTooltip);
|
||||
} else {
|
||||
initSelectionDetailTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
let currentRole = localStorage.getItem('currentRole') || '';
|
||||
let roles = [];
|
||||
let rolesSearchKeyword = ''; // 角色搜索关键词
|
||||
@@ -209,10 +282,18 @@ function renderRoleSelectionSidebar() {
|
||||
const isSelected = isDefaultRole ? (currentRole === '' || currentRole === '默认') : (currentRole === role.name);
|
||||
const roleItem = document.createElement('div');
|
||||
roleItem.className = 'role-selection-item-main' + (isSelected ? ' selected' : '');
|
||||
roleItem.setAttribute('role', 'option');
|
||||
roleItem.tabIndex = 0;
|
||||
roleItem.onclick = () => {
|
||||
selectRole(role.name);
|
||||
closeRoleSelectionPanel(); // 选择后自动关闭面板
|
||||
};
|
||||
roleItem.onkeydown = (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
roleItem.click();
|
||||
}
|
||||
};
|
||||
const icon = getRoleIcon(role);
|
||||
|
||||
// 处理默认角色的描述
|
||||
@@ -221,6 +302,7 @@ function renderRoleSelectionSidebar() {
|
||||
if (isDefaultRole && !plainDesc) {
|
||||
description = _t('roles.defaultRoleDescription');
|
||||
}
|
||||
roleItem.setAttribute('data-selection-detail', description);
|
||||
|
||||
roleItem.innerHTML = `
|
||||
<div class="role-selection-item-icon-main">${icon}</div>
|
||||
|
||||
@@ -6,6 +6,156 @@ let alwaysVisibleBuiltinToolNames = new Set();
|
||||
// 全局工具状态映射,用于保存用户在所有页面的修改
|
||||
// key: 唯一工具标识符(toolKey),value: { enabled: boolean, is_external: boolean, external_mcp: string }
|
||||
let toolStateMap = new Map();
|
||||
let activeRobotEditor = '';
|
||||
|
||||
function settingsT(key, fallback) {
|
||||
if (typeof window.t === 'function') {
|
||||
const translated = window.t(key);
|
||||
if (translated && translated !== key) return translated;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getRobotStatus(type) {
|
||||
const value = (id) => document.getElementById(id)?.value?.trim() || '';
|
||||
const checked = (id) => document.getElementById(id)?.checked === true;
|
||||
let configured = false;
|
||||
let enabled = false;
|
||||
|
||||
if (type === 'wechat') {
|
||||
configured = !!value('robot-wechat-ilink-bot-id');
|
||||
enabled = checked('robot-wechat-enabled');
|
||||
} else if (type === 'wecom') {
|
||||
const agentId = parseInt(value('robot-wecom-agent-id'), 10);
|
||||
configured = !!(value('robot-wecom-token') && value('robot-wecom-corp-id') && value('robot-wecom-secret') && agentId > 0);
|
||||
enabled = checked('robot-wecom-enabled');
|
||||
} else if (type === 'dingtalk') {
|
||||
configured = !!(value('robot-dingtalk-client-id') && value('robot-dingtalk-client-secret'));
|
||||
enabled = checked('robot-dingtalk-enabled');
|
||||
} else if (type === 'lark') {
|
||||
configured = !!(value('robot-lark-app-id') && value('robot-lark-app-secret'));
|
||||
enabled = checked('robot-lark-enabled');
|
||||
} else if (type === 'telegram') {
|
||||
configured = !!value('robot-telegram-bot-token');
|
||||
enabled = checked('robot-telegram-enabled');
|
||||
} else if (type === 'slack') {
|
||||
configured = !!(value('robot-slack-bot-token') && value('robot-slack-app-token'));
|
||||
enabled = checked('robot-slack-enabled');
|
||||
} else if (type === 'discord') {
|
||||
configured = !!value('robot-discord-bot-token');
|
||||
enabled = checked('robot-discord-enabled');
|
||||
} else if (type === 'qq') {
|
||||
configured = !!(value('robot-qq-app-id') && value('robot-qq-client-secret'));
|
||||
enabled = checked('robot-qq-enabled');
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
return { state: 'enabled', text: settingsT('settings.robots.statusEnabled', '已启用') };
|
||||
}
|
||||
if (configured) {
|
||||
return { state: 'ready', text: settingsT('settings.robots.statusConfigured', '已配置') };
|
||||
}
|
||||
return { state: 'idle', text: settingsT('settings.robots.statusNotConfigured', '未配置') };
|
||||
}
|
||||
|
||||
function refreshRobotManager() {
|
||||
['wechat', 'wecom', 'dingtalk', 'lark', 'telegram', 'slack', 'discord', 'qq'].forEach((type) => {
|
||||
const status = getRobotStatus(type);
|
||||
const pill = document.getElementById(`robot-card-${type}-status`);
|
||||
if (pill) {
|
||||
pill.className = `robot-status-pill robot-status-pill--${status.state}`;
|
||||
pill.textContent = status.text;
|
||||
}
|
||||
const card = document.querySelector(`[data-robot-card="${type}"]`);
|
||||
if (card) {
|
||||
card.classList.toggle('is-active', activeRobotEditor === type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openRobotEditor(type) {
|
||||
activeRobotEditor = type;
|
||||
const empty = document.getElementById('robot-editor-empty');
|
||||
if (empty) empty.hidden = true;
|
||||
document.querySelectorAll('[data-robot-editor]').forEach((panel) => {
|
||||
panel.hidden = panel.dataset.robotEditor !== type;
|
||||
});
|
||||
refreshRobotManager();
|
||||
const panel = document.querySelector(`[data-robot-editor="${type}"]`);
|
||||
if (panel) {
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
|
||||
function openRobotCreateModal() {
|
||||
const modal = document.getElementById('robot-create-modal');
|
||||
if (modal) modal.style.display = 'block';
|
||||
}
|
||||
|
||||
function closeRobotCreateModal() {
|
||||
const modal = document.getElementById('robot-create-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function openRobotCommandsModal() {
|
||||
if (typeof openAppModal === 'function') {
|
||||
openAppModal('robot-commands-modal', { focus: false });
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('robot-commands-modal');
|
||||
if (modal) modal.style.display = 'block';
|
||||
}
|
||||
|
||||
function closeRobotCommandsModal() {
|
||||
if (typeof closeAppModal === 'function') {
|
||||
closeAppModal('robot-commands-modal');
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('robot-commands-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
function selectRobotType(type) {
|
||||
closeRobotCreateModal();
|
||||
openRobotEditor(type);
|
||||
}
|
||||
|
||||
function bindRobotManagerEvents() {
|
||||
const robotInputIds = [
|
||||
'robot-wechat-enabled', 'robot-wechat-ilink-bot-id',
|
||||
'robot-wecom-enabled', 'robot-wecom-token', 'robot-wecom-corp-id', 'robot-wecom-secret', 'robot-wecom-agent-id',
|
||||
'robot-dingtalk-enabled', 'robot-dingtalk-client-id', 'robot-dingtalk-client-secret',
|
||||
'robot-lark-enabled', 'robot-lark-app-id', 'robot-lark-app-secret',
|
||||
'robot-telegram-enabled', 'robot-telegram-bot-token', 'robot-telegram-bot-username', 'robot-telegram-allow-group',
|
||||
'robot-slack-enabled', 'robot-slack-bot-token', 'robot-slack-app-token',
|
||||
'robot-discord-enabled', 'robot-discord-bot-token', 'robot-discord-allow-guild',
|
||||
'robot-qq-enabled', 'robot-qq-app-id', 'robot-qq-client-secret', 'robot-qq-sandbox'
|
||||
];
|
||||
robotInputIds.forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && !el.dataset.robotManagerBound) {
|
||||
el.addEventListener('input', refreshRobotManager);
|
||||
el.addEventListener('change', refreshRobotManager);
|
||||
el.dataset.robotManagerBound = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
const modal = document.getElementById('robot-create-modal');
|
||||
if (modal && !modal.dataset.robotManagerBound) {
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) closeRobotCreateModal();
|
||||
});
|
||||
modal.dataset.robotManagerBound = 'true';
|
||||
}
|
||||
|
||||
const commandsModal = document.getElementById('robot-commands-modal');
|
||||
if (commandsModal && !commandsModal.dataset.robotManagerBound) {
|
||||
commandsModal.addEventListener('click', (event) => {
|
||||
if (event.target === commandsModal) closeRobotCommandsModal();
|
||||
});
|
||||
commandsModal.dataset.robotManagerBound = 'true';
|
||||
}
|
||||
}
|
||||
|
||||
// 生成工具的唯一标识符,用于区分同名但来源不同的工具
|
||||
function getToolKey(tool) {
|
||||
@@ -500,6 +650,10 @@ async function loadConfig(loadTools = true) {
|
||||
const wecom = robots.wecom || {};
|
||||
const dingtalk = robots.dingtalk || {};
|
||||
const lark = robots.lark || {};
|
||||
const telegram = robots.telegram || {};
|
||||
const slack = robots.slack || {};
|
||||
const discord = robots.discord || {};
|
||||
const qq = robots.qq || {};
|
||||
const wechatEnabled = document.getElementById('robot-wechat-enabled');
|
||||
if (wechatEnabled) wechatEnabled.checked = wechat.enabled === true;
|
||||
const wechatBase = document.getElementById('robot-wechat-base-url');
|
||||
@@ -539,6 +693,36 @@ async function loadConfig(loadTools = true) {
|
||||
if (larkAppSecret) larkAppSecret.value = lark.app_secret || '';
|
||||
const larkVerify = document.getElementById('robot-lark-verify-token');
|
||||
if (larkVerify) larkVerify.value = lark.verify_token || '';
|
||||
const telegramEnabled = document.getElementById('robot-telegram-enabled');
|
||||
if (telegramEnabled) telegramEnabled.checked = telegram.enabled === true;
|
||||
const telegramToken = document.getElementById('robot-telegram-bot-token');
|
||||
if (telegramToken) telegramToken.value = telegram.bot_token || '';
|
||||
const telegramUsername = document.getElementById('robot-telegram-bot-username');
|
||||
if (telegramUsername) telegramUsername.value = telegram.bot_username || '';
|
||||
const telegramAllowGroup = document.getElementById('robot-telegram-allow-group');
|
||||
if (telegramAllowGroup) telegramAllowGroup.checked = telegram.allow_group_messages === true;
|
||||
const slackEnabled = document.getElementById('robot-slack-enabled');
|
||||
if (slackEnabled) slackEnabled.checked = slack.enabled === true;
|
||||
const slackBotToken = document.getElementById('robot-slack-bot-token');
|
||||
if (slackBotToken) slackBotToken.value = slack.bot_token || '';
|
||||
const slackAppToken = document.getElementById('robot-slack-app-token');
|
||||
if (slackAppToken) slackAppToken.value = slack.app_token || '';
|
||||
const discordEnabled = document.getElementById('robot-discord-enabled');
|
||||
if (discordEnabled) discordEnabled.checked = discord.enabled === true;
|
||||
const discordToken = document.getElementById('robot-discord-bot-token');
|
||||
if (discordToken) discordToken.value = discord.bot_token || '';
|
||||
const discordAllowGuild = document.getElementById('robot-discord-allow-guild');
|
||||
if (discordAllowGuild) discordAllowGuild.checked = discord.allow_guild_messages === true;
|
||||
const qqEnabled = document.getElementById('robot-qq-enabled');
|
||||
if (qqEnabled) qqEnabled.checked = qq.enabled === true;
|
||||
const qqAppId = document.getElementById('robot-qq-app-id');
|
||||
if (qqAppId) qqAppId.value = qq.app_id || '';
|
||||
const qqSecret = document.getElementById('robot-qq-client-secret');
|
||||
if (qqSecret) qqSecret.value = qq.client_secret || '';
|
||||
const qqSandbox = document.getElementById('robot-qq-sandbox');
|
||||
if (qqSandbox) qqSandbox.checked = qq.sandbox === true;
|
||||
bindRobotManagerEvents();
|
||||
refreshRobotManager();
|
||||
|
||||
// 只有在需要时才加载工具列表(MCP管理页面需要,系统设置页面不需要)
|
||||
if (loadTools) {
|
||||
@@ -1425,6 +1609,31 @@ async function applySettings() {
|
||||
app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '',
|
||||
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '',
|
||||
allow_chat_id_fallback: !!(prevRobots.lark && prevRobots.lark.allow_chat_id_fallback)
|
||||
},
|
||||
telegram: {
|
||||
enabled: document.getElementById('robot-telegram-enabled')?.checked === true,
|
||||
bot_token: document.getElementById('robot-telegram-bot-token')?.value.trim() || '',
|
||||
bot_username: document.getElementById('robot-telegram-bot-username')?.value.trim() || '',
|
||||
allow_group_messages: document.getElementById('robot-telegram-allow-group')?.checked === true,
|
||||
...(prevRobots.telegram && typeof prevRobots.telegram === 'object' ? {
|
||||
update_offset: prevRobots.telegram.update_offset || 0
|
||||
} : {})
|
||||
},
|
||||
slack: {
|
||||
enabled: document.getElementById('robot-slack-enabled')?.checked === true,
|
||||
bot_token: document.getElementById('robot-slack-bot-token')?.value.trim() || '',
|
||||
app_token: document.getElementById('robot-slack-app-token')?.value.trim() || ''
|
||||
},
|
||||
discord: {
|
||||
enabled: document.getElementById('robot-discord-enabled')?.checked === true,
|
||||
bot_token: document.getElementById('robot-discord-bot-token')?.value.trim() || '',
|
||||
allow_guild_messages: document.getElementById('robot-discord-allow-guild')?.checked === true
|
||||
},
|
||||
qq: {
|
||||
enabled: document.getElementById('robot-qq-enabled')?.checked === true,
|
||||
app_id: document.getElementById('robot-qq-app-id')?.value.trim() || '',
|
||||
client_secret: document.getElementById('robot-qq-client-secret')?.value.trim() || '',
|
||||
sandbox: document.getElementById('robot-qq-sandbox')?.checked === true
|
||||
}
|
||||
},
|
||||
tools: []
|
||||
|
||||
+16
-10
@@ -223,12 +223,13 @@ function wsRenderRoleList() {
|
||||
var html = '';
|
||||
// 默认角色
|
||||
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-content-main"><div class="role-selection-item-name-main">' +
|
||||
(wsTOr('chat.defaultRole', '默认')) +
|
||||
'</div><div class="role-selection-item-description-main">' +
|
||||
(wsTOr('roles.defaultRoleDescription', '默认角色,不额外携带用户提示词,使用所有工具')) +
|
||||
escapeHtml(defDesc) +
|
||||
'</div></div>' +
|
||||
(defSelected ? '<div class="role-selection-checkmark-main">\u2713</div>' : '') +
|
||||
'</button>';
|
||||
@@ -238,10 +239,11 @@ function wsRenderRoleList() {
|
||||
if (!r.enabled) continue;
|
||||
if (r.name === '默认') continue; // 已在上方硬编码默认角色,跳过 API 返回的默认项
|
||||
var sel = (r.name === cur) ? ' selected' : '';
|
||||
html += '<button type="button" class="role-selection-item-main' + sel + '" onclick="wsSelectRole(\'' + r.name.replace(/'/g, "\\'") + '\')">' +
|
||||
'<div class="role-selection-item-icon-main">' + (r.icon || '\ud83d\udd35') + '</div>' +
|
||||
'<div class="role-selection-item-content-main"><div class="role-selection-item-name-main">' + r.name + '</div>' +
|
||||
'<div class="role-selection-item-description-main">' + (r.description || '').substring(0, 60) + '</div></div>' +
|
||||
var desc = r.description || '';
|
||||
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-icon-main">' + escapeHtml(r.icon || '\ud83d\udd35') + '</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>' : '') +
|
||||
'</button>';
|
||||
}
|
||||
@@ -1136,6 +1138,10 @@ function escapeHtml(s) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escapeHtmlAttr(s) {
|
||||
return escapeHtml(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeSingleQuotedShellArg(value) {
|
||||
var s = value == null ? '' : String(value);
|
||||
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>' +
|
||||
'</div>' +
|
||||
'<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="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') || 'Deep(DeepAgent)') + '</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="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="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\')" 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') || 'Deep(DeepAgent)') + '</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\')" 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\')" 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>' +
|
||||
'<input type="hidden" id="ws-agent-mode-select" value="eino_single" autocomplete="off" />' +
|
||||
'</div>' +
|
||||
|
||||
@@ -170,6 +170,9 @@ function showWechatBoundUI(wechat) {
|
||||
if (btn) {
|
||||
btn.textContent = wechatT('settings.robots.wechat.rebindButton', '重新绑定');
|
||||
}
|
||||
if (typeof refreshRobotManager === 'function') {
|
||||
refreshRobotManager();
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
@@ -342,6 +345,9 @@ async function pollWechatBindStatus() {
|
||||
bound: true
|
||||
});
|
||||
}
|
||||
if (typeof refreshRobotManager === 'function') {
|
||||
refreshRobotManager();
|
||||
}
|
||||
return;
|
||||
case 'need_verifycode':
|
||||
updateWechatSteps('scan');
|
||||
@@ -405,5 +411,8 @@ function refreshWechatRobotBoundUI(wechat) {
|
||||
if (btn) {
|
||||
btn.textContent = wechatT('settings.robots.wechat.bindButton', '生成二维码并绑定');
|
||||
}
|
||||
if (typeof refreshRobotManager === 'function') {
|
||||
refreshRobotManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+574
-52
@@ -44,6 +44,13 @@
|
||||
}
|
||||
|
||||
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) {
|
||||
if (typeof escapeHtml === 'function') return escapeHtml(text == null ? '' : String(text));
|
||||
@@ -103,17 +110,17 @@
|
||||
case 'start':
|
||||
return { input_keys: 'message, conversationId, projectId' };
|
||||
case 'tool':
|
||||
return { tool_name: '', arguments: '{}', timeout_seconds: '' };
|
||||
return { tool_name: '', arguments: '{}', timeout_seconds: '', join_strategy: 'all_merge' };
|
||||
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':
|
||||
return { expression: '{{previous.output}} != ""' };
|
||||
return { expression: '{{previous.output}} != ""', join_strategy: 'all_merge' };
|
||||
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':
|
||||
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':
|
||||
return { result_binding: { from: 'outputs', field: 'result' } };
|
||||
return { result_binding: { from: 'outputs', field: 'result' }, join_strategy: 'all_merge' };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -191,6 +198,19 @@
|
||||
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() {
|
||||
const container = document.getElementById('workflow-canvas');
|
||||
if (!container || typeof cytoscape !== 'function') return;
|
||||
@@ -260,6 +280,15 @@
|
||||
'border-width': 4,
|
||||
'border-color': '#fbbf24'
|
||||
}
|
||||
},
|
||||
{
|
||||
selector: 'node.just-added',
|
||||
style: {
|
||||
'border-width': 4,
|
||||
'border-color': '#fbbf24',
|
||||
'border-opacity': 1,
|
||||
'z-index': 999
|
||||
}
|
||||
}
|
||||
],
|
||||
layout: { name: 'preset' }
|
||||
@@ -291,6 +320,7 @@
|
||||
deleteWorkflowSelection();
|
||||
}
|
||||
});
|
||||
setupWorkflowResizeObserver(container);
|
||||
}
|
||||
|
||||
async function loadWorkflows(includeDisabled) {
|
||||
@@ -329,6 +359,79 @@
|
||||
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() {
|
||||
const list = document.getElementById('workflow-list');
|
||||
if (!list) return;
|
||||
@@ -336,12 +439,28 @@
|
||||
list.innerHTML = '<div class="empty-state">' + esc(_t('workflows.emptyList')) + '</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = workflows.map(wf => `
|
||||
<button type="button" class="workflow-list-item ${wf.id === currentWorkflowId ? 'is-active' : ''}" onclick="selectWorkflow(decodeURIComponent('${encodeURIComponent(wf.id)}'))">
|
||||
<span class="workflow-list-title">${esc(wf.name || wf.id)}</span>
|
||||
<span class="workflow-list-meta">${esc(wf.id)} · v${wf.version || 1} · ${wf.enabled ? esc(_t('workflows.statusEnabled')) : esc(_t('workflows.statusDisabled'))}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
list.innerHTML = workflows.map(wf => {
|
||||
const encodedId = encodeURIComponent(wf.id);
|
||||
const isActive = wf.id === currentWorkflowId;
|
||||
const toggleTitle = esc(_t('workflows.toggleEnabled'));
|
||||
const editTitle = esc(_t('workflows.editMeta'));
|
||||
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) {
|
||||
@@ -372,19 +491,12 @@
|
||||
}
|
||||
|
||||
function fillWorkflowForm(wf) {
|
||||
const data = wf || {};
|
||||
syncWorkflowMetaForm(data);
|
||||
currentWorkflowId = data.id ? data.id : '';
|
||||
initCy();
|
||||
const idEl = document.getElementById('workflow-id');
|
||||
const nameEl = document.getElementById('workflow-name');
|
||||
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());
|
||||
if (!cy) return;
|
||||
const graph = parseGraph(data.graph_json || data.graph || defaultGraph());
|
||||
resetSequences(graph);
|
||||
cy.elements().remove();
|
||||
cy.add(graphToElements(graph));
|
||||
@@ -411,8 +523,6 @@
|
||||
if (deleteBtn) deleteBtn.hidden = true;
|
||||
return;
|
||||
}
|
||||
cy.elements().unselect();
|
||||
selectedElement.select();
|
||||
empty.hidden = true;
|
||||
form.hidden = false;
|
||||
if (title) title.textContent = selectedElement.isNode() ? _t('workflows.nodeProperties') : _t('workflows.edgeProperties');
|
||||
@@ -420,6 +530,8 @@
|
||||
deleteBtn.hidden = false;
|
||||
deleteBtn.textContent = selectedElement.isNode() ? _t('workflows.deleteNode') : _t('workflows.deleteEdge');
|
||||
}
|
||||
cy.elements().unselect();
|
||||
selectedElement.select();
|
||||
const typeWrap = document.getElementById('workflow-prop-type-wrap');
|
||||
const label = document.getElementById('workflow-prop-label');
|
||||
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) {
|
||||
const wrap = document.getElementById('workflow-typed-config');
|
||||
if (!wrap || !ele) return;
|
||||
@@ -474,7 +621,17 @@
|
||||
: _t('workflows.config.edgeConditionHintExample');
|
||||
wrap.innerHTML = `
|
||||
${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;
|
||||
}
|
||||
@@ -485,6 +642,7 @@
|
||||
break;
|
||||
case 'tool':
|
||||
wrap.innerHTML = `
|
||||
${joinStrategyHtml(cfg)}
|
||||
<div class="form-group">
|
||||
<label for="workflow-tool-name">${esc(_t('workflows.config.mcpTool'))}</label>
|
||||
<select id="workflow-tool-name" onchange="updateWorkflowTypedConfig()">
|
||||
@@ -503,6 +661,7 @@
|
||||
break;
|
||||
case 'agent':
|
||||
wrap.innerHTML = `
|
||||
${joinStrategyHtml(cfg)}
|
||||
<div class="form-group">
|
||||
<label for="workflow-agent-mode">${esc(_t('workflows.config.agentMode'))}</label>
|
||||
<select id="workflow-agent-mode" onchange="updateWorkflowTypedConfig()">
|
||||
@@ -516,12 +675,15 @@
|
||||
break;
|
||||
case 'condition':
|
||||
wrap.innerHTML = `
|
||||
${joinStrategyHtml(cfg)}
|
||||
${typedField('workflow-condition-expression', _t('workflows.config.conditionExpression'), cfg.expression, '{{previous.output}} != ""')}
|
||||
<p class="workflow-config-hint">${_t('workflows.config.conditionHint')}</p>
|
||||
${conditionExpressionGuideHtml()}
|
||||
`;
|
||||
break;
|
||||
case 'hitl':
|
||||
wrap.innerHTML = `
|
||||
${joinStrategyHtml(cfg)}
|
||||
${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')}
|
||||
<p class="workflow-config-hint">${_t('workflows.config.hitlInteractiveHint')}</p>
|
||||
@@ -536,13 +698,14 @@
|
||||
break;
|
||||
case 'output':
|
||||
wrap.innerHTML = `
|
||||
${joinStrategyHtml(cfg)}
|
||||
${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')}
|
||||
${typedField('workflow-output-static', _t('workflows.config.staticValue'), cfg.static_value || '', _t('workflows.config.optional'))}
|
||||
`;
|
||||
break;
|
||||
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;
|
||||
default:
|
||||
wrap.innerHTML = '';
|
||||
@@ -579,9 +742,13 @@
|
||||
function readTypedConfig(ele) {
|
||||
if (!ele) return {};
|
||||
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 join_strategy = (document.getElementById('workflow-join-strategy') || {}).value || 'all_merge';
|
||||
switch (type) {
|
||||
case 'start':
|
||||
return { input_keys: (document.getElementById('workflow-start-input-keys') || {}).value || '' };
|
||||
@@ -589,31 +756,35 @@
|
||||
return {
|
||||
tool_name: (document.getElementById('workflow-tool-name') || {}).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':
|
||||
return {
|
||||
agent_mode: (document.getElementById('workflow-agent-mode') || {}).value || 'eino_single',
|
||||
input_binding: readBinding('workflow-agent-input'),
|
||||
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':
|
||||
return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '' };
|
||||
return { expression: (document.getElementById('workflow-condition-expression') || {}).value || '', join_strategy };
|
||||
case 'hitl':
|
||||
return {
|
||||
prompt: (document.getElementById('workflow-hitl-prompt') || {}).value || '',
|
||||
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':
|
||||
return {
|
||||
output_key: (document.getElementById('workflow-output-key') || {}).value || 'result',
|
||||
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':
|
||||
return { result_binding: readBinding('workflow-end-result') };
|
||||
return { result_binding: readBinding('workflow-end-result'), join_strategy };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -676,6 +847,100 @@
|
||||
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) {
|
||||
initCy();
|
||||
if (!cy) return;
|
||||
@@ -687,10 +952,15 @@
|
||||
label: wfNodeLabel(type),
|
||||
config: defaultConfigForType(type)
|
||||
},
|
||||
position: position || { x: 180 + cy.nodes().length * 28, y: 160 + cy.nodes().length * 28 }
|
||||
position: position || defaultNodePosition(type)
|
||||
});
|
||||
selectWorkflowElement(node);
|
||||
updateEmptyState();
|
||||
if (position) {
|
||||
highlightNewNode(node);
|
||||
} else {
|
||||
revealWorkflowNode(node);
|
||||
}
|
||||
}
|
||||
|
||||
window.refreshWorkflows = async function () {
|
||||
@@ -699,19 +969,27 @@
|
||||
if (list) list.innerHTML = '<div class="loading-spinner">' + esc(_t('common.loading')) + '</div>';
|
||||
try {
|
||||
await loadWorkflows(true);
|
||||
renderWorkflowList();
|
||||
if (!currentWorkflowId && workflows.length) {
|
||||
if (currentWorkflowId) {
|
||||
const wf = workflows.find(item => item.id === currentWorkflowId);
|
||||
if (wf) {
|
||||
syncWorkflowMetaForm(wf);
|
||||
}
|
||||
} else if (workflows.length) {
|
||||
fillWorkflowForm(workflows[0]);
|
||||
} else if (!workflows.length) {
|
||||
newWorkflowDraft();
|
||||
} else {
|
||||
newWorkflowDraft({ openMeta: false });
|
||||
return;
|
||||
}
|
||||
renderWorkflowList();
|
||||
} catch (error) {
|
||||
if (list) list.innerHTML = `<div class="empty-state">${esc(error.message)}</div>`;
|
||||
if (typeof showNotification === 'function') showNotification(error.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
window.newWorkflowDraft = function () {
|
||||
window.newWorkflowDraft = function (options) {
|
||||
const shouldOpenMeta = !options || options.openMeta !== false;
|
||||
currentWorkflowId = '';
|
||||
fillWorkflowForm({
|
||||
id: '',
|
||||
name: '',
|
||||
@@ -719,6 +997,10 @@
|
||||
enabled: true,
|
||||
graph_json: defaultGraph()
|
||||
});
|
||||
syncWorkflowMetaIdField(false, '');
|
||||
if (shouldOpenMeta) {
|
||||
openWorkflowMetaModal();
|
||||
}
|
||||
};
|
||||
|
||||
window.selectWorkflow = function (id) {
|
||||
@@ -726,6 +1008,100 @@
|
||||
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) {
|
||||
const errors = [];
|
||||
const nodes = graph.nodes || [];
|
||||
@@ -733,6 +1109,7 @@
|
||||
const ids = new Set(nodes.map(node => node.id));
|
||||
const starts = nodes.filter(node => node.type === 'start');
|
||||
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 (!outputs.length) errors.push(_t('workflows.validation.needOutput'));
|
||||
edges.forEach(edge => {
|
||||
@@ -746,6 +1123,15 @@
|
||||
outputs.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 === '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 => {
|
||||
if (!String((node.config || {}).tool_name || '').trim()) {
|
||||
errors.push(_t('workflows.validation.toolNeedsMcp', { label: node.label || node.id }));
|
||||
@@ -761,23 +1147,96 @@
|
||||
} else if (outEdges.length > 2) {
|
||||
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 => {
|
||||
if (!String((node.config || {}).output_key || '').trim()) {
|
||||
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 () {
|
||||
initCy();
|
||||
const id = document.getElementById('workflow-id').value.trim();
|
||||
const name = document.getElementById('workflow-name').value.trim();
|
||||
const description = document.getElementById('workflow-description').value.trim();
|
||||
const enabled = document.getElementById('workflow-enabled').checked;
|
||||
if (!id || !name) {
|
||||
showNotification(_t('workflows.idNameRequired'), 'error');
|
||||
const meta = readWorkflowMetaFromForm();
|
||||
if (!meta.id || !meta.name) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(_t('workflows.idNameRequired'), 'error');
|
||||
}
|
||||
openWorkflowMetaModal();
|
||||
return;
|
||||
}
|
||||
const graph = elementsToGraph();
|
||||
@@ -786,12 +1245,24 @@
|
||||
showNotification(errors.slice(0, 4).join(';'), 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await validateWorkflowGraphOnServer(graph);
|
||||
} catch (error) {
|
||||
showNotification(error.message || _t('workflows.validation.serverFailed'), 'error');
|
||||
return;
|
||||
}
|
||||
const method = currentWorkflowId ? 'PUT' : 'POST';
|
||||
const url = currentWorkflowId ? `/api/workflows/${encodeURIComponent(currentWorkflowId)}` : '/api/workflows';
|
||||
const response = await apiFetch(url, {
|
||||
method,
|
||||
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) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
@@ -799,7 +1270,9 @@
|
||||
return;
|
||||
}
|
||||
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');
|
||||
await refreshWorkflows();
|
||||
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 () {
|
||||
const id = currentWorkflowId || document.getElementById('workflow-id').value.trim();
|
||||
const meta = readWorkflowMetaFromForm();
|
||||
const id = currentWorkflowId || meta.id;
|
||||
if (!id) {
|
||||
showNotification(_t('workflows.selectToDelete'), 'warning');
|
||||
return;
|
||||
@@ -822,7 +1335,7 @@
|
||||
}
|
||||
currentWorkflowId = '';
|
||||
showNotification(_t('workflows.deleted'), 'success');
|
||||
newWorkflowDraft();
|
||||
newWorkflowDraft({ openMeta: false });
|
||||
await refreshWorkflows();
|
||||
};
|
||||
|
||||
@@ -927,6 +1440,14 @@
|
||||
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) {
|
||||
if (!selectedElement) return;
|
||||
const entries = Object.entries(stripTypedConfig(selectedElement));
|
||||
@@ -984,6 +1505,7 @@
|
||||
connectBtn.textContent = connectMode ? _t('workflows.connecting') : _t('workflows.connect');
|
||||
}
|
||||
refreshCanvasLabels();
|
||||
updateWorkflowCanvasTitle();
|
||||
renderWorkflowList();
|
||||
if (selectedElement && selectedElement.length) {
|
||||
selectWorkflowElement(selectedElement);
|
||||
|
||||
+366
-53
@@ -175,10 +175,10 @@
|
||||
</div>
|
||||
<div class="nav-item" data-page="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>
|
||||
<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>
|
||||
<span data-i18n="nav.hitl">人机协同</span>
|
||||
</div>
|
||||
@@ -1146,7 +1146,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<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 + Runner,MCP 工具(/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-content-main">
|
||||
<div class="role-selection-item-name-main" data-i18n="chat.agentModeEinoSingle">Eino 单代理(ADK)</div>
|
||||
@@ -1154,15 +1154,15 @@
|
||||
</div>
|
||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="eino_single">✓</div>
|
||||
</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-content-main">
|
||||
<div class="role-selection-item-name-main" data-i18n="chat.agentModeDeep">Deep(DeepAgent)</div>
|
||||
<div class="role-selection-item-description-main" data-i18n="chat.agentModeDeepHint">Eino DeepAgent,task 调度子代理</div>
|
||||
<div class="role-selection-item-description-main" data-i18n="chat.agentModeDeepHint">Eino DeepAgent,适合复杂安全测试、多阶段 task 子代理委派与汇总</div>
|
||||
</div>
|
||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="deep">✓</div>
|
||||
</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-content-main">
|
||||
<div class="role-selection-item-name-main" data-i18n="chat.agentModePlanExecuteLabel">Plan-Execute</div>
|
||||
@@ -1170,11 +1170,11 @@
|
||||
</div>
|
||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="plan_execute">✓</div>
|
||||
</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-content-main">
|
||||
<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-name-main" data-i18n="chat.agentModeSupervisorLabel">Supervisor(专家路由)</div>
|
||||
<div class="role-selection-item-description-main" data-i18n="chat.agentModeSupervisorHint">专家路由场景:监督者通过 transfer 动态分派多个专业子代理</div>
|
||||
</div>
|
||||
<div class="role-selection-checkmark-main agent-mode-check" data-agent-mode-check="supervisor">✓</div>
|
||||
</button>
|
||||
@@ -2583,16 +2583,17 @@
|
||||
</aside>
|
||||
<main class="workflow-main">
|
||||
<section class="workflow-meta-bar">
|
||||
<div class="workflow-meta-fields">
|
||||
<label><span data-i18n="workflows.metaId">ID</span> <input type="text" id="workflow-id" placeholder="web-scan-basic" autocomplete="off"></label>
|
||||
<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>
|
||||
<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>
|
||||
<label class="workflow-enabled-toggle"><input type="checkbox" id="workflow-enabled" checked> <span data-i18n="workflows.metaEnabled">启用</span></label>
|
||||
<div class="workflow-canvas-header">
|
||||
<div class="workflow-canvas-heading">
|
||||
<h3 id="workflow-canvas-title" class="workflow-canvas-title" data-i18n="workflows.untitled">未命名流程</h3>
|
||||
<span id="workflow-canvas-subtitle" class="workflow-canvas-subtitle" hidden></span>
|
||||
</div>
|
||||
</div>
|
||||
<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="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="dryRunWorkflowDraft()" data-i18n="workflows.dryRun">试运行</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>
|
||||
</div>
|
||||
@@ -2632,6 +2633,12 @@
|
||||
</div>
|
||||
<div id="workflow-custom-fields" class="workflow-custom-fields"></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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3300,8 +3307,106 @@
|
||||
<p class="settings-description" data-i18n="settings.robots.description">配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。</p>
|
||||
</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 开放平台 WebSocket,C2C 与群 @</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 -->
|
||||
<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-text">
|
||||
<h4 data-i18n="settings.robots.wechat.title">微信 / iLink</h4>
|
||||
@@ -3375,7 +3480,7 @@
|
||||
</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>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
@@ -3409,7 +3514,7 @@
|
||||
</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>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
@@ -3432,7 +3537,7 @@
|
||||
</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>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
@@ -3457,42 +3562,212 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsRobotsExtra.botCommandsTitle">机器人命令说明</h4>
|
||||
<p class="settings-description" data-i18n="settingsRobotsExtra.botCommandsDesc">在对话中可发送以下命令(支持中英文):</p>
|
||||
<!-- Telegram -->
|
||||
<div class="settings-subsection robot-editor-panel" data-robot-editor="telegram" hidden>
|
||||
<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>
|
||||
<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>
|
||||
<!-- Slack -->
|
||||
<div class="settings-subsection robot-editor-panel" data-robot-editor="slack" hidden>
|
||||
<h4 data-i18n="settings.robots.slack.title">Slack</h4>
|
||||
<div class="settings-form">
|
||||
<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>
|
||||
<ul class="robot-cmd-list">
|
||||
<li><code>列表</code> <code>list</code> — <span data-i18n="settingsRobotsExtra.botCmdList">列出所有对话标题与 ID | List conversations</span></li>
|
||||
<li><code>切换 <ID></code> <code>switch <ID></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>删除 <ID></code> <code>delete <ID></code> — <span data-i18n="settingsRobotsExtra.botCmdDelete">删除指定对话 | Delete conversation</span></li>
|
||||
</ul>
|
||||
<!-- Discord -->
|
||||
<div class="settings-subsection robot-editor-panel" data-robot-editor="discord" hidden>
|
||||
<h4 data-i18n="settings.robots.discord.title">Discord</h4>
|
||||
<div class="settings-form">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="robot-discord-enabled" class="modern-checkbox" />
|
||||
<span class="checkbox-custom"></span>
|
||||
<span class="checkbox-text" data-i18n="settings.robots.discord.enabled">启用 Discord 机器人</span>
|
||||
</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>
|
||||
<ul class="robot-cmd-list">
|
||||
<li><code>角色</code> <code>roles</code> — <span data-i18n="settingsRobotsExtra.botCmdRoles">列出所有可用角色 | List roles</span></li>
|
||||
<li><code>角色 <名></code> <code>role <name></code> — <span data-i18n="settingsRobotsExtra.botCmdRole">切换当前角色 | Switch role</span></li>
|
||||
</ul>
|
||||
<!-- QQ -->
|
||||
<div class="settings-subsection robot-editor-panel" data-robot-editor="qq" hidden>
|
||||
<h4 data-i18n="settings.robots.qq.title">QQ 机器人</h4>
|
||||
<div class="settings-form">
|
||||
<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>
|
||||
<ul class="robot-cmd-list">
|
||||
<li><code>项目</code> <code>projects</code> — <span data-i18n="settingsRobotsExtra.botCmdProjects">列出所有项目 | List projects</span></li>
|
||||
<li><code>新建项目 <名称></code> <code>new project <name></code> — <span data-i18n="settingsRobotsExtra.botCmdNewProject">创建项目并绑定当前对话 | Create & bind project</span></li>
|
||||
<li><code>绑定项目 <ID或名称></code> <code>bind project <ID|name></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>
|
||||
<div id="robot-create-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="robot-create-title">
|
||||
<div class="modal-content robot-create-modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="robot-create-title" data-i18n="settings.robots.createTitle">新建机器人</h2>
|
||||
<button type="button" class="modal-close" onclick="closeRobotCreateModal()" aria-label="Close"></button>
|
||||
</div>
|
||||
<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>切换 <ID></code> <code>switch <ID></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>删除 <ID></code> <code>delete <ID></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>角色 <名></code> <code>role <name></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>新建项目 <名称></code> <code>new project <name></code> — <span data-i18n="settingsRobotsExtra.botCmdNewProject">创建项目并绑定当前对话 | Create & bind project</span></li>
|
||||
<li><code>绑定项目 <ID或名称></code> <code>bind project <ID|name></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 class="settings-actions">
|
||||
@@ -3796,6 +4071,10 @@
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<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">
|
||||
<strong data-i18n="externalMcpModal.formatLabel">配置格式:</strong><span data-i18n="externalMcpModal.formatDesc">JSON对象,key为配置名称,value为配置内容。状态通过"启动/停止"按钮控制,无需在JSON中配置。</span><br>
|
||||
<strong data-i18n="externalMcpModal.configExample">配置示例:</strong><br>
|
||||
@@ -3825,10 +4104,6 @@
|
||||
</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 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 class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeExternalMCPModal()" data-i18n="common.cancel">取消</button>
|
||||
@@ -4037,6 +4312,44 @@
|
||||
window.ELK = elk;
|
||||
}
|
||||
</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()">×</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模态框 -->
|
||||
<div id="skill-modal" class="modal">
|
||||
|
||||
Reference in New Issue
Block a user